Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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.',
Comment thread
alan-agius4 marked this conversation as resolved.
Outdated
);

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(',');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider: Is it worth validating that the provided names are valid hostnames (letters, numbers, dashes only I think)?

Less of a security thing and more just DX to check for typos.

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