-
Notifications
You must be signed in to change notification settings - Fork 11.9k
Description
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:
- Angular SSR app has a redirect route:
{ path: 'redirect', redirectTo: 'home' } - Request arrives with header:
X-Forwarded-Prefix: ///evil.com app.tsline 197 callsjoinUrlParts('///evil.com', 'home')joinUrlPartsstrips only the first/→"//evil.com"remains- Final output:
"//evil.com/home"→ set asLocationheader - Browser follows
//evil.com/homeas a protocol-relative redirect → navigates toevil.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 withX-Forwarded-Prefixng.ts:215— SSR navigation redirect URL constructionapp-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-appStep 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.mjsStep 4: Send a request with a crafted header:
curl -v -H "X-Forwarded-Prefix: ///evil.com" http://localhost:4000/redirectExpected 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-Prefixheader 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-Controlheader, meaning CDNs may cache the poisoned redirect and serve it to other users. - A simple fix (changing
iftowhileinjoinUrlParts) 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.