refactor(platform-server): replace standard Error with RuntimeError (#69184)

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.

PR Close #69184
This commit is contained in:
Alan Agius
2026-06-05 10:03:20 +00:00
committed by Andrew Scott
parent 3960ad64e5
commit abfb04a342
11 changed files with 96 additions and 16 deletions
@@ -63,6 +63,24 @@ export function renderModule<T>(moduleType: Type<T>, options: {
allowedHosts?: Readonly<string>[];
}): Promise<string>;
// @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)
@@ -7,8 +7,11 @@
*/
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';
// g3-only import domino from 'domino';
import domino from '../third_party/domino/bundled-domino'; // 3p-only
@@ -116,6 +119,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',
);
}
}
+21
View File
@@ -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,
}
+8 -2
View File
@@ -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();
@@ -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';
@@ -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';
+19 -4
View File
@@ -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:
*/
@@ -69,7 +73,10 @@ 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,
ngDevMode ? `Invalid URL: ${urlStr}` : urlStr,
);
}
if (!originUrl) {
@@ -82,7 +89,12 @@ export function resolveUrl(
// 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);
@@ -101,8 +113,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,
ngDevMode
? `URL ${urlStr} changed origin unexpectedly. This is suspicious and may indicate a security bypass attempt.`
: urlStr,
);
}
+8 -1
View File
@@ -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<string> = 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,
);
}
}
}
@@ -1471,7 +1471,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.`,
);
},
});
@@ -1598,7 +1598,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.`,
);
},
});
@@ -1621,7 +1621,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.`,
);
},
});
@@ -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,7 +164,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();
});
+2 -2
View File
@@ -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.`,
);
}
});
@@ -51,7 +51,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.`,
);
});
});