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 2 commits intoangular:mainfrom
Conversation
a44aef7 to
8778533
Compare
757306a to
de79ce3
Compare
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?
| 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?
There was a problem hiding this comment.
Document in this is case is the index.html which we need to serve it to render CSR.
| 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?
There was a problem hiding this comment.
My reason for this is that you'd be able to provide the allowedHost at runtime where using env variables is not an option. For instance in worker context.
If you feel we should hold this off, I am fine with this. Please let me know.
There was a problem hiding this comment.
I'm ok either way and happy to defer to you on the decision. I'm just highlighting the option to defer "providing allowedHosts at runtime" to v22 if you don't feel it's necessary here. Ultimately up to you, no objection to landing it now if you think that's better.
|
|
||
| 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.
There was a problem hiding this comment.
They will actually not trigger the CSR deopt. in this case they will not be validated.
There was a problem hiding this comment.
Why wouldn't this be validated? Wouldn't reading req.headers['host'] (even through Object.entries or another iterator) trigger validation and then deopt?
There was a problem hiding this comment.
Because .entries retrieves the values from an internal list
There was a problem hiding this comment.
Oh... does that imply that we should patch these iteration functions? entries, entriesList, rawValues, toSortedArray, Symbol.iterator? Otherwise you could circumvent this check a basic req.headers.entriesList().find(([header]) => header === 'host').
(That example is obviously intentionally working around the safety check, but anything just iterating all headers has the same risk.)
| } | ||
| } | ||
|
|
||
| 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?
There was a problem hiding this comment.
No we do not normalize headers names, the headers.get() is case insensitive.
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 a strict allowlist.
- `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) environment variable for authorizing hosts.
da7a6aa to
301a633
Compare
9feb3c7 to
b0b1a97
Compare
I removed
Updated the logic to do this. |
50ff773 to
fe3fbba
Compare
fe3fbba to
e38778a
Compare
e38778a to
a9a660f
Compare
dgp1130
left a comment
There was a problem hiding this comment.
Like I mentioned previously, feel free to ignore or come back to these comments later, I don't think any of them are blocking.
| 'Please provide a list of allowed hosts in the "allowedHosts" option in the "CommonEngine" constructor.', | ||
| isAllowedHostConfigured | ||
| ? '' | ||
| : '\nFallbacking to client side rendering. This will become a 400 Bad Request in a future major version.', |
| return allowedHosts; | ||
| } | ||
|
|
||
| const hosts = envNgAllowedHosts.split(','); |
There was a problem hiding this comment.
Consider: Is it worth validating that the provided names are valid hostnames (letters, numbers, dashes only I think)?
Less of a security thing and more just DX to check for typos.
| } | ||
|
|
||
| // Clone request with patched headers to prevent unallowed host header access. | ||
| const { request: securedRequest, onError: onHeaderValidationError } = |
There was a problem hiding this comment.
Consider: Rename request to insecureRequest and then rename securedRequest to request.
This way avoids future changes accidentally using request when they really wanted to use the secured one.
| * @returns An object containing the cloned request and a promise that resolves to an error | ||
| * if any of the validated headers contain invalid values. | ||
| */ | ||
| export function cloneRequestAndPatchHeaders( |
There was a problem hiding this comment.
Consider: The functionality purist it me likes the idea of cloning the request, but I'm wondering if there's a risk of the original request leaking into user code somehow in a way which might circumvent this check? Are there any server frameworks which offer a kind of getActiveRequest() function which might retrieve the original? Would it be safer to mutate the original object?
Maybe this is fine as a temporary mitigation since we'll eventually move to a hard 400 error up front, so it might not be too big a deal in the short term.
| } catch (error) { | ||
| onError(error as Error); | ||
|
|
||
| return null; |
There was a problem hiding this comment.
Consider: Would it be safer to throw in user space rather than letting them assume a given header doesn't exist?
We probably still need onError as we wouldn't want the user to catch and suppress this error, but I'm thinking user code might behave strangely if we pretend the header doesn't exist at all.
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.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.