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:
Alan Agius
2026-06-05 10:18:38 +00:00
committed by Andrew Scott
parent abfb04a342
commit cf9d7fa0f8
6 changed files with 112 additions and 14 deletions
+2
View File
@@ -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
+8 -4
View File
@@ -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;
+28 -9
View File
@@ -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);
}
+1 -1
View File
@@ -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();
});
});
})();
+20
View File
@@ -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',