Skip to content

fix(@angular/ssr): introduce trustProxyHeaders option to safely vali…#33046

Open
alan-agius4 wants to merge 1 commit intoangular:19.2.xfrom
alan-agius4:trustproy-19
Open

fix(@angular/ssr): introduce trustProxyHeaders option to safely vali…#33046
alan-agius4 wants to merge 1 commit intoangular:19.2.xfrom
alan-agius4:trustproy-19

Conversation

@alan-agius4
Copy link
Copy Markdown
Collaborator

…date 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.

…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.
@alan-agius4 alan-agius4 requested a review from dgp1130 April 24, 2026 08:15
@alan-agius4 alan-agius4 added action: review The PR is still awaiting reviews from at least one requested reviewer target: lts This PR is targeting a version currently in long-term support labels Apr 24, 2026
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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.

Suggested change
const VALID_PREFIX_REGEX = /^\/([a-z0-9_-]+\/)*[a-z0-9_-]*$/i;
const VALID_PREFIX_REGEX = /^\/([a-z0-9_.-]+\/)*[a-z0-9_.-]*$/i;

Comment on lines +254 to +256
if (trustProxyHeaders === undefined) {
return new Set(['x-forwarded-host', 'x-forwarded-proto']);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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.

Suggested change
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']);
}

Comment on lines 125 to 132
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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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.

Suggested change
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,
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action: review The PR is still awaiting reviews from at least one requested reviewer area: @angular/ssr target: lts This PR is targeting a version currently in long-term support

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant