mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
fix(platform-server): harden platform location origin validation during SSR (#69184)
Add allowOriginChange option to ResolveUrlOptions in resolveUrl to enforce same-origin validation on resolved URLs. When set to false, it prevents any cross-origin changes (including HTTP/HTTPS URLs), aligning the emulated server-side platform location environment with browser security behavior. Refactor ServerPlatformLocation.replaceState to use allowOriginChange: false instead of manual comparison, hardening state change validation against cross-origin URLs. Add unit tests in url_spec.ts and platform_location_spec.ts for the origin validation changes. PR Close #69184
This commit is contained in:
@@ -25,6 +25,8 @@ import {ERROR_DETAILS_PAGE_BASE_URL} from './error_details_base_url';
|
||||
* - animations: 3000-3999
|
||||
* - router: 4000-4999
|
||||
* - platform-browser: 5000-5500
|
||||
* - service-worker: 5600-5699
|
||||
* - platform-server: 5700-5800
|
||||
*/
|
||||
export const enum RuntimeErrorCode {
|
||||
// Change Detection Errors
|
||||
|
||||
@@ -33,7 +33,8 @@ export class ServerPlatformLocation implements PlatformLocation {
|
||||
public readonly search: string = '';
|
||||
public readonly hash: string = '';
|
||||
private _hashUpdate = new Subject<LocationChangeEvent>();
|
||||
private _doc = inject(DOCUMENT);
|
||||
private readonly _doc = inject(DOCUMENT);
|
||||
private readonly origin = this._doc.location.origin;
|
||||
|
||||
constructor() {
|
||||
const config = inject(INITIAL_CONFIG, {optional: true});
|
||||
@@ -41,9 +42,9 @@ export class ServerPlatformLocation implements PlatformLocation {
|
||||
return;
|
||||
}
|
||||
if (config.url) {
|
||||
const {protocol, hostname, port, pathname, search, hash, href} = resolveUrl(
|
||||
const {protocol, hostname, port, pathname, search, hash, href, origin} = resolveUrl(
|
||||
config.url,
|
||||
this._doc.location.origin,
|
||||
this.origin,
|
||||
);
|
||||
this.protocol = protocol;
|
||||
this.hostname = hostname;
|
||||
@@ -52,6 +53,7 @@ export class ServerPlatformLocation implements PlatformLocation {
|
||||
this.search = search;
|
||||
this.hash = hash;
|
||||
this.href = href;
|
||||
this.origin = origin;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +95,9 @@ export class ServerPlatformLocation implements PlatformLocation {
|
||||
|
||||
replaceState(state: any, title: string, newUrl: string): void {
|
||||
const oldUrl = this.url;
|
||||
const {pathname, search, hash, href, protocol} = resolveUrl(newUrl, this._doc.location.origin);
|
||||
const {pathname, search, hash, href, protocol} = resolveUrl(newUrl, this.origin, {
|
||||
allowOriginChange: false,
|
||||
});
|
||||
const writableThis = this as Writable<this>;
|
||||
writableThis.pathname = pathname;
|
||||
writableThis.search = search;
|
||||
|
||||
@@ -21,8 +21,14 @@ const HTTP_OR_HTTPS_PROTOCOL_REGEX = /^https?:/i;
|
||||
export interface ResolveUrlOptions {
|
||||
/**
|
||||
* Allow protocol-relative URLs (e.g. `//example.com`).
|
||||
* @default false
|
||||
*/
|
||||
allowProtocolRelative?: boolean;
|
||||
/**
|
||||
* Allow origin changes.
|
||||
* @default true
|
||||
*/
|
||||
allowOriginChange?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,9 +64,10 @@ export function resolveUrl(
|
||||
try {
|
||||
resolved = new URL(urlStr);
|
||||
} catch {}
|
||||
const {allowProtocolRelative = false, allowOriginChange = true} = options;
|
||||
|
||||
if (resolved) {
|
||||
if (originUrl && !isSafeOriginChange(resolved, originUrl, urlStr)) {
|
||||
if (originUrl && !isSafeOriginChange(resolved, originUrl, urlStr, allowOriginChange)) {
|
||||
throwSuspiciousUrlError(urlStr);
|
||||
}
|
||||
|
||||
@@ -75,7 +82,7 @@ export function resolveUrl(
|
||||
if (!URL.canParse(urlStr, 'http://fake')) {
|
||||
throw new RuntimeError(
|
||||
RuntimeErrorCode.INVALID_URL,
|
||||
ngDevMode ? `Invalid URL: ${urlStr}` : urlStr,
|
||||
typeof ngDevMode === 'undefined' || ngDevMode ? `Invalid URL: ${urlStr}` : urlStr,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,15 +90,13 @@ export function resolveUrl(
|
||||
return null;
|
||||
}
|
||||
|
||||
const {allowProtocolRelative = false} = options;
|
||||
|
||||
// Check if we have a legitimate protocol-relative URL (starts with '//' and not a duplicate/backslash bypass)
|
||||
// and we are configured to allow and preserve standard cross-origin protocol-relative requests.
|
||||
if (urlStr.startsWith('//')) {
|
||||
if (!allowProtocolRelative) {
|
||||
throw new RuntimeError(
|
||||
RuntimeErrorCode.PROTOCOL_RELATIVE_URL_NOT_ALLOWED,
|
||||
ngDevMode
|
||||
typeof ngDevMode === 'undefined' || ngDevMode
|
||||
? `Protocol relative URLs are not allowed in this context. URL: ${urlStr}`
|
||||
: urlStr,
|
||||
);
|
||||
@@ -102,7 +107,7 @@ export function resolveUrl(
|
||||
|
||||
resolved = new URL(urlStr, origin);
|
||||
|
||||
if (!isSafeOriginChange(resolved, originUrl, urlStr)) {
|
||||
if (!isSafeOriginChange(resolved, originUrl, urlStr, allowOriginChange)) {
|
||||
throwSuspiciousUrlError(urlStr);
|
||||
}
|
||||
|
||||
@@ -115,7 +120,7 @@ export function resolveUrl(
|
||||
function throwSuspiciousUrlError(urlStr: string): never {
|
||||
throw new RuntimeError(
|
||||
RuntimeErrorCode.SUSPICIOUS_URL_CHANGE_ORIGIN,
|
||||
ngDevMode
|
||||
typeof ngDevMode === 'undefined' || ngDevMode
|
||||
? `URL ${urlStr} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`
|
||||
: urlStr,
|
||||
);
|
||||
@@ -127,8 +132,22 @@ function throwSuspiciousUrlError(urlStr: string): never {
|
||||
* @param resolved The resolved URL.
|
||||
* @param origin The origin URL.
|
||||
* @param urlStr The URL string.
|
||||
* @param allowOriginChange Whether to allow origin changes.
|
||||
* @returns True if the origin has changed in a safe way, false otherwise.
|
||||
*/
|
||||
function isSafeOriginChange(resolved: URL, origin: URL, urlStr: string): boolean {
|
||||
return origin.origin === resolved.origin || HTTP_OR_HTTPS_PROTOCOL_REGEX.test(urlStr);
|
||||
function isSafeOriginChange(
|
||||
resolved: URL,
|
||||
origin: URL,
|
||||
urlStr: string,
|
||||
allowOriginChange: boolean,
|
||||
): boolean {
|
||||
if (origin.origin === resolved.origin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!allowOriginChange) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return HTTP_OR_HTTPS_PROTOCOL_REGEX.test(urlStr);
|
||||
}
|
||||
|
||||
@@ -388,7 +388,7 @@ function validateAllowedHosts(url: string | undefined, allowedHosts: string[] |
|
||||
if (!isHostAllowed(hostname, allowedHostsSet)) {
|
||||
throw new RuntimeError(
|
||||
RuntimeErrorCode.HOST_NOT_ALLOWED,
|
||||
ngDevMode
|
||||
typeof ngDevMode === 'undefined' || ngDevMode
|
||||
? `Host ${url} is not allowed. You can configure \`allowedHosts\` option.`
|
||||
: url,
|
||||
);
|
||||
|
||||
@@ -168,5 +168,58 @@ import {INITIAL_CONFIG, platformServer} from '@angular/platform-server';
|
||||
);
|
||||
platform.destroy();
|
||||
});
|
||||
|
||||
it('should throw on replaceState with different origin', async () => {
|
||||
const platform = platformServer([
|
||||
{
|
||||
provide: INITIAL_CONFIG,
|
||||
useValue: {
|
||||
document: '<html><head></head><body></body></html>',
|
||||
url: 'http://test.com/deep/path',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const location = platform.injector.get(PlatformLocation);
|
||||
expect(() => location.replaceState(null, 'Title', 'http://attacker.com/foo')).toThrowError(
|
||||
`NG05703: URL http://attacker.com/foo changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`,
|
||||
);
|
||||
platform.destroy();
|
||||
});
|
||||
|
||||
it('should throw on pushState with different origin', async () => {
|
||||
const platform = platformServer([
|
||||
{
|
||||
provide: INITIAL_CONFIG,
|
||||
useValue: {
|
||||
document: '<html><head></head><body></body></html>',
|
||||
url: 'http://test.com/deep/path',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const location = platform.injector.get(PlatformLocation);
|
||||
expect(() => location.pushState(null, 'Title', 'http://attacker.com/foo')).toThrowError(
|
||||
`NG05703: URL http://attacker.com/foo changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`,
|
||||
);
|
||||
platform.destroy();
|
||||
});
|
||||
|
||||
it('should allow replaceState/pushState with same origin', async () => {
|
||||
const platform = platformServer([
|
||||
{
|
||||
provide: INITIAL_CONFIG,
|
||||
useValue: {
|
||||
document: '<html><head></head><body></body></html>',
|
||||
url: 'http://test.com/deep/path',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const location = platform.injector.get(PlatformLocation);
|
||||
expect(() => location.replaceState(null, 'Title', '/other-path')).not.toThrow();
|
||||
expect(() => location.pushState(null, 'Title', 'http://test.com/other-path')).not.toThrow();
|
||||
platform.destroy();
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -32,6 +32,26 @@ describe('resolveUrl', () => {
|
||||
expect(url.origin).toBe('http://other.com');
|
||||
});
|
||||
|
||||
it('should throw when allowOriginChange is false and origin changes', () => {
|
||||
expect(() =>
|
||||
resolveUrl('http://other.com/deep/path', 'http://test.com', {allowOriginChange: false}),
|
||||
).toThrowError(
|
||||
`NG05703: URL http://other.com/deep/path changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve same origin when allowOriginChange is false', () => {
|
||||
const url = resolveUrl('http://test.com/other-path', 'http://test.com', {
|
||||
allowOriginChange: false,
|
||||
});
|
||||
expect(url.href).toBe('http://test.com/other-path');
|
||||
});
|
||||
|
||||
it('should resolve relative paths when allowOriginChange is false', () => {
|
||||
const url = resolveUrl('/other-path', 'http://test.com', {allowOriginChange: false});
|
||||
expect(url.href).toBe('http://test.com/other-path');
|
||||
});
|
||||
|
||||
it('should throw an error for malformed absolute URLs', () => {
|
||||
const malformedUrls = [
|
||||
'http://evil.com:80:80/path',
|
||||
|
||||
Reference in New Issue
Block a user