-
Notifications
You must be signed in to change notification settings - Fork 11.9k
fix(@angular/ssr): validate host headers to prevent header-based SSRF #32516
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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 | ||
| * 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| return { | ||
| ...options, | ||
| allowedHosts, | ||
| }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 { | ||||||
|
|
@@ -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 { | ||||||
|
|
@@ -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(); | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: What's the purpose of reading the |
||||||
| // 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.`, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||
| 'Fallbacking to client side rendering. This will become a 400 Bad Request in a future major version.\n', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||
| ); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| const enablePerformanceProfiler = this.options?.enablePerformanceProfiler; | ||||||
|
|
||||||
| const runMethod = enablePerformanceProfiler | ||||||
|
|
@@ -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> { | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: I recommend making this |
||||||
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: Is there any standard/precedent for |
||||||
|
|
||||||
| return allowedHosts; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| async function exists(path: fs.PathLike): Promise<boolean> { | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: formatting looks incorrect.