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
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
72 changes: 66 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,15 @@
import { AngularAppEngine } from '@angular/ssr';
import type { IncomingMessage } from 'node:http';
import type { Http2ServerRequest } from 'node:http2';
import { AngularAppEngineOptions } from '../../src/app-engine';
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,32 +27,86 @@ 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(this.resolveAppEngineOptions(options));

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

Choose a reason for hiding this comment

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

nit: formatting looks incorrect.

* of the `request.url` against a list of authorized hosts.
* If the hostname is not recognized, a Client-Side Rendered (CSR) version of the page 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 environment variables
* (`process.env['HOSTNAME']` or `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);
}

/**
* Resolves the Angular server application engine options.
* @param options Options for the Angular server application engine.
* @returns Resolved options for the Angular server application engine.
*/
private resolveAppEngineOptions(
options: AngularNodeAppEngineOptions | undefined,
): AngularAppEngineOptions {
const allowedHosts = options?.allowedHosts ? [...options.allowedHosts] : [];
const processEnv = process.env;

const envNgAllowedHosts = processEnv['NG_ALLOWED_HOSTS'];
if (envNgAllowedHosts) {
const hosts = envNgAllowedHosts.split(',');
for (const host of hosts) {
const hostTrimmed = host.trim();
if (hostTrimmed) {
allowedHosts.push(hostTrimmed);
}
}
}

const envHostName = processEnv['HOSTNAME']?.trim();
if (envHostName) {
allowedHosts.push(envHostName);
}
Comment on lines +91 to +105
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggestion: Can we consolidate and share the logic for resolving allowed hosts somewhere? They're both used in @angular/ssr, so I feel like we should be able to move this into a separate file.


return {
...options,
allowedHosts,
};
}
}
56 changes: 55 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,7 @@ 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 { attachNodeGlobalErrorHandlers } from '../errors';
import { CommonEngineInlineCriticalCssProcessor } from './inline-css-processor';
import {
Expand All @@ -31,6 +32,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 +68,11 @@ 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 = this.resolveAllowedHosts(options);

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

Expand All @@ -74,6 +81,25 @@ 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) {
let document = opts.document;
if (!document && opts.documentFilePath) {
document = await this.getDocument(opts.documentFilePath);
}
Comment on lines +91 to +94
Copy link
Collaborator

Choose a reason for hiding this comment

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

Question: What's the purpose of reading the document here? Why do we need this?

// eslint-disable-next-line no-console
console.error(
`ERROR: Host ${urlObj.hostname} is not allowed. Please provide a list of allowed hosts in the "allowedHosts" option.`,

Choose a reason for hiding this comment

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

nit: can we also refer to docs here? It's a bit unclear from the error message alone where the allowedHosts option is located.

'Fallbacking to client side rendering. This will become a 400 Bad Request in a future major version.\n',

Choose a reason for hiding this comment

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

nit: a minor typo + a proposal to include the current URL into the error message, so that it's easier to identify which pages are affected.

Suggested change
'Fallbacking to client side rendering. This will become a 400 Bad Request in a future major version.\n',
'Falling back to client side rendering for ${location}. This will become a 400 Bad Request in a future major version.\n',

);
}
}

const enablePerformanceProfiler = this.options?.enablePerformanceProfiler;

const runMethod = enablePerformanceProfiler
Expand Down Expand Up @@ -186,6 +212,34 @@ export class CommonEngine {

return doc;
}

/**
* Resolves the allowed hosts from the provided options and environment variables.
* @param options Options for the common engine.
* @returns A set of allowed hosts.
*/
private resolveAllowedHosts(options: CommonEngineOptions | undefined): ReadonlySet<string> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: I recommend making this static or a standalone function rather than calling a method in a constructor, when the class might not be fully initialized, as this can lead to unexpected behavior when accessing this. properties which haven't been initialized yet.

const allowedHosts = new Set(options?.allowedHosts ?? []);
const processEnv = process.env;

const envNgAllowedHosts = processEnv['NG_ALLOWED_HOSTS'];
if (envNgAllowedHosts) {
const hosts = envNgAllowedHosts.split(',');
for (const host of hosts) {
const hostTrimmed = host.trim();
if (hostTrimmed) {
allowedHosts.add(hostTrimmed);
}
}
}
Comment on lines +225 to +234
Copy link
Collaborator

Choose a reason for hiding this comment

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

Consider: Do you want to tackle this now, as something we'll probably have to support for a few versions, or leave it off and require allowedHosts to be specified in the build options in the short term until we come back to this in v22+?

I agree this is probably a good direction, but I'm wondering if it might benefit from more discussion, and I don't think we need to have it right now. Would it make sense to hold off before committing to a new public API here?


const envHostName = processEnv['HOSTNAME']?.trim();
if (envHostName) {
allowedHosts.add(envHostName);
}
Comment on lines +236 to +239
Copy link
Collaborator

Choose a reason for hiding this comment

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

Question: Is there any standard/precedent for ${HOSTNAME}, or are you just making up a name here? Are we aware of any deployment infra which provides this name already?


return allowedHosts;
}
}

async function exists(path: fs.PathLike): Promise<boolean> {
Expand Down
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