fix(@angular/ssr): validate host headers to prevent header-based SSRF#32516
fix(@angular/ssr): validate host headers to prevent header-based SSRF#32516alan-agius4 wants to merge 1 commit intoangular:mainfrom
Conversation
a44aef7 to
8778533
Compare
757306a to
de79ce3
Compare
9030440 to
faf18cf
Compare
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.
faf18cf to
aa6ee92
Compare
dgp1130
left a comment
There was a problem hiding this comment.
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:
- Should we commit to
${HOSTNAME}/${NG_ALLOWED_HOSTS}right now, or come back later? - Should we error when
allowedHostsis 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> { |
There was a problem hiding this comment.
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.
| let document = opts.document; | ||
| if (!document && opts.documentFilePath) { | ||
| document = await this.getDocument(opts.documentFilePath); | ||
| } |
There was a problem hiding this comment.
Question: What's the purpose of reading the document here? Why do we need this?
| const envHostName = processEnv['HOSTNAME']?.trim(); | ||
| if (envHostName) { | ||
| allowedHosts.add(envHostName); | ||
| } |
There was a problem hiding this comment.
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?
| 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); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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?
| 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); | ||
| } |
There was a problem hiding this comment.
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, { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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.
| '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.`, |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
nit: formatting looks incorrect.
| } | ||
| } | ||
|
|
||
| const xForwardedPort = getFirstHeaderValue(headers.get('x-forwarded-port')); |
There was a problem hiding this comment.
Quick question: do we normalize headers and lowercase the names earlier in the code?
| * corresponding to `https://www.example.com/page`. | ||
| */ | ||
| * | ||
| * @remarks |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
nit: typo + it'd be great to add info about the current URL (similar to one of the comments above).
| '\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.', |
This change introduces strict validation for
Host,X-Forwarded-Host,X-Forwarded-Proto, andX-Forwarded-Portheaders in the Angular SSR request handling pipeline, includingCommonEngineandAngularAppEngine.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
HttpClientrequests to arbitrary internal or external hosts (SSRF).The validation rules are:
HostandX-Forwarded-Hostheaders are validated against an authorized allowlist. Whilelocalhostis permitted by default, all other hostnames require explicit configuration.HostandX-Forwarded-Hostheaders cannot contain path separators.X-Forwarded-Portheader must be numeric.X-Forwarded-Protoheader must behttporhttps.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
allowedHostsoption inangular.jsonto include all domain names where your application is deployed.Example configuration in
angular.json:or
CommonEngine Users:
If you are using
CommonEngine, you must now provide theallowedHostsoption when initializing or rendering your application.Example:
The application also respects
NG_ALLOWED_HOSTS(comma-separated list) andHOSTNAMEenvironment variables for authorizing hosts.