diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 2da3d6edfaa..ea93304eb7c 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/location.ts b/packages/platform-server/src/location.ts index 66c2e91816d..f5af5ec7e4d 100644 --- a/packages/platform-server/src/location.ts +++ b/packages/platform-server/src/location.ts @@ -33,7 +33,8 @@ export class ServerPlatformLocation implements PlatformLocation { public readonly search: string = ''; public readonly hash: string = ''; private _hashUpdate = new Subject(); - 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; writableThis.pathname = pathname; writableThis.search = search; diff --git a/packages/platform-server/src/url.ts b/packages/platform-server/src/url.ts index e8a7c00f97d..8dad4ac18c8 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 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); } diff --git a/packages/platform-server/src/utils.ts b/packages/platform-server/src/utils.ts index 53d955d7169..5f635a46c04 100644 --- a/packages/platform-server/src/utils.ts +++ b/packages/platform-server/src/utils.ts @@ -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, ); diff --git a/packages/platform-server/test/platform_location_spec.ts b/packages/platform-server/test/platform_location_spec.ts index 0384de69b15..da1a1986bc9 100644 --- a/packages/platform-server/test/platform_location_spec.ts +++ b/packages/platform-server/test/platform_location_spec.ts @@ -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: '', + 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 55bf3bd59f0..878a659d371 100644 --- a/packages/platform-server/test/url_spec.ts +++ b/packages/platform-server/test/url_spec.ts @@ -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',