Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions goldens/public-api/angular/ssr/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ import { Type } from '@angular/core';

// @public
export class AngularAppEngine {
constructor(options?: AngularAppEngineOptions);
handle(request: Request, requestContext?: unknown): Promise<Response | null>;
static ɵallowStaticRouteRender: boolean;
static ɵhooks: Hooks;
}

// @public
export interface AngularAppEngineOptions {
allowedHosts?: readonly string[];
}

// @public
export function createRequestHandler(handler: RequestHandlerFunction): RequestHandlerFunction;

Expand Down
9 changes: 7 additions & 2 deletions goldens/public-api/angular/ssr/node/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ import { Type } from '@angular/core';

// @public
export class AngularNodeAppEngine {
constructor();
handle(request: IncomingMessage | Http2ServerRequest, requestContext?: unknown): Promise<Response | null>;
constructor(options?: AngularNodeAppEngineOptions);
handle(request: IncomingMessage | Http2ServerRequest | Request, requestContext?: unknown): Promise<Response | null>;
}

// @public
export interface AngularNodeAppEngineOptions extends AngularAppEngineOptions {
}

// @public
Expand All @@ -27,6 +31,7 @@ export class CommonEngine {

// @public (undocumented)
export interface CommonEngineOptions {
allowedHosts?: readonly string[];
bootstrap?: Type<{}> | ((context: BootstrapContext) => Promise<ApplicationRef>);
enablePerformanceProfiler?: boolean;
providers?: StaticProvider[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export async function executeBuild(
verbose,
colors,
jsonLogs,
security,
} = options;

// TODO: Consider integrating into watch mode. Would require full rebuild on target changes.
Expand Down Expand Up @@ -263,7 +264,7 @@ export async function executeBuild(
if (serverEntryPoint) {
executionResult.addOutputFile(
SERVER_APP_ENGINE_MANIFEST_FILENAME,
generateAngularServerAppEngineManifest(i18nOptions, baseHref),
generateAngularServerAppEngineManifest(i18nOptions, security.allowedHosts, baseHref),
BuildOutputFileType.ServerRoot,
);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/angular/build/src/builders/application/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,8 +400,9 @@ export async function normalizeOptions(
}
}

const autoCsp = options.security?.autoCsp;
const { autoCsp, allowedHosts = [] } = options.security ?? {};
const security = {
allowedHosts,
autoCsp: autoCsp
? {
unsafeEval: autoCsp === true ? false : !!autoCsp.unsafeEval,
Expand Down
8 changes: 8 additions & 0 deletions packages/angular/build/src/builders/application/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@
"type": "object",
"additionalProperties": false,
"properties": {
"allowedHosts": {
"description": "A list of hostnames that are allowed to access the server-side application. For more information, see https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf.",
"type": "array",
"uniqueItems": true,
"items": {
"type": "string"
}
},
"autoCsp": {
"description": "Enables automatic generation of a hash-based Strict Content Security Policy (https://web.dev/articles/strict-csp#choose-hash) based on scripts in index.html. Will default to true once we are out of experimental/preview phases.",
"default": false,
Expand Down
12 changes: 9 additions & 3 deletions packages/angular/build/src/builders/dev-server/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import type { BuilderContext } from '@angular-devkit/architect';
import type { Plugin } from 'esbuild';
import assert from 'node:assert';
import { builtinModules, isBuiltin } from 'node:module';
import { join } from 'node:path';
import type { Connect, ViteDevServer } from 'vite';
import type { ComponentStyleRecord } from '../../../tools/vite/middlewares';
Expand All @@ -21,7 +20,6 @@ import { Result, ResultKind } from '../../application/results';
import { OutputHashing } from '../../application/schema';
import {
type ApplicationBuilderInternalOptions,
type ExternalResultMetadata,
JavaScriptTransformer,
getSupportedBrowsers,
isZonelessApp,
Expand Down Expand Up @@ -99,8 +97,16 @@ export async function* serveWithVite(
browserOptions.ssr ||= true;
}

// Disable auto CSP.
const allowedHosts = Array.isArray(serverOptions.allowedHosts)
? [...serverOptions.allowedHosts]
: [];

// Always allow the dev server host
allowedHosts.push(serverOptions.host);

browserOptions.security = {
allowedHosts,
// Disable auto CSP.
autoCsp: false,
};

Expand Down
3 changes: 3 additions & 0 deletions packages/angular/build/src/utils/server-rendering/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,13 @@ function escapeUnsafeChars(str: string): string {
*
* @param i18nOptions - The internationalization options for the application build. This
* includes settings for inlining locales and determining the output structure.
* @param allowedHosts - A list of hosts that are allowed to access the server-side application.
* @param baseHref - The base HREF for the application. This is used to set the base URL
* for all relative URLs in the application.
*/
export function generateAngularServerAppEngineManifest(
i18nOptions: NormalizedApplicationBuildOptions['i18nOptions'],
allowedHosts: string[],
baseHref: string | undefined,
): string {
const entryPoints: Record<string, string> = {};
Expand All @@ -84,6 +86,7 @@ export function generateAngularServerAppEngineManifest(
const manifestContent = `
export default {
basePath: '${basePath}',
allowedHosts: ${JSON.stringify(allowedHosts, undefined, 2)},
supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)},
entryPoints: {
${Object.entries(entryPoints)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ async function renderPages(
hasSsrEntry: !!outputFilesForWorker['server.mjs'],
} as RenderWorkerData,
execArgv: workerExecArgv,
env: {
'NG_ALLOWED_HOSTS': 'localhost',
},
});

try {
Expand Down Expand Up @@ -337,6 +340,9 @@ async function getAllRoutes(
hasSsrEntry: !!outputFilesForWorker['server.mjs'],
} as RoutesExtractorWorkerData,
execArgv: workerExecArgv,
env: {
'NG_ALLOWED_HOSTS': 'localhost',
},
});

try {
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/ssr/node/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export {
type CommonEngineOptions,
} from './src/common-engine/common-engine';

export { AngularNodeAppEngine } from './src/app-engine';
export { AngularNodeAppEngine, type AngularNodeAppEngineOptions } from './src/app-engine';

export { createNodeRequestHandler, type NodeRequestHandlerFunction } from './src/handler';
export { writeResponseToNodeResponse } from './src/response';
Expand Down
43 changes: 37 additions & 6 deletions packages/angular/ssr/node/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@
import { AngularAppEngine } from '@angular/ssr';
import type { IncomingMessage } from 'node:http';
import type { Http2ServerRequest } from 'node:http2';
import { AngularAppEngineOptions } from '../../src/app-engine';
import { getAllowedHostsFromEnv } from './environment-options';
import { attachNodeGlobalErrorHandlers } from './errors';
import { createWebRequestFromNodeRequest } from './request';

/**
* Options for the Angular Node.js server application engine.
*/
export interface AngularNodeAppEngineOptions extends AngularAppEngineOptions {}

/**
* Angular server application engine.
* Manages Angular server applications (including localized ones), handles rendering requests,
Expand All @@ -21,31 +28,55 @@ import { createWebRequestFromNodeRequest } from './request';
* application to ensure consistent handling of rendering requests and resource management.
*/
export class AngularNodeAppEngine {
private readonly angularAppEngine = new AngularAppEngine();
private readonly angularAppEngine: AngularAppEngine;

/**
* Creates a new instance of the Angular Node.js server application engine.
* @param options Options for the Angular Node.js server application engine.
*/
constructor(options?: AngularNodeAppEngineOptions) {
this.angularAppEngine = new AngularAppEngine({
...options,
allowedHosts: [...getAllowedHostsFromEnv(), ...(options?.allowedHosts ?? [])],
});

constructor() {
attachNodeGlobalErrorHandlers();
}

/**
* Handles an incoming HTTP request by serving prerendered content, performing server-side rendering,
* or delivering a static file for client-side rendered routes based on the `RenderMode` setting.
*
* This method adapts Node.js's `IncomingMessage` or `Http2ServerRequest`
* This method adapts Node.js's `IncomingMessage`, `Http2ServerRequest` or `Request`
* to a format compatible with the `AngularAppEngine` and delegates the handling logic to it.
*
* @param request - The incoming HTTP request (`IncomingMessage` or `Http2ServerRequest`).
* @param request - The incoming HTTP request (`IncomingMessage`, `Http2ServerRequest` or `Request`).
* @param requestContext - Optional context for rendering, such as metadata associated with the request.
* @returns A promise that resolves to the resulting HTTP response object, or `null` if no matching Angular route is found.
*
* @remarks A request to `https://www.example.com/page/index.html` will serve or render the Angular route
* corresponding to `https://www.example.com/page`.
*
* @remarks
* To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname
* of the `request.url` against a list of authorized hosts.
* If the hostname is not recognized and `allowedHosts` is not empty, a Client-Side Rendered (CSR) version of the
* page is returned otherwise a 400 Bad Request is returned.
*
* Resolution:
* Authorize your hostname by configuring `allowedHosts` in `angular.json` in:
* `projects.[project-name].architect.build.options.security.allowedHosts`.
* Alternatively, you can define the allowed hostname via the environment variable `process.env['NG_ALLOWED_HOSTS']`
* or pass it directly through the configuration options of `AngularNodeAppEngine`.
*
* For more information see: https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf
*/
async handle(
request: IncomingMessage | Http2ServerRequest,
request: IncomingMessage | Http2ServerRequest | Request,
requestContext?: unknown,
): Promise<Response | null> {
const webRequest = createWebRequestFromNodeRequest(request);
const webRequest =
request instanceof Request ? request : createWebRequestFromNodeRequest(request);

return this.angularAppEngine.handle(webRequest, requestContext);
}
Expand Down
47 changes: 46 additions & 1 deletion packages/angular/ssr/node/src/common-engine/common-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/plat
import * as fs from 'node:fs';
import { dirname, join, normalize, resolve } from 'node:path';
import { URL } from 'node:url';
import { validateUrl } from '../../../src/utils/validation';
import { getAllowedHostsFromEnv } from '../environment-options';
import { attachNodeGlobalErrorHandlers } from '../errors';
import { CommonEngineInlineCriticalCssProcessor } from './inline-css-processor';
import {
Expand All @@ -31,6 +33,9 @@ export interface CommonEngineOptions {

/** Enable request performance profiling data collection and printing the results in the server console. */
enablePerformanceProfiler?: boolean;

/** A set of hostnames that are allowed to access the server. */
allowedHosts?: readonly string[];
}

export interface CommonEngineRenderOptions {
Expand Down Expand Up @@ -64,8 +69,14 @@ export class CommonEngine {
private readonly templateCache = new Map<string, string>();
private readonly inlineCriticalCssProcessor = new CommonEngineInlineCriticalCssProcessor();
private readonly pageIsSSG = new Map<string, boolean>();
private readonly allowedHosts: ReadonlySet<string>;

constructor(private options?: CommonEngineOptions) {
this.allowedHosts = new Set([
...getAllowedHostsFromEnv(),
...(this.options?.allowedHosts ?? []),
]);

constructor(private options?: CommonEngineOptions | undefined) {
attachNodeGlobalErrorHandlers();
}

Expand All @@ -74,6 +85,40 @@ export class CommonEngine {
* render options
*/
async render(opts: CommonEngineRenderOptions): Promise<string> {
const { url } = opts;

if (url && URL.canParse(url)) {
const urlObj = new URL(url);
try {
validateUrl(urlObj, this.allowedHosts);
} catch (error) {
const isAllowedHostConfigured = this.allowedHosts.size > 0;
// eslint-disable-next-line no-console
console.error(
`ERROR: ${(error as Error).message}` +
'Please provide a list of allowed hosts in the "allowedHosts" option in the "CommonEngine" constructor.',
isAllowedHostConfigured
? ''
: '\nFallbacking to client side rendering. This will become a 400 Bad Request in a future major version.',
);

if (!isAllowedHostConfigured) {
// Fallback to CSR to avoid a breaking change.
// TODO(alanagius): Return a 400 and remove this fallback in the next major version (v22).
let document = opts.document;
if (!document && opts.documentFilePath) {
document = opts.document ?? (await this.getDocument(opts.documentFilePath));
}

if (document) {
return document;
}
}

throw error;
}
}

const enablePerformanceProfiler = this.options?.enablePerformanceProfiler;

const runMethod = enablePerformanceProfiler
Expand Down
29 changes: 29 additions & 0 deletions packages/angular/ssr/node/src/environment-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @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
*/

/**
* Retrieves the list of allowed hosts from the environment variable `NG_ALLOWED_HOSTS`.
* @returns An array of allowed hosts.
*/
export function getAllowedHostsFromEnv(): ReadonlyArray<string> {
const allowedHosts: string[] = [];
const envNgAllowedHosts = process.env['NG_ALLOWED_HOSTS'];
if (!envNgAllowedHosts) {
return allowedHosts;
}

const hosts = envNgAllowedHosts.split(',');
for (const host of hosts) {
const trimmed = host.trim();
if (trimmed.length > 0) {
allowedHosts.push(trimmed);
}
}

return allowedHosts;
}
19 changes: 1 addition & 18 deletions packages/angular/ssr/node/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import type { IncomingHttpHeaders, IncomingMessage } from 'node:http';
import type { Http2ServerRequest } from 'node:http2';
import { getFirstHeaderValue } from '../../src/utils/validation';

/**
* A set containing all the pseudo-headers defined in the HTTP/2 specification.
Expand Down Expand Up @@ -103,21 +104,3 @@ export function createRequestUrl(nodeRequest: IncomingMessage | Http2ServerReque

return new URL(`${protocol}://${hostnameWithPort}${originalUrl ?? url}`);
}

/**
* Extracts the first value from a multi-value header string.
*
* @param value - A string or an array of strings representing the header values.
* If it's a string, values are expected to be comma-separated.
* @returns The first trimmed value from the multi-value header, or `undefined` if the input is invalid or empty.
*
* @example
* ```typescript
* getFirstHeaderValue("value1, value2, value3"); // "value1"
* getFirstHeaderValue(["value1", "value2"]); // "value1"
* getFirstHeaderValue(undefined); // undefined
* ```
*/
function getFirstHeaderValue(value: string | string[] | undefined): string | undefined {
return value?.toString().split(',', 1)[0]?.trim();
}
2 changes: 1 addition & 1 deletion packages/angular/ssr/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

export * from './private_export';

export { AngularAppEngine } from './src/app-engine';
export { AngularAppEngine, type AngularAppEngineOptions } from './src/app-engine';
export { createRequestHandler, type RequestHandlerFunction } from './src/handler';

export {
Expand Down
Loading