Skip to content

fix(@angular/ssr): validate host headers to prevent header-based SSRF#32516

Open
alan-agius4 wants to merge 1 commit intoangular:mainfrom
alan-agius4:security-validate-main
Open

fix(@angular/ssr): validate host headers to prevent header-based SSRF#32516
alan-agius4 wants to merge 1 commit intoangular:mainfrom
alan-agius4:security-validate-main

Conversation

@alan-agius4
Copy link
Collaborator

@alan-agius4 alan-agius4 commented Feb 19, 2026

This change introduces strict validation for Host, X-Forwarded-Host, X-Forwarded-Proto, and X-Forwarded-Port headers in the Angular SSR request handling pipeline, including CommonEngine and AngularAppEngine.

Previously, the application engine constructed the base URL for server-side rendering using these headers without validation. This could allow an attacker to manipulate the headers to steer relative HttpClient requests to arbitrary internal or external hosts (SSRF).

The validation rules are:

  • Host and X-Forwarded-Host headers are validated against an authorized allowlist. While localhost is permitted by default, all other hostnames require explicit configuration.
  • Host and X-Forwarded-Host headers cannot contain path separators.
  • X-Forwarded-Port header must be numeric.
  • X-Forwarded-Proto header must be http or https.

Requests with invalid or disallowed headers will now log an error and fallback to Client-Side Rendering (CSR). In a future major version, these requests will be rejected with a 400 Bad Request.

Note: Most cloud providers and CDNs already validate these headers before the request reaches the text application, but this change adds an essential layer of defense-in-depth.

AngularAppEngine Users:
To exclude safe hosts from validation, configure the allowedHosts option in angular.json to include all domain names where your application is deployed.

Example configuration in angular.json:

"architect": {
  "build": {
    "options": {
      "security": {
        "allowedHosts": ["example.com", "*.trusted-example.com"]
      }
    }
  }
}

or

const appEngine = new AngularAppEngine({
  allowedHosts: ["example.com", "*.trusted-example.com"]
})

const appEngine = new AngularNodeAppEngine({
  allowedHosts: ["example.com", "*.trusted-example.com"]
})

CommonEngine Users:
If you are using CommonEngine, you must now provide the allowedHosts option when initializing or rendering your application.

Example:

const commonEngine = new CommonEngine({
  allowedHosts: ["example.com", "*.trusted-example.com"]
});

The application also respects NG_ALLOWED_HOSTS (comma-separated list) and HOSTNAME environment variables for authorizing hosts.

@alan-agius4 alan-agius4 added the target: minor This PR is targeted for the next minor release label Feb 19, 2026
@alan-agius4 alan-agius4 added target: rc This PR is targeted for the next release-candidate and removed target: minor This PR is targeted for the next minor release area: @angular/ssr labels Feb 19, 2026
@alan-agius4 alan-agius4 force-pushed the security-validate-main branch from a44aef7 to 8778533 Compare February 19, 2026 11:26
@alan-agius4 alan-agius4 force-pushed the security-validate-main branch 5 times, most recently from 757306a to de79ce3 Compare February 19, 2026 12:15
@alan-agius4 alan-agius4 requested a review from dgp1130 February 19, 2026 13:15
@alan-agius4 alan-agius4 added action: review The PR is still awaiting reviews from at least one requested reviewer labels Feb 19, 2026
@alan-agius4 alan-agius4 marked this pull request as ready for review February 19, 2026 13:15
@alan-agius4 alan-agius4 force-pushed the security-validate-main branch 3 times, most recently from 9030440 to faf18cf Compare February 19, 2026 14:46
This change introduces strict validation for `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-Port` headers in the Angular SSR request handling pipeline, including `CommonEngine` and `AngularAppEngine`.

Previously, the application engine constructed the base URL for server-side rendering using these headers without validation. This could allow an attacker to manipulate the headers to steer relative `HttpClient` requests to arbitrary internal or external hosts (SSRF).

The validation rules are:

- `Host` and `X-Forwarded-Host` headers are validated against an authorized allowlist. While `localhost` is permitted by default, all other hostnames require explicit configuration.
- `Host` and `X-Forwarded-Host` headers cannot contain path separators.
- `X-Forwarded-Port` header must be numeric.
- `X-Forwarded-Proto` header must be `http` or `https`.

Requests with invalid or disallowed headers will now log an error and fallback to Client-Side Rendering (CSR). In a future major version, these requests will be rejected with a 400 Bad Request.

Note: Most cloud providers and CDNs already validate these headers before the request reaches the text application, but this change adds an essential layer of defense-in-depth.

**AngularAppEngine Users:**
To exclude safe hosts from validation, configure the `allowedHosts` option in `angular.json` to include all domain names where your application is deployed.

Example configuration in `angular.json`:

```json
"architect": {
  "build": {
    "options": {
      "security": {
        "allowedHosts": ["example.com", "*.trusted-example.com"]
      }
    }
  }
}
```

or

```ts
const appEngine = new AngularAppEngine({
  allowedHosts: ["example.com", "*.trusted-example.com"]
})

const appEngine = new AngularNodeAppEngine({
  allowedHosts: ["example.com", "*.trusted-example.com"]
})
```

**CommonEngine Users:**
If you are using `CommonEngine`, you must now provide the `allowedHosts` option when initializing or rendering your application.

Example:
```typescript
const commonEngine = new CommonEngine({
  allowedHosts: ["example.com", "*.trusted-example.com"]
});
```

The application also respects `NG_ALLOWED_HOSTS` (comma-separated list) and `HOSTNAME` environment variables for authorizing hosts.
@alan-agius4 alan-agius4 force-pushed the security-validate-main branch from faf18cf to aa6ee92 Compare February 19, 2026 14:49
Copy link
Collaborator

@dgp1130 dgp1130 left a comment

Choose a reason for hiding this comment

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

Left some general suggestions, but nothing major. Feel free to ignore or come back to any of these later rather than treating them as blocking this PR. The two main things to confirm are really just:

  1. Should we commit to ${HOSTNAME} / ${NG_ALLOWED_HOSTS} right now, or come back later?
  2. Should we error when allowedHosts is configured and we receive a request for a disallowed host, to be up front about a misconfiguration rather than silently deoptimizing?

* @param options Options for the common engine.
* @returns A set of allowed hosts.
*/
private resolveAllowedHosts(options: CommonEngineOptions | undefined): ReadonlySet<string> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: I recommend making this static or a standalone function rather than calling a method in a constructor, when the class might not be fully initialized, as this can lead to unexpected behavior when accessing this. properties which haven't been initialized yet.

Comment on lines +91 to +94
let document = opts.document;
if (!document && opts.documentFilePath) {
document = await this.getDocument(opts.documentFilePath);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Question: What's the purpose of reading the document here? Why do we need this?

Comment on lines +236 to +239
const envHostName = processEnv['HOSTNAME']?.trim();
if (envHostName) {
allowedHosts.add(envHostName);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Question: Is there any standard/precedent for ${HOSTNAME}, or are you just making up a name here? Are we aware of any deployment infra which provides this name already?

Comment on lines +225 to +234
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);
}
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 allowedHosts to be specified in the build options in the short term until we come back to this in v22+?

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?

Comment on lines +91 to +105
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);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 @angular/ssr, so I feel like we should be able to move this into a separate file.


const headersTarget = target.headers;

return new Proxy(headersTarget, {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Consider: I know there's nothing we can do here which will be fully transparent and unobservable, but I'm wondering if there might be less churn if we overwrote specifically the req.headers['host' | 'x-forwarded-host'] properties with a getter? Something like:

for (const header of ['host', 'x-forwarded-host']) {
  const value = req.headers[header];
  delete req.headers[header];
  Object.defineProperty(req.headers, header, {
    get: () => {
      verifyHostAllowed(value, allowedHosts);
      return value;
    },
  });
}

Would that potentially be less likely to break things like instanceof / constructor checks and/or be more performant? Might also be less likely to break in more unique situations as Proxy is a pretty complex API and I'm less than certain that we're covering all the edge cases here.

Related: Even if we do stick with Proxy, do we need two of them? Could we make a proxy for headers and just assign it to Request.prototype.headers?


const key = name.toLowerCase();
if (HOST_HEADERS_TO_VALIDATE.has(key)) {
verifyHostAllowed(key, value, allowedHosts);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Observation: I could see this failing some apps who iterate through headers like:

for (const [name, value] of Object.entries(req.headers)) {
  if (!name.startsWith('x-myapp-*')) continue;

  doSomethingWith(name, value);
}

In this case, they're not really using host or x-forwarded-host, but they will read the values and probably trigger the CSR deopt. I don't think there's anything we can meaningfully do about this, but pointing out the case of iterating through all the headers which isn't too uncommon.

* @param options Options for the Angular server application engine.
*/
constructor(options?: AngularAppEngineOptions) {
const allowedHosts = new Set(this.manifest.allowedHosts);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Consider: Can we merge these in the constructor like new Set([...this.manifest.allowedHosts, ...options?.allowedHosts ?? []])?

request = secureRequest(request, this.allowedHosts);

try {
validateRequest(request, allowedHost);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Question: Won't validateRequest read the host and x-forwarded-host headers, triggering the deopt? Shouldn't we validate before securing the request?

// 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.`,
'Fallbacking to client side rendering. This will become a 400 Bad Request in a future major version.\n',

Choose a reason for hiding this comment

The 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
'Fallbacking to client side rendering. This will become a 400 Bad Request in a future major version.\n',
'Falling back to client side rendering for ${location}. This will become a 400 Bad Request in a future major version.\n',

}
// 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.`,

Choose a reason for hiding this comment

The 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 allowedHosts option is located.

* corresponding to `https://www.example.com/page`.
*
* @remarks
* To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname

Choose a reason for hiding this comment

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

nit: formatting looks incorrect.

}
}

const xForwardedPort = getFirstHeaderValue(headers.get('x-forwarded-port'));

Choose a reason for hiding this comment

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

Quick question: do we normalize headers and lowercase the names earlier in the code?

* corresponding to `https://www.example.com/page`.
*/
*
* @remarks

Choose a reason for hiding this comment

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

nit: formatting looks incorrect.

console.error(
`ERROR: Bad Request ("${request.url}").\n` +
msg +
'\nFallbacking to client side rendering. This will become a 400 Bad Request in a future major version.',

Choose a reason for hiding this comment

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

nit: typo + it'd be great to add info about the current URL (similar to one of the comments above).

Suggested change
'\nFallbacking to client side rendering. This will become a 400 Bad Request in a future major version.',
'\nFalling back to client side rendering. This will become a 400 Bad Request in a future major version.',

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: rc This PR is targeted for the next release-candidate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Comments