Feb 26, 2026·7 min read·11 visits
Angular SSR's URL normalization logic only stripped a single leading slash from path segments. Attackers sending a `X-Forwarded-Prefix: ///evil.com` header can trick the server into generating a `Location: //evil.com` redirect. This protocol-relative URL forces the browser to navigate to the attacker's domain, enabling high-credibility phishing attacks.
A deceptively simple logic error in Angular's Server-Side Rendering (SSR) engine allows attackers to turn internal redirects into open redirects. By exploiting how the framework normalizes URLs from the `X-Forwarded-Prefix` header, a malicious actor can bypass validation with extra slashes, leading to protocol-relative URL redirection. This flaw affects major versions 19, 20, and 21, turning trusted applications into phishing launchpads.
Server-Side Rendering (SSR) is the magic glue that makes heavy JavaScript frameworks feel snappy and SEO-friendly. But SSR is also a dangerous bridge between the raw, untrusted internet and your internal application logic. In the modern cloud stack, your Angular app rarely sits naked on the internet. It lives behind layers of Nginx, AWS ALBs, or Cloudflare, all of which helpfully annotate requests with headers like X-Forwarded-For and X-Forwarded-Prefix so the app knows where it supposedly lives.
Here is the problem: Angular decided to trust these headers a little too much. Specifically, the @angular/ssr package needs to construct full URLs when performing redirects (like when you hit a protected route and get bounced to /login). To do this, it stitches together the base URL and the target path. It sounds simple. It sounds boring. But in the world of exploit development, "boring" utility functions are where the bodies are buried.
CVE-2026-27738 isn't a complex buffer overflow or a heap grooming masterpiece. It is a testament to the fact that developers still treat input validation like a chore rather than a survival skill. The vulnerability lies in a helper function designed to clean up URL paths. It tries to be helpful by removing leading slashes to avoid double-slashing. Spoiler alert: it didn't remove enough of them.
Let's talk about joinUrlParts. This utility function in packages/angular/ssr/src/utils/url.ts has one job: take a bunch of string segments and glue them together into a valid URL path. To prevent the classic //path//subpath mess, it attempts to normalize the segments by stripping leading slashes before joining them.
Here is where the developer's optimism collided with reality. The code checked if a segment started with a slash. If it did, it removed it. Singular. Once. It assumed that a path segment would only ever have one accidental leading slash. It looked something like this:
// The logic of a optimist
if (part[0] === '/') {
normalizedPart = normalizedPart.slice(1);
}See the issue? If I hand you /home, you give me home. Perfect. But what if I hand you ///evil.com? The code sees the first slash, says "Gotcha!", removes it, and leaves //evil.com. In the world of browsers, // isn't just two slashes; it's a protocol-relative URL. It tells the browser, "Keep using HTTPS, but switch the host to evil.com."
This is the digital equivalent of a bouncer checking your ID, seeing you have a fake mustache, peeling it off, but failing to notice the second, smaller fake mustache underneath.
The fix provided by the Angular team is a textbook example of "defense in depth" replacing "naive trust." They didn't just fix the loop; they scorched the earth. Let's look at the critical diff in the joinUrlParts function.
The Vulnerable Code:
// Old logic: strictly checks index 0
if (part.startsWith('/')) {
part = part.substring(1);
}The Fix (Commit 877f017):
The new implementation treats slashes like an infestation. It uses a while loop to aggressively strip every leading and trailing slash it finds. It doesn't matter if you send ///, ////, or //////////////////—they are all getting nuked.
export function joinUrlParts(...parts: string[]): string {
const normalizedParts: string[] = [];
for (const part of parts) {
let start = 0;
let end = part.length;
// Eat all the slashes at the start
while (start < end && part[start] === '/') { start++; }
// Eat all the slashes at the end
while (end > start && part[end - 1] === '/') { end--; }
if (start < end) {
normalizedParts.push(part.slice(start, end));
}
}
return addLeadingSlash(normalizedParts.join('/'));
}But they didn't stop there. They realized that X-Forwarded-Prefix shouldn't contain suspicious characters at all. They added a regex validator INVALID_PREFIX_REGEX that instantly throws an error if the header contains double slashes or directory traversal sequences (..).
const INVALID_PREFIX_REGEX = /^[\/\\]{2}|(?:^|[\/\\])\.\.?(?:[\/\\]|$)/;This is the difference between "patching a bug" and "killing a bug class."
So, how do we weaponize this? We need an Angular SSR app sitting behind a proxy (like Nginx) that blindly passes the X-Forwarded-Prefix header. This is a very common configuration for apps hosted on sub-paths (e.g., example.com/app/).
The Setup:
https://vulnerable-bank.comhttps://evil-phishing.com/logout route, which redirects users back to /home after clearing the session.The Attack: We craft a link or use a CSRF chain to force a request with a manipulated header. If we can control the upstream proxy or if the application is misconfigured to trust client headers directly (common in dev/staging), we send this:
GET /logout HTTP/1.1
Host: vulnerable-bank.com
X-Forwarded-Prefix: ///evil-phishing.comThe Execution:
joinUrlParts('///evil-phishing.com', 'home').//evil-phishing.com.//evil-phishing.com/home.HTTP/1.1 302 Found
Location: //evil-phishing.com/homeThe Result:
The victim's browser sees //evil-phishing.com/home. Since the original page was loaded over HTTPS, the browser expands this to https://evil-phishing.com/home. The user is silently whisked away to the attacker's domain, which likely looks exactly like the bank's login page.
Security engineers often roll their eyes at Open Redirects. "So what? The user goes to the wrong page." But in the context of a trusted application, an Open Redirect is a silver bullet for social engineering.
First, there is the Phishing Implication. Users are trained to check the URL bar. If they click a link that starts with vulnerable-bank.com, they let their guard down. If that link immediately 302s them to a clone site, they rarely notice the switch. The initial trust is established by the valid domain.
Second, consider Cache Poisoning. If the X-Forwarded-Prefix header is not part of the cache key (the Vary header) on your CDN, an attacker could send a single malicious request that gets cached. Now, every legitimate user who visits that page gets redirected to the malware site. This turns a targeted attack into a Denial of Service or a mass-compromise event.
Finally, this bypasses many SSRF filters. While this specific CVE results in a client-side redirect, the underlying logic flaw—insufficient sanitization of path components—often mirrors how internal requests are constructed. If this logic was used to fetch internal resources, we'd be looking at a much nastier RCE scenario.
If you are running Angular SSR on versions 19, 20, or 21, you are exposed. The immediate fix is to upgrade to the patched versions:
21.1.520.3.1719.2.21If you cannot upgrade immediately (corporate bureaucracy is the real vulnerability, after all), you must sanitize the input before it reaches Angular. In your server.ts or Nginx configuration, you need to flatten that header.
Nginx Mitigation: Ensure you are hardcoding the prefix if you know it, rather than trusting the client. If you must pass it dynamically, validate it.
# Don't do this if you can avoid it
# proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix;
# Do this: explicitly define the prefix
proxy_set_header X-Forwarded-Prefix /my-app;Node.js Middleware Mitigation:
// Add this early in your server stack
app.use((req, res, next) => {
const prefix = req.headers['x-forwarded-prefix'];
if (typeof prefix === 'string' && prefix.startsWith('//')) {
// Flatten multiple slashes to one
req.headers['x-forwarded-prefix'] = prefix.replace(/^\/+/, '/');
}
next();
});This isn't just about fixing a bug; it's about acknowledging that "normalization" is hard and that headers are user input. Treat them accordingly.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N| Product | Affected Versions | Fixed Version |
|---|---|---|
@angular/ssr Angular | >= 19.0.0-next.0, < 19.2.21 | 19.2.21 |
@angular/ssr Angular | >= 20.0.0-next.0, < 20.3.17 | 20.3.17 |
@angular/ssr Angular | >= 21.0.0-next.0, < 21.1.5 | 21.1.5 |
| Attribute | Detail |
|---|---|
| CWE | CWE-601 (Open Redirect) |
| CVSS v4.0 | 6.9 (Medium) |
| Attack Vector | Network (Header Manipulation) |
| Exploit Maturity | Proof of Concept |
| Privileges | None |
| User Interaction | Required (Victim must click link) |
URL Redirection to Untrusted Site ('Open Redirect')