Skip to content

Open Redirect via Unsanitized X-Forwarded-Prefix Header, Chainable to Web Cache Poisoning #32501

@VenkatKwest

Description

@VenkatKwest

Command

add, 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

joinUrlParts() in packages/angular/ssr/src/utils/url.ts only strips one leading slash from URL parts. When the X-Forwarded-Prefix header contains multiple leading slashes (e.g., ///evil.com), the function produces a protocol-relative URL (//evil.com/home) that browsers interpret as an external redirect.

What happens:

  1. Angular SSR app has a redirect route: { path: 'redirect', redirectTo: 'home' }
  2. Request arrives with header: X-Forwarded-Prefix: ///evil.com
  3. app.ts line 197 calls joinUrlParts('///evil.com', 'home')
  4. joinUrlParts strips only the first /"//evil.com" remains
  5. Final output: "//evil.com/home" → set as Location header
  6. Browser follows //evil.com/home as a protocol-relative redirect → navigates to evil.com

Root cause (url.ts line 106):

// Current code — only strips ONE leading slash:
if (part[0] === '/') {
  normalizedPart = normalizedPart.slice(1);
}

Suggested fix:

// Strip ALL leading slashes:
while (normalizedPart[0] === '/') {
  normalizedPart = normalizedPart.slice(1);
}

This affects all code paths that use joinUrlParts() with external input:

  • app.ts:197 — redirect routes with X-Forwarded-Prefix
  • ng.ts:215 — SSR navigation redirect URL construction
  • app-engine.ts:116 — i18n locale redirects

Minimal Reproduction

Step 1: Create a new Angular SSR app

ng new vuln-test-app --ssr
cd vuln-test-app

Step 2: Add a redirect route in src/app/app.routes.ts:

import { Routes } from '@angular/router';
import { Component } from '@angular/core';

@Component({ selector: 'app-home', template: '<h1>Home</h1>' })
export class Home {}

export const routes: Routes = [
  { path: 'home', component: Home },
  { path: 'redirect', redirectTo: 'home', pathMatch: 'full' }
];

Step 3: Build and start the SSR server:

ng build
node dist/vuln-test-app/server/server.mjs

Step 4: Send a request with a crafted header:

curl -v -H "X-Forwarded-Prefix: ///evil.com" http://localhost:4000/redirect

Expected behavior:
The Location header should be a safe relative path like /evil.com/home (all extra slashes stripped), not a protocol-relative URL.

Actual behavior:

HTTP/1.1 302 Found
Location: //evil.com/home

The Location header is //evil.com/home — a protocol-relative URL. Browsers redirect to https://evil.com/home.

Exception or Error

No exception. The server responds with HTTP 302, but the `Location` header contains a protocol-relative URL that causes an unintended external redirect:


HTTP/1.1 302 Found
X-Powered-By: Express
Location: //evil.com/home
Content-Type: text/plain; charset=utf-8
Content-Length: 28
Date: Sat, 15 Feb 2026 10:30:00 GMT
Connection: keep-alive

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?

  • The X-Forwarded-Prefix header is commonly used in production when Angular SSR apps run behind reverse proxies (Nginx, HAProxy, AWS ALB, Kubernetes Ingress).
  • The redirect response also lacks a Cache-Control header, meaning CDNs may cache the poisoned redirect and serve it to other users.
  • A simple fix (changing if to while in joinUrlParts) resolves the issue without breaking any existing behavior, since legitimate prefixes never have multiple leading slashes.
  • This was reported to Google Bug Hunters who confirmed it and suggested public disclosure.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions