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
3 changes: 2 additions & 1 deletion goldens/public-api/angular/ssr/node/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ export class AngularNodeAppEngine {

// @public
export class CommonEngine {
constructor(options?: CommonEngineOptions | undefined);
constructor(options: CommonEngineOptions);
render(opts: CommonEngineRenderOptions): Promise<string>;
}

// @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 hosts that are allowed to access the server-side application.",
"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
3 changes: 1 addition & 2 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 @@ -102,6 +100,7 @@ export async function* serveWithVite(
// Disable auto CSP.
browserOptions.security = {
autoCsp: false,
allowedHosts: Array.isArray(serverOptions.allowedHosts) ? serverOptions.allowedHosts : [],
};

// Disable JSON build stats.
Expand Down
7 changes: 7 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,11 @@ export function generateAngularServerAppEngineManifest(
const manifestContent = `
export default {
basePath: '${basePath}',
allowedHosts: ${JSON.stringify(
allowedHosts.map((host) => host.replace(/^www\./i, '')),
undefined,
2,
)},
supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)},
entryPoints: {
${Object.entries(entryPoints)
Expand Down
51 changes: 50 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 @@ -20,6 +20,11 @@ import {
runMethodAndMeasurePerf,
} from './peformance-profiler';

/**
* Regular expression to match and remove the `www.` prefix from hostnames.
*/
const WWW_HOST_REGEX = /^www\./i;

const SSG_MARKER_REGEXP = /ng-server-context=["']\w*\|?ssg\|?\w*["']/;

export interface CommonEngineOptions {
Expand All @@ -31,6 +36,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 +72,17 @@ 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([
...options.allowedHosts.map((host) => host.replace(WWW_HOST_REGEX, '')),
'localhost',
'127.0.0.1',
'::1',
'[::1]',
]);

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

Expand All @@ -74,6 +91,10 @@ export class CommonEngine {
* render options
*/
async render(opts: CommonEngineRenderOptions): Promise<string> {
if (opts.url) {
this.validateHost(opts.url);
}

const enablePerformanceProfiler = this.options?.enablePerformanceProfiler;

const runMethod = enablePerformanceProfiler
Expand Down Expand Up @@ -102,6 +123,34 @@ export class CommonEngine {
return html;
}

private validateHost(url: string): void {
if (!URL.canParse(url)) {
throw new Error(`URL "${url}" is invalid.`);
}

const hostname = new URL(url).hostname.replace(WWW_HOST_REGEX, '');

if (this.allowedHosts.has(hostname)) {
return;
}

// Support wildcard hostnames.
for (const allowedHost of this.allowedHosts) {
if (!allowedHost.startsWith('*.')) {
continue;
}

const domain = allowedHost.slice(1);
if (hostname.endsWith(domain)) {
return;
}
}

throw new Error(
`Host ${hostname} is not allowed. Please provide a list of allowed hosts in the "allowedHosts" option.`,
);
}

private inlineCriticalCss(html: string, opts: CommonEngineRenderOptions): Promise<string> {
const outputPath =
opts.publicPath ?? (opts.documentFilePath ? dirname(opts.documentFilePath) : '');
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/headers';

/**
* 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();
}
23 changes: 22 additions & 1 deletion packages/angular/ssr/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { AngularServerApp, getOrCreateAngularServerApp } from './app';
import { Hooks } from './hooks';
import { getPotentialLocaleIdFromUrl, getPreferredLocale } from './i18n';
import { EntryPointExports, getAngularAppEngineManifest } from './manifest';
import { validateHeaders } from './utils/headers';
import { joinUrlParts } from './utils/url';

/**
Expand Down Expand Up @@ -45,6 +46,11 @@ export class AngularAppEngine {
*/
private readonly manifest = getAngularAppEngineManifest();

/**
* A set of allowed hostnames for the server application.
*/
private readonly allowedHosts: ReadonlySet<string> = new Set(this.manifest.allowedHosts);

/**
* A map of supported locales from the server application's manifest.
*/
Expand All @@ -67,10 +73,25 @@ export class AngularAppEngine {
*
* @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 If the `Host` or `X-Forwarded-Host` header value is not in the allowed hosts list, this function will return a 400 response.
* To resolve this, configure the `allowedHosts` option in `angular.json` and include the hostname.
* Path: `projects.[project-name].architect.build.options.security.allowedHosts`.
*/
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
const serverApp = await this.getAngularServerAppForRequest(request);
try {
validateHeaders(request, this.allowedHosts);
} catch (error) {
const body = error instanceof Error ? error.message : undefined;

return new Response(body, {
status: 400,
statusText: 'Bad Request',
headers: { 'Content-Type': 'text/plain' },
});
}

const serverApp = await this.getAngularServerAppForRequest(request);
if (serverApp) {
return serverApp.handle(request, requestContext);
}
Expand Down
6 changes: 5 additions & 1 deletion packages/angular/ssr/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/

import type { BootstrapContext } from '@angular/platform-browser';
import type { SerializableRouteTreeNode } from './routes/route-tree';
import { AngularBootstrap } from './utils/ng';

Expand Down Expand Up @@ -74,6 +73,11 @@ export interface AngularAppEngineManifest {
* - `value`: The url segment associated with that locale.
*/
readonly supportedLocales: Readonly<Record<string, string>>;

/**
* A readonly array of allowed hostnames.
*/
readonly allowedHosts: Readonly<string[]>;
}

/**
Expand Down
Loading