From acc1767ed0cf1f528b1a4b981417ac41179b3347 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:03:20 +0000 Subject: [PATCH 1/2] refactor(platform-server): replace standard Error with RuntimeError Update platform-server to use Angular 's native `RuntimeError` class. This aligns error throwing patterns in platform-server with other packages of the framework such as core, common, and platform-browser. For URL and host errors, the error messages are configured to return only the raw dynamic URL when `ngDevMode` is false (in production) to aid in troubleshooting without bloating production bundles. --- .../public-api/platform-server/index.api.md | 18 ++++++++++++ .../platform-server/src/domino_adapter.ts | 7 ++++- packages/platform-server/src/errors.ts | 21 ++++++++++++++ packages/platform-server/src/http.ts | 13 +++++++-- .../platform-server/src/platform-server.ts | 1 + .../platform-server/src/platform_state.ts | 8 ++++- packages/platform-server/src/url.ts | 23 ++++++++++++--- packages/platform-server/src/utils.ts | 9 +++++- .../platform-server/test/integration_spec.ts | 29 +++++++++++++++++-- .../test/platform_location_spec.ts | 4 +-- packages/platform-server/test/url_spec.ts | 4 +-- 11 files changed, 122 insertions(+), 15 deletions(-) create mode 100644 packages/platform-server/src/errors.ts diff --git a/goldens/public-api/platform-server/index.api.md b/goldens/public-api/platform-server/index.api.md index 2578436d5477..9b296af8474f 100644 --- a/goldens/public-api/platform-server/index.api.md +++ b/goldens/public-api/platform-server/index.api.md @@ -61,6 +61,24 @@ export function renderModule(moduleType: Type, options: { allowedHosts?: Readonly[]; }): Promise; +// @public +export const enum RuntimeErrorCode { + // (undocumented) + DISABLED_DOM_EMULATION_IN_NON_BROWSER = 5704, + // (undocumented) + GET_COOKIE_NOT_IMPLEMENTED = 5700, + // (undocumented) + HOST_NOT_ALLOWED = 5706, + // (undocumented) + INVALID_URL = 5701, + // (undocumented) + PROTOCOL_RELATIVE_URL_NOT_ALLOWED = 5702, + // (undocumented) + SUSPICIOUS_URL_CHANGE_ORIGIN = 5703, + // (undocumented) + XHR_NOT_LOADED = 5705 +} + // @public export class ServerModule { // (undocumented) diff --git a/packages/platform-server/src/domino_adapter.ts b/packages/platform-server/src/domino_adapter.ts index 3a70e56b91e8..4fe73382c137 100644 --- a/packages/platform-server/src/domino_adapter.ts +++ b/packages/platform-server/src/domino_adapter.ts @@ -7,8 +7,10 @@ */ import {ɵsetRootDomAdapter as setRootDomAdapter} from '@angular/common'; +import {ɵRuntimeError as RuntimeError} from '@angular/core'; import {ɵBrowserDomAdapter as BrowserDomAdapter} from '@angular/platform-browser'; +import {RuntimeErrorCode} from './errors'; import domino from './bundled-domino'; export function setDomTypes() { @@ -115,6 +117,9 @@ export class DominoAdapter extends BrowserDomAdapter { } override getCookie(name: string): string { - throw new Error('getCookie has not been implemented'); + throw new RuntimeError( + RuntimeErrorCode.GET_COOKIE_NOT_IMPLEMENTED, + (typeof ngDevMode === 'undefined' || ngDevMode) && 'getCookie has not been implemented', + ); } } diff --git a/packages/platform-server/src/errors.ts b/packages/platform-server/src/errors.ts new file mode 100644 index 000000000000..68bb0d768f3d --- /dev/null +++ b/packages/platform-server/src/errors.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * The list of error codes used in runtime code of the `platform-server` package. + * Reserved error code range: 5700-5800. + */ +export const enum RuntimeErrorCode { + GET_COOKIE_NOT_IMPLEMENTED = 5700, + INVALID_URL = 5701, + PROTOCOL_RELATIVE_URL_NOT_ALLOWED = 5702, + SUSPICIOUS_URL_CHANGE_ORIGIN = 5703, + DISABLED_DOM_EMULATION_IN_NON_BROWSER = 5704, + XHR_NOT_LOADED = 5705, + HOST_NOT_ALLOWED = 5706, +} diff --git a/packages/platform-server/src/http.ts b/packages/platform-server/src/http.ts index d977b3434279..b2ef27eb38ed 100644 --- a/packages/platform-server/src/http.ts +++ b/packages/platform-server/src/http.ts @@ -13,10 +13,15 @@ import { HttpRequest, ɵHTTP_ROOT_INTERCEPTOR_FNS as HTTP_ROOT_INTERCEPTOR_FNS, } from '@angular/common/http'; -import {inject, Injectable, Provider} from '@angular/core'; +import {inject, Injectable, Provider, ɵRuntimeError as RuntimeError} from '@angular/core'; import {Observable} from 'rxjs'; +<<<<<<< HEAD import {parseUrl} from './url'; +======= +import {RuntimeErrorCode} from './errors'; +import {resolveUrl} from './url'; +>>>>>>> 29b5cb51b0 (refactor(platform-server): replace standard Error with RuntimeError) @Injectable() export class ServerXhr implements XhrFactory { @@ -36,7 +41,11 @@ export class ServerXhr implements XhrFactory { build(): XMLHttpRequest { const impl = this.xhrImpl; if (!impl) { - throw new Error('Unexpected state in ServerXhr: XHR implementation is not loaded.'); + throw new RuntimeError( + RuntimeErrorCode.XHR_NOT_LOADED, + (typeof ngDevMode === 'undefined' || ngDevMode) && + 'Unexpected state in ServerXhr: XHR implementation is not loaded.', + ); } return new impl.XMLHttpRequest(); diff --git a/packages/platform-server/src/platform-server.ts b/packages/platform-server/src/platform-server.ts index 04f283a8199f..f17d2f627bb5 100644 --- a/packages/platform-server/src/platform-server.ts +++ b/packages/platform-server/src/platform-server.ts @@ -11,6 +11,7 @@ export {provideServerRendering} from './provide_server'; export {platformServer, ServerModule} from './server'; export {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens'; export {renderApplication, renderModule} from './utils'; +export {RuntimeErrorCode} from './errors'; export * from './private_export'; export {VERSION} from './version'; diff --git a/packages/platform-server/src/platform_state.ts b/packages/platform-server/src/platform_state.ts index c066fcebbcf4..6849fc7d82ed 100644 --- a/packages/platform-server/src/platform_state.ts +++ b/packages/platform-server/src/platform_state.ts @@ -14,8 +14,10 @@ import { Injector, ɵstartMeasuring as startMeasuring, ɵstopMeasuring as stopMeasuring, + ɵRuntimeError as RuntimeError, } from '@angular/core'; +import {RuntimeErrorCode} from './errors'; import {serializeDocument} from './domino_adapter'; import {ENABLE_DOM_EMULATION} from './tokens'; @@ -36,7 +38,11 @@ export class PlatformState { */ renderToString(): string { if (ngDevMode && !this._enableDomEmulation && !window?.document) { - throw new Error('Disabled DOM emulation should only run in browser environments'); + throw new RuntimeError( + RuntimeErrorCode.DISABLED_DOM_EMULATION_IN_NON_BROWSER, + (typeof ngDevMode === 'undefined' || ngDevMode) && + 'Disabled DOM emulation should only run in browser environments', + ); } const measuringLabel = 'renderToString'; diff --git a/packages/platform-server/src/url.ts b/packages/platform-server/src/url.ts index 8be88bdd8003..33bf8cf2f3d2 100644 --- a/packages/platform-server/src/url.ts +++ b/packages/platform-server/src/url.ts @@ -6,6 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ +import {ɵRuntimeError as RuntimeError} from '@angular/core'; + +import {RuntimeErrorCode} from './errors'; + /** * Matches http: or https: */ @@ -66,7 +70,10 @@ export function parseUrl( // absolute URL. Since it is malformed, the native URL constructor will throw a validation // error. Standard relative/protocol-relative paths parse successfully, allowing the flow to continue. if (!URL.canParse(urlStr, 'http://fake')) { - throw new Error(`Invalid URL: ${urlStr}`); + throw new RuntimeError( + RuntimeErrorCode.INVALID_URL, + ngDevMode ? `Invalid URL: ${urlStr}` : urlStr, + ); } if (!originUrl) { @@ -79,7 +86,12 @@ export function parseUrl( // and we are configured to allow and preserve standard cross-origin protocol-relative requests. if (urlStr.startsWith('//')) { if (!allowProtocolRelative) { - throw new Error(`Protocol relative URLs are not allowed in this context. URL: ${urlStr}`); + throw new RuntimeError( + RuntimeErrorCode.PROTOCOL_RELATIVE_URL_NOT_ALLOWED, + ngDevMode + ? `Protocol relative URLs are not allowed in this context. URL: ${urlStr}` + : urlStr, + ); } return new URL(urlStr, origin); @@ -98,8 +110,11 @@ export function parseUrl( * Throws a suspicious URL error indicating a security bypass attempt. */ function throwSuspiciousUrlError(urlStr: string): never { - throw new Error( - `URL ${urlStr} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, + throw new RuntimeError( + RuntimeErrorCode.SUSPICIOUS_URL_CHANGE_ORIGIN, + ngDevMode + ? `URL ${urlStr} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.` + : urlStr, ); } diff --git a/packages/platform-server/src/utils.ts b/packages/platform-server/src/utils.ts index badded739d35..eb91e02cb4e5 100644 --- a/packages/platform-server/src/utils.ts +++ b/packages/platform-server/src/utils.ts @@ -21,9 +21,11 @@ import { ɵSSR_CONTENT_INTEGRITY_MARKER as SSR_CONTENT_INTEGRITY_MARKER, ɵstartMeasuring as startMeasuring, ɵstopMeasuring as stopMeasuring, + ɵRuntimeError as RuntimeError, } from '@angular/core'; import {BootstrapContext} from '@angular/platform-browser'; +import {RuntimeErrorCode} from './errors'; import {platformServer} from './server'; import {PlatformState} from './platform_state'; import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens'; @@ -381,7 +383,12 @@ function validateAllowedHosts(url: string | undefined, allowedHosts: string[] | const hostname = parsedUrl.hostname; const allowedHostsSet: ReadonlySet = new Set(allowedHosts); if (!isHostAllowed(hostname, allowedHostsSet)) { - throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`); + throw new RuntimeError( + RuntimeErrorCode.HOST_NOT_ALLOWED, + ngDevMode + ? `Host ${url} is not allowed. You can configure \`allowedHosts\` option.` + : url, + ); } } } diff --git a/packages/platform-server/test/integration_spec.ts b/packages/platform-server/test/integration_spec.ts index 922037c5b591..e918ee64add3 100644 --- a/packages/platform-server/test/integration_spec.ts +++ b/packages/platform-server/test/integration_spec.ts @@ -1444,6 +1444,31 @@ class HiddenModule {} }); }); + it('prevents SSRF bypasses via backslash URLs in HttpClient by throwing a suspicious origin error', async () => { + const platform = platformServer([ + { + provide: INITIAL_CONFIG, + useValue: {document: '', url: 'http://localhost:4000/base'}, + }, + ]); + await platform.bootstrapModule(HttpClientExampleModule).then((ref) => { + const mock = ref.injector.get(HttpTestingController); + const http = ref.injector.get(HttpClient); + ref.injector.get(NgZone).run(() => { + http.get('/\\evil.com/api').subscribe({ + next: () => fail('Expected request to fail, but it succeeded.'), + error: (err) => { + expect(err.message).toBe( + `NG05703: URL /\\evil.com/api changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, + ); + }, + }); + + mock.verify(); + }); + }); + }); + it('can use HttpInterceptor that injects HttpClient', async () => { const platform = platformServer([ {provide: INITIAL_CONFIG, useValue: {document: ''}}, @@ -1567,7 +1592,7 @@ class HiddenModule {} next: () => fail(`Expected request for ${badUrl} to fail, but it succeeded.`), error: (err) => { expect(err.message).toBe( - `URL ${badUrl.trim()} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, + `NG05703: URL ${badUrl.trim()} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, ); }, }); @@ -1590,7 +1615,7 @@ class HiddenModule {} next: () => fail(`Expected request for ${badUrl} to fail, but it succeeded.`), error: (err) => { expect(err.message).toBe( - `URL ${badUrl.trim()} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, + `NG05703: URL ${badUrl.trim()} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, ); }, }); diff --git a/packages/platform-server/test/platform_location_spec.ts b/packages/platform-server/test/platform_location_spec.ts index 2f32de1a3c54..3e44c9ec5334 100644 --- a/packages/platform-server/test/platform_location_spec.ts +++ b/packages/platform-server/test/platform_location_spec.ts @@ -148,7 +148,7 @@ import {INITIAL_CONFIG, platformServer} from '@angular/platform-server'; ]); expect(() => platform.injector.get(DOCUMENT)).toThrowError( - `URL /\\attacker.com/deep/path changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, + `NG05703: URL /\\attacker.com/deep/path changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, ); platform.destroy(); }); @@ -165,7 +165,7 @@ import {INITIAL_CONFIG, platformServer} from '@angular/platform-server'; ]); expect(() => platform.injector.get(DOCUMENT)).toThrowError( - `Protocol relative URLs are not allowed in this context. URL: //attacker.com/deep/path`, + `NG05702: Protocol relative URLs are not allowed in this context. URL: //attacker.com/deep/path`, ); platform.destroy(); }); diff --git a/packages/platform-server/test/url_spec.ts b/packages/platform-server/test/url_spec.ts index dbb5c38f93a7..858224c8fa98 100644 --- a/packages/platform-server/test/url_spec.ts +++ b/packages/platform-server/test/url_spec.ts @@ -21,7 +21,7 @@ describe('parseUrl', () => { const urls = ['/\\attacker.com/deep/path', '\\\\attacker.com/deep/path']; for (const url of urls) { expect(() => parseUrl(url, 'http://test.com')).toThrowError( - `URL ${url} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, + `NG05703: URL ${url} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, ); } }); @@ -51,7 +51,7 @@ describe('parseUrl', () => { it('should throw on obfuscated protocols attempting to change origin', () => { const url = 'ht\ntp://evil.com/path'; expect(() => parseUrl(url, 'http://test.com')).toThrowError( - `URL ${url} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, + `NG05703: URL ${url} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, ); }); }); From 675eab026268a6355986e0f9235204dace12ba40 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:01:10 +0000 Subject: [PATCH 2/2] fix(platform-server): harden platform location origin validation during SSR Align ServerPlatformLocation state modification behavior (replaceState and pushState) with the browser's HTML5 History API by enforcing same-origin validation. In a browser environment, calling replaceState/pushState with a cross-origin URL throws a SecurityError. Previously, the emulated ServerPlatformLocation in platform-server silently allowed cross-origin state changes. If application code passed untrusted input to replaceState/pushState during SSR, this could cause the base URL to be changed, resulting in potential SSRF and credential leaks for relative HTTP requests. To mitigate this: 1. Add an `allowOriginChange` option to `ParseUrlOptions`. If false, `parseUrl` validates that the resolved URL's origin matches the base URL's origin. 2. Update `ServerPlatformLocation.replaceState` to call `parseUrl` with `allowOriginChange: false`. --- packages/core/src/errors.ts | 2 + packages/platform-server/src/http.ts | 6 +-- packages/platform-server/src/location.ts | 27 ++++++---- packages/platform-server/src/url.ts | 37 +++++++++---- packages/platform-server/src/utils.ts | 2 +- .../platform-server/test/integration_spec.ts | 2 +- .../test/platform_location_spec.ts | 53 +++++++++++++++++++ packages/platform-server/test/url_spec.ts | 20 +++++++ 8 files changed, 124 insertions(+), 25 deletions(-) diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 9d9a79e4e019..216670d0a3fc 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -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 diff --git a/packages/platform-server/src/http.ts b/packages/platform-server/src/http.ts index b2ef27eb38ed..387c70ee67b6 100644 --- a/packages/platform-server/src/http.ts +++ b/packages/platform-server/src/http.ts @@ -16,12 +16,8 @@ import { import {inject, Injectable, Provider, ɵRuntimeError as RuntimeError} from '@angular/core'; import {Observable} from 'rxjs'; -<<<<<<< HEAD -import {parseUrl} from './url'; -======= import {RuntimeErrorCode} from './errors'; -import {resolveUrl} from './url'; ->>>>>>> 29b5cb51b0 (refactor(platform-server): replace standard Error with RuntimeError) +import {parseUrl} from './url'; @Injectable() export class ServerXhr implements XhrFactory { diff --git a/packages/platform-server/src/location.ts b/packages/platform-server/src/location.ts index e52f963cd3ab..809e357a0012 100644 --- a/packages/platform-server/src/location.ts +++ b/packages/platform-server/src/location.ts @@ -33,20 +33,25 @@ export class ServerPlatformLocation implements PlatformLocation { public readonly search: string = ''; public readonly hash: string = ''; private _hashUpdate = new Subject(); + public readonly origin: string; constructor( @Inject(DOCUMENT) private _doc: any, @Optional() @Inject(INITIAL_CONFIG) _config: any, ) { + let origin = this._doc.location.origin; const config = _config as PlatformConfig | null; - if (!config) { - return; - } - if (config.url) { - const {protocol, hostname, port, pathname, search, hash, href} = parseUrl( - config.url, - this._doc.location.origin, - ); + if (config && config.url) { + const { + protocol, + hostname, + port, + pathname, + search, + hash, + href, + origin: parsedOrigin, + } = parseUrl(config.url, origin); this.protocol = protocol; this.hostname = hostname; this.port = port; @@ -54,7 +59,9 @@ export class ServerPlatformLocation implements PlatformLocation { this.search = search; this.hash = hash; this.href = href; + origin = parsedOrigin; } + this.origin = origin; } getBaseHrefFromDOM(): string { @@ -95,7 +102,9 @@ export class ServerPlatformLocation implements PlatformLocation { replaceState(state: any, title: string, newUrl: string): void { const oldUrl = this.url; - const {pathname, search, hash, href, protocol} = parseUrl(newUrl, this._doc.location.origin); + const {pathname, search, hash, href, protocol} = parseUrl(newUrl, this.origin, { + allowOriginChange: false, + }); const writableThis = this as Writable; writableThis.pathname = pathname; writableThis.search = search; diff --git a/packages/platform-server/src/url.ts b/packages/platform-server/src/url.ts index 33bf8cf2f3d2..c3afb81d8f12 100644 --- a/packages/platform-server/src/url.ts +++ b/packages/platform-server/src/url.ts @@ -21,8 +21,14 @@ const HTTP_OR_HTTPS_PROTOCOL_REGEX = /^https?:/i; export interface ParseUrlOptions { /** * Allow protocol-relative URLs (e.g. `//example.com`). + * @default false */ allowProtocolRelative?: boolean; + /** + * Allow origin changes. + * @default true + */ + allowOriginChange?: boolean; } /** @@ -55,9 +61,10 @@ export function parseUrl( 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); } @@ -72,7 +79,7 @@ export function parseUrl( 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, ); } @@ -80,15 +87,13 @@ export function parseUrl( 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, ); @@ -99,7 +104,7 @@ export function parseUrl( resolved = new URL(urlStr, origin); - if (!isSafeOriginChange(resolved, originUrl, urlStr)) { + if (!isSafeOriginChange(resolved, originUrl, urlStr, allowOriginChange)) { throwSuspiciousUrlError(urlStr); } @@ -112,7 +117,7 @@ export function parseUrl( 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, ); @@ -124,8 +129,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); } diff --git a/packages/platform-server/src/utils.ts b/packages/platform-server/src/utils.ts index eb91e02cb4e5..6d42305bcef6 100644 --- a/packages/platform-server/src/utils.ts +++ b/packages/platform-server/src/utils.ts @@ -385,7 +385,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, ); diff --git a/packages/platform-server/test/integration_spec.ts b/packages/platform-server/test/integration_spec.ts index e918ee64add3..253a6bf1cdc4 100644 --- a/packages/platform-server/test/integration_spec.ts +++ b/packages/platform-server/test/integration_spec.ts @@ -1569,7 +1569,7 @@ class HiddenModule {} next: () => fail('Expected request to fail, but it succeeded.'), error: (err) => { expect(err.message).toBe( - `URL /\\evil.com/api changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, + `NG05703: URL /\\evil.com/api changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`, ); }, }); diff --git a/packages/platform-server/test/platform_location_spec.ts b/packages/platform-server/test/platform_location_spec.ts index 3e44c9ec5334..d806bad0f13c 100644 --- a/packages/platform-server/test/platform_location_spec.ts +++ b/packages/platform-server/test/platform_location_spec.ts @@ -169,5 +169,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: '', + 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: '', + 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: '', + 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(); + }); }); })(); diff --git a/packages/platform-server/test/url_spec.ts b/packages/platform-server/test/url_spec.ts index 858224c8fa98..09c40ca61c4e 100644 --- a/packages/platform-server/test/url_spec.ts +++ b/packages/platform-server/test/url_spec.ts @@ -32,6 +32,26 @@ describe('parseUrl', () => { expect(url.origin).toBe('http://other.com'); }); + it('should throw when allowOriginChange is false and origin changes', () => { + expect(() => + parseUrl('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 = parseUrl('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 = parseUrl('/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',