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/core/src/errors.ts b/packages/core/src/errors.ts index b65f1f687782..d63e6b6f598c 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/domino_adapter.ts b/packages/platform-server/src/domino_adapter.ts index d9087ef04b45..29917a86abd5 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 '../third_party/domino/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 837a985db5c9..509aba309ce6 100644 --- a/packages/platform-server/src/http.ts +++ b/packages/platform-server/src/http.ts @@ -13,8 +13,10 @@ import { HttpHandlerFn, HttpRequest, } 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'; + +import {RuntimeErrorCode} from './errors'; import {resolveUrl} from './url'; @Injectable() @@ -35,7 +37,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/location.ts b/packages/platform-server/src/location.ts index 66c2e91816da..f5af5ec7e4de 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/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 3d4418506ff0..8dad4ac18c80 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: */ @@ -17,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; } /** @@ -54,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); } @@ -69,20 +80,26 @@ export function resolveUrl( // 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, + typeof ngDevMode === 'undefined' || ngDevMode ? `Invalid URL: ${urlStr}` : urlStr, + ); } if (!originUrl) { 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 Error(`Protocol relative URLs are not allowed in this context. URL: ${urlStr}`); + throw new RuntimeError( + RuntimeErrorCode.PROTOCOL_RELATIVE_URL_NOT_ALLOWED, + typeof ngDevMode === 'undefined' || ngDevMode + ? `Protocol relative URLs are not allowed in this context. URL: ${urlStr}` + : urlStr, + ); } return new URL(urlStr, origin); @@ -90,7 +107,7 @@ export function resolveUrl( resolved = new URL(urlStr, origin); - if (!isSafeOriginChange(resolved, originUrl, urlStr)) { + if (!isSafeOriginChange(resolved, originUrl, urlStr, allowOriginChange)) { throwSuspiciousUrlError(urlStr); } @@ -101,8 +118,11 @@ export function resolveUrl( * 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, + typeof ngDevMode === 'undefined' || ngDevMode + ? `URL ${urlStr} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.` + : urlStr, ); } @@ -112,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 60eaf332a99f..5f635a46c04e 100644 --- a/packages/platform-server/src/utils.ts +++ b/packages/platform-server/src/utils.ts @@ -22,9 +22,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'; @@ -384,7 +386,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, + 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 6b25b8d5de8a..f47c8e4c9d4a 100644 --- a/packages/platform-server/test/integration_spec.ts +++ b/packages/platform-server/test/integration_spec.ts @@ -1470,7 +1470,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.`, ); }, }); @@ -1597,7 +1597,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.`, ); }, }); @@ -1620,7 +1620,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 090c8e67ac10..da1a1986bc9a 100644 --- a/packages/platform-server/test/platform_location_spec.ts +++ b/packages/platform-server/test/platform_location_spec.ts @@ -147,7 +147,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(); }); @@ -164,9 +164,62 @@ 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(); }); + + 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 b348dbc608de..878a659d371f 100644 --- a/packages/platform-server/test/url_spec.ts +++ b/packages/platform-server/test/url_spec.ts @@ -21,7 +21,7 @@ describe('resolveUrl', () => { const urls = ['/\\attacker.com/deep/path', '\\\\attacker.com/deep/path']; for (const url of urls) { expect(() => resolveUrl(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.`, ); } }); @@ -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', @@ -51,7 +71,7 @@ describe('resolveUrl', () => { it('should throw on obfuscated protocols attempting to change origin', () => { const url = 'ht\ntp://evil.com/path'; expect(() => resolveUrl(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.`, ); }); });