fix(@angular/ssr): introduce trustProxyHeaders option to safely vali…#33046
fix(@angular/ssr): introduce trustProxyHeaders option to safely vali…#33046alan-agius4 wants to merge 1 commit intoangular:19.2.xfrom
Conversation
…ate and sanitize proxy headers This commit adds the `trustProxyHeaders` option to `AngularAppEngineOptions` and `AngularNodeAppEngineOptions` to configure, validate, and sanitize `X-Forwarded-*` headers. - When `trustProxyHeaders` is `undefined` (default): - Allows `X-Forwarded-Host` and `X-Forwarded-Proto`. - Intercepts `X-Forwarded-Prefix` and triggers a dynamic CSR deoptimization to skip SSR if present. - Logs an informative message when receiving any other `X-Forwarded-*` headers. - When `false`: - Ignores and strips all proxy headers from the request. - When `true`: - Trusts all proxy headers. - When a string array: - Allows only the proxy headers provided inside the array.
There was a problem hiding this comment.
Code Review
This pull request introduces a trustProxyHeaders configuration option for the Angular SSR engine, allowing developers to specify which X-Forwarded-* headers should be trusted during request processing. It replaces the previous header patching mechanism with a sanitization process that can trigger a fallback to client-side rendering (CSR) when untrusted proxy headers are detected. The changes also include enhanced validation for host and prefix headers, along with security warnings for permissive host configurations. Feedback focuses on a regression in the prefix validation regex that excludes dots, the omission of x-forwarded-for from default trusted headers, and a more efficient approach for creating derived requests with modified headers.
| * Regular expression to validate that the prefix is valid. | ||
| */ | ||
| const INVALID_PREFIX_REGEX = /^[/\\]{2}|(?:^|[/\\])\.\.?(?:[/\\]|$)/; | ||
| const VALID_PREFIX_REGEX = /^\/([a-z0-9_-]+\/)*[a-z0-9_-]*$/i; |
There was a problem hiding this comment.
The VALID_PREFIX_REGEX is too restrictive as it does not allow dots (.). This is a regression from the previous implementation and will break applications that use dots in their URL paths (e.g., for versioning like /v1.2/ or for /.well-known/ directories).
Consider allowing dots in the character set while ensuring that directory traversal segments (like ..) are still blocked.
| const VALID_PREFIX_REGEX = /^\/([a-z0-9_-]+\/)*[a-z0-9_-]*$/i; | |
| const VALID_PREFIX_REGEX = /^\/([a-z0-9_.-]+\/)*[a-z0-9_.-]*$/i; |
| if (trustProxyHeaders === undefined) { | ||
| return new Set(['x-forwarded-host', 'x-forwarded-proto']); | ||
| } |
There was a problem hiding this comment.
In the default configuration (trustProxyHeaders is undefined), x-forwarded-for is not included in the allowed list. This will cause any request passing through a standard proxy that adds this header to trigger a CSR deoptimization and log a warning. Since X-Forwarded-For is a standard and generally safe header, it should likely be allowed by default to maintain compatibility with common proxy setups.
| if (trustProxyHeaders === undefined) { | |
| return new Set(['x-forwarded-host', 'x-forwarded-proto']); | |
| } | |
| if (trustProxyHeaders === undefined) { | |
| return new Set(['x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-for']); | |
| } |
| const clonedReq = new Request(request.clone(), { | ||
| signal: request.signal, | ||
| }); | ||
|
|
||
| const headers = clonedReq.headers; | ||
|
|
||
| const originalGet = headers.get; | ||
| // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion | ||
| (headers.get as typeof originalGet) = function (name) { | ||
| const value = originalGet.call(headers, name); | ||
| if (!value) { | ||
| return value; | ||
| } | ||
|
|
||
| validateHeader(name, value, allowedHosts, onError); | ||
|
|
||
| return value; | ||
| }; | ||
|
|
||
| const originalValues = headers.values; | ||
| // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion | ||
| (headers.values as typeof originalValues) = function () { | ||
| for (const name of HOST_HEADERS_TO_VALIDATE) { | ||
| validateHeader(name, originalGet.call(headers, name), allowedHosts, onError); | ||
| } | ||
|
|
||
| return originalValues.call(headers); | ||
| }; | ||
|
|
||
| const originalEntries = headers.entries; | ||
| // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion | ||
| (headers.entries as typeof originalEntries) = function () { | ||
| const iterator = originalEntries.call(headers); | ||
|
|
||
| return { | ||
| next() { | ||
| const result = iterator.next(); | ||
| if (!result.done) { | ||
| const [key, value] = result.value; | ||
| validateHeader(key, value, allowedHosts, onError); | ||
| } | ||
|
|
||
| return result; | ||
| }, | ||
| [Symbol.iterator]() { | ||
| return this; | ||
| }, | ||
| }; | ||
| }; | ||
|
|
||
| // Ensure for...of loops use the new patched entries | ||
| (headers[Symbol.iterator] as typeof originalEntries) = headers.entries; | ||
|
|
||
| return { request: clonedReq, onError: onErrorPromise }; | ||
| } | ||
|
|
||
| /** | ||
| * Validates a specific header value against the allowed hosts. | ||
| * @param name - The name of the header to validate. | ||
| * @param value - The value of the header to validate. | ||
| * @param allowedHosts - A set of allowed hostnames. | ||
| * @param onError - A callback function to call if the header value is invalid. | ||
| * @throws Error if the header value is invalid. | ||
| */ | ||
| function validateHeader( | ||
| name: string, | ||
| value: string | null, | ||
| allowedHosts: ReadonlySet<string>, | ||
| onError: (value: Error) => void, | ||
| ): void { | ||
| if (!value) { | ||
| return; | ||
| for (const key of keysToDelete) { | ||
| headers.delete(key); | ||
| } |
There was a problem hiding this comment.
Creating a new Request by cloning the original one and then manually modifying headers is inefficient. A more performant approach is to create a new Headers object from the original request, perform the deletions, and then use the Request constructor to create a derived request. This avoids redundant cloning of the request metadata.
| const clonedReq = new Request(request.clone(), { | |
| signal: request.signal, | |
| }); | |
| const headers = clonedReq.headers; | |
| const originalGet = headers.get; | |
| // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion | |
| (headers.get as typeof originalGet) = function (name) { | |
| const value = originalGet.call(headers, name); | |
| if (!value) { | |
| return value; | |
| } | |
| validateHeader(name, value, allowedHosts, onError); | |
| return value; | |
| }; | |
| const originalValues = headers.values; | |
| // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion | |
| (headers.values as typeof originalValues) = function () { | |
| for (const name of HOST_HEADERS_TO_VALIDATE) { | |
| validateHeader(name, originalGet.call(headers, name), allowedHosts, onError); | |
| } | |
| return originalValues.call(headers); | |
| }; | |
| const originalEntries = headers.entries; | |
| // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion | |
| (headers.entries as typeof originalEntries) = function () { | |
| const iterator = originalEntries.call(headers); | |
| return { | |
| next() { | |
| const result = iterator.next(); | |
| if (!result.done) { | |
| const [key, value] = result.value; | |
| validateHeader(key, value, allowedHosts, onError); | |
| } | |
| return result; | |
| }, | |
| [Symbol.iterator]() { | |
| return this; | |
| }, | |
| }; | |
| }; | |
| // Ensure for...of loops use the new patched entries | |
| (headers[Symbol.iterator] as typeof originalEntries) = headers.entries; | |
| return { request: clonedReq, onError: onErrorPromise }; | |
| } | |
| /** | |
| * Validates a specific header value against the allowed hosts. | |
| * @param name - The name of the header to validate. | |
| * @param value - The value of the header to validate. | |
| * @param allowedHosts - A set of allowed hostnames. | |
| * @param onError - A callback function to call if the header value is invalid. | |
| * @throws Error if the header value is invalid. | |
| */ | |
| function validateHeader( | |
| name: string, | |
| value: string | null, | |
| allowedHosts: ReadonlySet<string>, | |
| onError: (value: Error) => void, | |
| ): void { | |
| if (!value) { | |
| return; | |
| for (const key of keysToDelete) { | |
| headers.delete(key); | |
| } | |
| const headers = new Headers(request.headers); | |
| for (const key of keysToDelete) { | |
| headers.delete(key); | |
| } | |
| const clonedReq = new Request(request, { | |
| headers, | |
| }); |
…date and sanitize proxy headers
This commit adds the
trustProxyHeadersoption toAngularAppEngineOptionsandAngularNodeAppEngineOptionsto configure, validate, and sanitizeX-Forwarded-*headers.trustProxyHeadersisundefined(default):X-Forwarded-HostandX-Forwarded-Proto.X-Forwarded-Prefixand triggers a dynamic CSR deoptimization to skip SSR if present.X-Forwarded-*headers.false:true: