Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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