{"id":"CVE-2026-111754","kind":"vulnerability","aliases":["CVE-2026-111754","GHSA-w739-gvwx-grc3"],"related":[],"title":"@angular/platform-server: Open Redirect via Protocol-Relative URLs in Server-Side Rendering (SSR)","description":"An open redirect vulnerability was identified in `@angular/platform-server` when Server-Side Rendering (SSR) is enabled.\n\nWhen Angular SSR renders an incoming request, URL resolution and platform navigation are managed by `ServerPlatformLocation` using `resolveUrl` (or `parseUrl` in v20). While `resolveUrl` rejected protocol-relative URLs whose raw string directly started with `//`, this check ran *prior* to WHATWG URL normalization.\n\nWhen a request URL includes a popping dot-segment followed by multiple slashes (such as `/.//evil.test` or serialized from router route shapes like `/.;/(//evil.test)`), the input bypassed the initial `urlStr.startsWith('//')` check. Subsequent WHATWG URL parsing resolved the dot-segments and resulted in a `pathname` starting with `//` (`//evil.test`).\n\nConsequently, `ServerPlatformLocation` stored `pathname = '//evil.test'`. When `@angular/ssr` detected a mismatch between the rendered URL and the router's final location state, it emitted the unnormalized pathname into the HTTP `Location` response header, producing an HTTP 302 redirect with `Location: //evil.test`, which browsers resolve as a protocol-relative navigation off-origin.\n\n### Impact\n\nSuccessful exploitation allows an unauthenticated remote attacker to craft URLs that trick Angular SSR into redirecting users to an arbitrary external domain.\n\n* **Bypasses Application-Level Guards:** Common application-level validation routines (such as checking `!returnUrl.startsWith('//')` before invoking `router.navigateByUrl`) accept `/.//evil.test` or `/.;/(//evil.test)` as a safe relative path, allowing attackers to bypass redirect allowlists.\n* **Client-side SPAs Unaffected:** Pure client-side Angular applications (Single Page Applications without SSR) are **not** vulnerable, as `ServerPlatformLocation` is only utilized in server-side execution environments.\n\n### Attack Preconditions & Vulnerable Configurations\n\nAn application is affected if the following conditions are met:\n\n* **SSR Enabled:** The application runs in a Server-Side Rendering environment using `@angular/platform-server` and `@angular/ssr`.\n* **Parameter Route Shapes:** The router contains parameter routes with wildcard or child segments (e.g. `/:tenant` matching `.` and serializing secondary outlet groups `(//evil.test)` to `/.//evil.test`), or passes user-provided navigation targets (e.g. `returnUrl`).\n* **Reverse Proxy Passthrough:** Upstream reverse proxies (Nginx, Cloudflare, ALB) pass semicolons (`;`) or dot-segments without stripping or normalizing them.\n\n#### Exploit Payload Example\n\nAn attacker sends a request targeting a route with parameters and child segments:\n\n```http\nGET /.;/(//evil.test) HTTP/1.1\nHost: example.com\n```\n\n**Response:**\n```http\nHTTP/1.1 302 Found\nLocation: //evil.test\n```\n\nThe client browser resolves `//evil.test` protocol-relatively and redirects the user away from `example.com` to `evil.test`.\n\n### Patches\n\nThe issue is resolved by updating `resolveUrl` (and `parseUrl` in v20) in `@angular/platform-server` to verify that the normalized URL pathname does not start with `//` when `allowProtocolRelative` is `false`.\n\n* `22.2.1`\n* `21.2.25`\n* `20.3.33`\n\n### Workarounds & Mitigations\n\nIf you cannot immediately upgrade to a patched version, apply one of the following mitigations at your edge reverse proxy or WAF:\n\n1. **Reject Paths with Dot-Segments Followed by Slashes:**\n   Configure your reverse proxy (e.g., Nginx, Cloudflare, AWS WAF) to block requests containing `/.//`, `/..//`, or `/.;/`:\n   ```nginx\n   # Nginx example: reject suspicious path normalization sequences\n   if ($request_uri ~* \"(\\n/\\.\\/|\\/\\.\\;)\") {\n       return 400;\n   }\n   ```\n2. **Sanitize Redirect Headers:**\n   Ensure edge proxies or API gateways rewrite or disallow protocol-relative (`//`) `Location` response headers originating from the SSR upstream.","summary":"An Angular SSR app built on @angular/platform-server can be made to answer an attacker-crafted URL such as /.;/(//evil.test) with a 302 whose Location header is //evil.test, sending the browser off-origin. This is an open redirect that can bypass naive returnUrl checks that only look for a leading '//'.","severity":"medium","cvss":null,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"@angular/platform-server","fixed":["22.2.1"],"affected":">= 22.0.0, < 22.2.1","ecosystem":"npm"},{"name":"@angular/platform-server","fixed":["21.2.25"],"affected":">= 21.0.0, < 21.2.25","ecosystem":"npm"},{"name":"@angular/platform-server","fixed":["20.3.33"],"affected":">= 20.0.0, < 20.3.33","ecosystem":"npm"},{"name":"@angular/platform-server","fixed":[],"affected":"<= 19.2.25","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["angular","angular-ssr","cwe-601","next.js-alternative","node","package","self-hosted"],"not_affected_if":["client-side-only","managed-hosting","no-ssr"],"how_to_check":"Check the installed version: npm ls @angular/platform-server\nConfirm SSR is in use: @angular/ssr present in package.json\nSend GET /.;/(//evil.test) to the app and look for a 302 with Location: //evil.test","fix":"Upgrade @angular/platform-server to 22.2.1, 21.2.25, or 20.3.33 (the fixed version for your release line).\nUntil patched, block /.// , /..// and /.;/ at the edge proxy or WAF, and strip or reject protocol-relative Location headers from the SSR upstream.","status":"open","published_at":"2026-10-20T14:35:49+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","triaged_at":"2026-10-23T20:13:40+00:00","updated_at":"2026-10-23T20:13:40+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-23T20:13:40Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade @angular/platform-server to 22.2.1, 21.2.25, or 20.3.33 (the fixed version for your release line).\nUntil patched, block /.// , /..// and /.;/ at the edge proxy or WAF, and strip or reject protocol-relative Location headers from the SSR upstream.","summary":"An Angular SSR app built on @angular/platform-server can be made to answer an attacker-crafted URL such as /.;/(//evil.test) with a 302 whose Location header is //evil.test, sending the browser off-origin. This is an open redirect that can bypass naive returnUrl checks that only look for a leading '//'.","category":"package","who_cares":["angular","angular-ssr","next.js-alternative","self-hosted","node"],"confidence":0.8,"analyst_note":"","how_to_check":"Check the installed version: npm ls @angular/platform-server\nConfirm SSR is in use: @angular/ssr present in package.json\nSend GET /.;/(//evil.test) to the app and look for a 302 with Location: //evil.test","needs_analyst":false,"not_affected_if":["client-side-only","no-ssr","managed-hosting"],"severity_if_unknown":"medium"},"cost_usd":0.00057,"input_tokens":3589,"output_tokens":422,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-111754':17A,25A '-2026':16A,24A '-601':35B '/..':548C '/evil.test':62B,70B,169C,177C,201C,206C,241C,296C,298C,391C,393C,441C,451C,456C '1':525C '2':564C '20.3.33':500C '21.2.25':499C '22.2.1':498C '302':65B,237C,448C '400':563C 'accept':295C 'affect':347C 'alb':411C 'allow':257C,304C 'allowlist':309C 'allowprotocolrel':495C 'altern':37B 'angular':30B,32B,44B,111C,267C,319C 'angular-ssr':31B 'angular/platform-server':1A,26A,27A,28A,29A,49B,101C,367C,482C 'angular/ssr':208C,369C 'answer':54B 'api':572C 'app':46B 'appli':512C 'applic':279C,284C,320C,323C,345C,357C 'application-level':278C,283C 'arbitrari':274C 'attack':57B,261C,305C,340C,427C 'attacker-craft':56B 'aw':542C 'away':464C 'block':545C 'browser':73B,243C,454C 'built':47B 'bypass':84B,180C,277C,307C 'cannot':505C 'check':87B,146C,184C,290C 'child':381C,437C 'client':311C,317C,453C 'client-sid':310C,316C 'cloudflar':410C,541C 'common':282C 'condit':351C 'configur':343C,535C 'consequ':202C 'contain':375C,547C 'craft':58B,263C 'cve':15A,23A 'cwe':34B 'detect':209C 'direct':142C 'disallow':576C 'domain':276C 'dot':161C,192C,416C,530C 'dot-seg':160C,191C,415C,529C 'e.g':383C,401C,539C 'edg':520C,569C 'emit':224C 'enabl':109C,355C 'ensur':568C 'environ':339C,365C 'evil.test':468C 'exampl':425C,552C 'example.com':444C,466C 'execut':338C 'exploit':256C,423C 'extern':275C 'fals':497C 'final':220C 'follow':163C,350C,516C,532C 'found':449C 'gateway':573C 'get':440C 'ghsa':19A 'ghsa-w739-gvwx-grc3':18A 'grc3':22A 'group':390C 'guard':281C 'gvwx':21A 'header':68B,233C,567C,582C 'host':42B,443C 'http':230C,236C,439C,446C 'http/1.1':442C,447C 'identifi':99C 'immedi':506C 'impact':254C 'includ':157C 'incom':115C 'initi':182C 'input':179C 'invok':293C 'issu':471C 'lead':93B 'level':280C,285C 'like':176C 'locat':67B,221C,231C,240C,450C,580C 'look':90B 'made':52B 'manag':123C 'match':385C 'met':353C 'mismatch':211C 'mitig':502C,517C 'multipl':165C 'n':561C 'naiv':85B 'navig':121C,250C,399C 'next.js':36B 'nginx':409C,540C,550C,551C 'node':38B 'normal':152C,421C,487C,556C 'off-origin':74B,251C 'one':513C 'open':2A,80B,95C 'origin':76B,253C,583C 'outlet':389C 'packag':39B 'page':322C 'paramet':370C,376C,435C 'pars':188C 'parseurl':129C,478C 'pass':395C,412C 'passthrough':405C 'patch':469C,510C 'path':303C,527C,555C 'pathnam':198C,205C,227C,489C 'payload':424C 'platform':120C 'pop':159C 'precondit':341C 'prior':148C 'produc':234C 'protocol':6A,136C,248C,458C,578C 'protocol-rel':5A,135C,247C,457C,577C 'provid':398C 'proxi':404C,408C,522C,538C,570C 'pure':315C 'ran':147C 'raw':140C 'redirect':3A,81B,96C,238C,270C,308C,461C,566C 'reject':134C,526C,553C 'relat':7A,137C,249C,302C,459C,579C 'remot':260C 'render':13A,106C,113C,214C,364C 'request':116C,155C,430C,546C,559C 'resolut':118C 'resolv':189C,244C,455C,473C 'resolveurl':127C,133C,476C 'respons':232C,445C,581C 'result':195C 'return':562C 'returnurl':86B,402C 'returnurl.startswith':291C 'revers':403C,407C,521C,537C 'rewrit':574C 'rout':174C,371C,377C,433C 'router':173C,218C,374C 'router.navigatebyurl':294C 'routin':287C 'run':358C 'safe':301C 'sanit':565C 'secondari':388C 'segment':162C,193C,382C,417C,438C,531C 'self':41B 'self-host':40B 'semicolon':413C 'send':71B,428C 'sequenc':557C 'serial':171C,387C 'server':11A,104C,336C,362C 'server-sid':10A,103C,335C,361C 'serverplatformloc':125C,203C,330C 'shape':175C,372C 'side':12A,105C,312C,318C,337C,363C 'singl':321C 'slash':166C,534C 'spas':313C 'ssr':14A,33B,45B,107C,112C,268C,325C,354C,586C 'start':143C,199C,492C 'state':222C 'store':204C 'string':141C 'strip':419C 'subsequ':185C 'success':255C 'suspici':554C 'target':400C,431C 'tenant':384C 'trick':266C 'unaffect':314C 'unauthent':259C 'unnorm':226C 'updat':475C 'upgrad':507C 'upstream':406C,587C 'uri':560C 'url':8A,59B,117C,138C,151C,156C,187C,215C,264C,488C 'urlstr.startswith':183C 'use':126C,366C 'user':271C,397C,463C 'user-provid':396C 'util':333C 'v20':131C,480C 'valid':286C 'verifi':484C 'version':511C 'via':4A 'vulner':97C,328C,342C 'w739':20A 'waf':524C,543C 'whatwg':150C,186C 'whose':66B,139C 'wildcard':379C 'without':324C,418C 'workaround':501C","nicknames":[],"mitigations":[],"who_affected":null,"tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-20T14:35:49+00:00","kev":null,"cvss_vector":null,"cvss_by":null,"poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-w739-gvwx-grc3","url":"https://github.com/advisories/GHSA-w739-gvwx-grc3","severity":"medium","published_at":"2026-10-20T14:35:49+00:00","modified_at":"2026-10-20T14:35:51+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","fetched_at":"2026-10-23T19:11:32+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}