-
Notifications
You must be signed in to change notification settings - Fork 11.9k
Description
Command
build, other
Is this a regression?
- Yes, this behavior used to work in the previous version
The previous version in which this bug was not present was
No response
Description
Description
Note
I have checked the official documentation, and there is no mention of allowedHosts equivalents or related configurations to prevent this in production SSR. If this behavior is already known or intentional, please feel free to close this report.
Important
While modern reverse proxies (e.g., Nginx, AWS ALB, Cloudflare) typically strip or override the X-Forwarded-Host header—mitigating direct host injection—they generally do not strip X-Forwarded-Port or X-Forwarded-Proto. This means an attacker can still exploit these unvalidated headers to poison CDN/proxy caches, even behind a properly configured reverse proxy.
createRequestUrl() in packages/angular/ssr/node/src/request.ts blindly trusts the X-Forwarded-Host, X-Forwarded-Proto, and X-Forwarded-Port headers when constructing the application's base URL during Server-Side Rendering. This allows an attacker to poison the DOCUMENT token and document.location object, leading to SSRF, Host Header Injection, SEO/Cache Poisoning, and Cache Poisoning DoS.
Note: This only affects the production server (
node dist/.../server/server.mjs), NOT the dev server (ng serve). Vite'shost-check-middlewarecorrectly validates theHostheader in dev mode.
What happens:
createRequestUrl()readsX-Forwarded-Host,X-Forwarded-Proto, andX-Forwarded-Portheaders- It constructs a
URLobject from these values with no validation or allowlist check - This URL backs
document.locationin the SSR context - Any application code using
document.location.origin(e.g., for API calls, canonical URLs, OAuth callbacks) uses the attacker-controlled value
Root cause (request.ts — createRequestUrl()):
const protocol =
getFirstHeaderValue(headers['x-forwarded-proto']) ??
('encrypted' in socket && socket.encrypted ? 'https' : 'http');
const hostname =
getFirstHeaderValue(headers['x-forwarded-host']) ?? headers.host ?? headers[':authority'];
let hostnameWithPort = hostname;
if (!hostname?.includes(':')) {
const port = getFirstHeaderValue(headers['x-forwarded-port']);
if (port) {
hostnameWithPort += `:${port}`;
}
}
// No validation of 'hostname' or 'protocol'
return new URL(`${protocol}://${hostnameWithPort}${originalUrl ?? url}`);getFirstHeaderValue() simply splits on , and returns the first trimmed value — it performs no validation or sanitization.
Data flow from header to application code:
createRequestUrl()→ poisonedURLfromX-Forwarded-*headerscreateWebRequestFromNodeRequest()→ WebRequestwith poisoned URLAngularNodeAppEngine.handle()→ passes toAngularAppEngineAngularServerApp.handle()→ extractsrequest.url, passes torenderAngular()renderAngular()→ injects asINITIAL_CONFIG.urlintoplatformServer()ServerPlatformLocation→ backsdocument.location- Application code reads
document.location.origin→ attacker-controlled
Attack scenarios:
- SSRF: App code like
this.http.get(${doc.location.origin}/api/data)sends the request to the attacker's server, leaking cookies/tokens - SEO Poisoning:
<link rel="canonical" href="${doc.location.href}">renders with attacker's domain - Cache Poisoning DoS via
X-Forwarded-Proto:curl -H "X-Forwarded-Proto: ftp" https://target.com/→ CDN caches HTML withftp://URLs → page breaks for all users - Cache Poisoning DoS via
X-Forwarded-Port:curl -H "X-Forwarded-Port: 9999" https://target.com/→ CDN caches HTML with port 9999 URLs → assets fail to load for all users
Suggested fix:
Validate the host against a flexible allowlist (similar to the dev server's allowedHosts), or only trust X-Forwarded-* headers when explicit trust is configured (e.g., via a trustedProxies option in AngularNodeAppEngine).
Minimal Reproduction
Step 1: Create a new Angular SSR app
npx -y @angular/cli@latest new vuln-test-app --ssr --defaultsStep 2: Force runtime SSR in src/app/app.routes.server.ts (static prerendering masks the issue):
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [{ path: '**', renderMode: RenderMode.Server }];Step 3: Instrument the component in src/app/app.component.ts:
import { DOCUMENT } from '@angular/common';
import { Component, inject } from '@angular/core';
@Component({
selector: 'app-root',
template: `<h1>Origin: {{ origin }}</h1>`,
})
export class AppComponent {
doc = inject(DOCUMENT);
origin = this.doc.location.origin;
}Step 4: Build and run the production server:
npm run build
node dist/vuln-test-app/server/server.mjsStep 5: Send a request with a spoofed header:
curl -v -H "X-Forwarded-Host: evil.com" http://localhost:4000/Expected behavior:
The server should either reject untrusted X-Forwarded-* headers or validate them against an allowlist. document.location.origin should reflect the actual server host (localhost:4000), not the spoofed header value.
Actual behavior:
The rendered HTML contains <h1>Origin: http://evil.com</h1>. The attacker fully controls document.location.origin during SSR via a single spoofed header.
Additional proof — protocol and port poisoning:
# Protocol poisoning
curl -v -H "X-Forwarded-Proto: ftp" http://localhost:4000/
# → origin becomes ftp://localhost:4000
# Port poisoning
curl -v -H "X-Forwarded-Port: 9999" http://localhost:4000/
# → origin becomes http://localhost:9999Exception or Error
No exception. The server responds normally with HTTP 200, but the SSR-rendered HTML contains attacker-controlled values in `document.location`, which poisons any application logic that relies on the origin:
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
...<h1>Origin: http://evil.com</h1>...
Your Environment
Angular CLI: 21.1.4
Node: 25.6.0
Package Manager: npm 11.3.0
OS: Windows 11
Angular: 21.1.4
@angular/ssr: 21.1.4
Package Version
------------------------------------------------------
@angular-devkit/architect 0.2101.4
@angular-devkit/build-angular 21.1.4
@angular-devkit/core 21.1.4
@angular-devkit/schematics 21.1.4
@angular/cli 21.1.4
@schematics/angular 21.1.4
Anything else relevant?
- Only production builds are affected. The dev server (
ng serve/ Vite) hashost-check-middlewarethat validates headers — it is NOT vulnerable. - The vulnerable function is
createRequestUrl()inpackages/angular/ssr/node/src/request.ts. - The
X-Forwarded-Host/Proto/Portheaders are commonly set by reverse proxies (Nginx, HAProxy, AWS ALB, Kubernetes Ingress). Without validation, any client can spoof these headers when traffic reaches the Angular server directly or through a misconfigured proxy. - Cache Poisoning amplifies the impact: CDNs compute cache keys from
Host+ path but do NOT includeX-Forwarded-ProtoorX-Forwarded-Port. A single poisoned request can break the page for ALL subsequent users until the cache expires. - Temporary mitigation for developers: Add host validation middleware in
server.tsbefore the Angular engine:const allowedHosts = ['mysite.com', 'localhost']; app.use((req, res, next) => { const host = req.headers['x-forwarded-host'] || req.headers.host; if (!allowedHosts.includes(host)) { return res.status(400).send('Invalid Host'); } next(); });