Command
build
Is this a regression?
No
Description
@angular/ssr matches every incoming request twice, with two different tokenisers, and lets the two answers disagree.
ServerRouter.match() picks the response's headers, status, renderMode and preload using RouteTree.getPathSegments() (packages/angular/ssr/src/routes/route-tree.ts):
private getPathSegments(route: string): string[] {
return route.split('/').filter(Boolean).map(decodeURIComponent);
}
@angular/router picks the component that renders into the response body using DefaultUrlSerializer, a grammar in which (, ), ; and // are metacharacters and unparseable input is silently discarded.
Appending a single character to a path therefore produces a response whose body is route A and whose cache policy, security headers, status and render mode come from route B.
The @angular/router side is intentional and is not the bug. (name:seg) is the documented secondary-outlet syntax, ;k=v the documented matrix-parameter syntax, and d4d6c28 (fix(router): handle parenthesized outlets without a name in DefaultUrlSerializer, #64507) deliberately made an unnamed (...) group mean the primary outlet. @angular/ssr implements none of that grammar.
Verified against the published @angular/router 22.1.6, serialize(parse(x)):
| input |
router resolves to |
/priv) |
/priv |
/priv( |
/priv |
/priv; |
/priv |
/(priv) |
/priv |
/priv/1//y |
/priv/1 |
/a/b)c/d |
/a/b |
This project has already patched one case of this class. 85c18b4 (fix(@angular/ssr): correctly handle routes with matrix parameters, closes #31457) added stripMatrixParams because URLs with matrix parameters failed to match their route. That is the same divergence surfacing as a functional bug. Parentheses and interior // are the remaining cases, and the general fix is smaller than the third special case would be.
What is affected: the per-route headers, status and renderMode declared in app.routes.server.ts are taken from a different route than the one that renders. headers is also spread last in handleRendering, after Content-Type, so those are attacker-selected too.
What is NOT affected, stated plainly: canActivate and the other router guards still run, because @angular/router sees an ordinary path. This is not an authorization bypass and I am not claiming one. What is bypassed is the response policy and the render-mode directive.
Minimal Reproduction
ng new ssrlab --ssr --defaults && cd ssrlab
src/app/app.routes.ts: client routes pub, priv, priv/:id, privc, priv/:id/share, each pointing at a component that renders a marker naming its own routeConfig.path, plus { path: '**', component: NotFound }.
src/app/app.routes.server.ts:
const PRIVATE_HEADERS = {
'Cache-Control': 'no-store, private',
'X-Frame-Options': 'DENY',
'Content-Security-Policy': "frame-ancestors 'none'",
};
export const serverRoutes: ServerRoute[] = [
{ path: 'pub', renderMode: RenderMode.Server, headers: { 'Cache-Control': 'public, max-age=600' } },
{ path: 'priv', renderMode: RenderMode.Server, headers: PRIVATE_HEADERS },
{ path: 'priv/:id', renderMode: RenderMode.Server, headers: PRIVATE_HEADERS },
{ path: 'privc', renderMode: RenderMode.Client, headers: PRIVATE_HEADERS },
{ path: 'priv/:id/share', renderMode: RenderMode.Server, headers: { 'Cache-Control': 'public, max-age=31536000, immutable' } },
{ path: '**', renderMode: RenderMode.Server, headers: { 'Cache-Control': 'public, max-age=600' } },
];
ng build && PORT=4111 node dist/ssrlab/server/server.mjs
curl -sD - --path-as-is 'http://127.0.0.1:4111/priv'
curl -sD - --path-as-is 'http://127.0.0.1:4111/priv)'
policy_from below is read off the response headers, rendered off the response body.
--- canonical spelling of each route ---
/pub 200 policy_from=public Cache-Control=public, max-age=600 X-Frame-Options=- rendered=pub
/priv 200 policy_from=private Cache-Control=no-store, private X-Frame-Options=DENY rendered=priv
/priv/1 200 policy_from=private Cache-Control=no-store, private X-Frame-Options=DENY rendered=priv/:id
/privc 200 policy_from=private Cache-Control=no-store, private X-Frame-Options=DENY rendered=<not server-rendered>
/nope 200 policy_from=wildcard Cache-Control=public, max-age=600 X-Frame-Options=- rendered=<not server-rendered>
--- same routes, spelled so the two matchers disagree ---
/priv) 200 policy_from=wildcard Cache-Control=public, max-age=600 X-Frame-Options=- rendered=priv
/priv( 200 policy_from=wildcard Cache-Control=public, max-age=600 X-Frame-Options=- rendered=priv
/priv; 200 policy_from=wildcard Cache-Control=public, max-age=600 X-Frame-Options=- rendered=priv
/(priv) 200 policy_from=wildcard Cache-Control=public, max-age=600 X-Frame-Options=- rendered=priv
/priv/1//y 200 policy_from=wildcard Cache-Control=public, max-age=600 X-Frame-Options=- rendered=priv/:id
/privc) 200 policy_from=wildcard Cache-Control=public, max-age=600 X-Frame-Options=- rendered=privc
GET /priv) is one appended character. It replaces no-store, private with public, max-age=600, drops X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none', and on /privc) server-renders a route whose configuration says RenderMode.Client.
The mangled path is not restricted to falling through to the catch-all. The mangled segment list is matched normally, so any route pattern that fits it is selectable:
/priv/1 policy_from=private Cache-Control=no-store, private rendered=priv/:id
/priv/1/share policy_from=share Cache-Control=public, max-age=31536000, immutable rendered=priv/:id/share
/priv/1//share policy_from=share Cache-Control=public, max-age=31536000, immutable rendered=priv/:id
Line three renders the account page under the public sub-route's cache policy.
Exception or Error
None. Nothing throws and nothing is logged; both matchers succeed and return different answers. That is what makes it hard to notice in an application's own tests.
Your Environment
@angular/ssr 22.1.8 (latest on npm at time of testing)
@angular/router 22.1.6 (latest)
files: packages/angular/ssr/src/routes/route-tree.ts, packages/angular/ssr/src/routes/router.ts
Anything else relevant?
Scope. A differential sweep of 1575 paths against the running server produced 80 divergent paths in two root shapes: parenthesis/semicolon, and interior //. Enumerated over printable ASCII, the single-character triggers are (, ) and ;. All three are legal path characters that proxies and CDNs forward unchanged.
Suggested fix. @angular/ssr already peer-depends on @angular/router, so normalising the pathname through the router's own grammar before tokenising makes the two matchers agree, in ServerRouter.match:
import { DefaultUrlSerializer } from '@angular/router';
private static readonly serializer = new DefaultUrlSerializer();
private static normalize(pathname: string): string {
try {
const s = ServerRouter.serializer.serialize(ServerRouter.serializer.parse(pathname));
const cut = s.search(/[?#]/);
return cut === -1 ? s : s.slice(0, cut);
} catch {
return pathname; // malformed percent-encoding keeps today's behaviour
}
}
match(url: URL): RouteTreeNodeMetadata | undefined {
let { pathname } = stripIndexHtmlFromURL(url);
pathname = stripMatrixParams(ServerRouter.normalize(pathname));
return this.routeTree.match(pathname);
}
Checked against the payload set and a calibration set: the divergent paths collapse onto the canonical route, and /priv, /pub, /pub/1, /nope, /a%2Fb, /%zz and /priv/./x tokenise exactly as they do today.
Known residual of that fix: an application supplying a custom UrlSerializer still diverges, because the normalisation uses DefaultUrlSerializer. The durable fix is to build and query the route tree through the serializer the application injects. I am happy to open a PR with either shape.
Prior art checked. All six angular/angular-cli security advisories; the five SSR ones concern X-Forwarded-Prefix, createRequestUrl and CommonEngine path traversal, none concerns route matching. Open PR #33248 centralises URL normalisation for prerender redirects, Location headers and the server manifest, but does not touch routes/router.ts or routes/route-tree.ts.
Reporting history. Submitted to the Google OSS VRP as issue 559764571 on 2026-09-10. It was triaged and assigned, and the Bug Hunter Team invited public disclosure here on 2026-09-14. Filing it publicly at their request.
Disclosure. This report and its testing were produced with AI assistance. Every claim in it was executed against a real production build of @angular/ssr 22.1.8 and @angular/router 22.1.6 rather than reasoned about, and the differential sweep carried a calibration set that showed no divergence.
Command
build
Is this a regression?
No
Description
@angular/ssrmatches every incoming request twice, with two different tokenisers, and lets the two answers disagree.ServerRouter.match()picks the response'sheaders,status,renderModeandpreloadusingRouteTree.getPathSegments()(packages/angular/ssr/src/routes/route-tree.ts):@angular/routerpicks the component that renders into the response body usingDefaultUrlSerializer, a grammar in which(,),;and//are metacharacters and unparseable input is silently discarded.Appending a single character to a path therefore produces a response whose body is route A and whose cache policy, security headers, status and render mode come from route B.
The
@angular/routerside is intentional and is not the bug.(name:seg)is the documented secondary-outlet syntax,;k=vthe documented matrix-parameter syntax, and d4d6c28 (fix(router): handle parenthesized outlets without a name in DefaultUrlSerializer, #64507) deliberately made an unnamed(...)group mean the primary outlet.@angular/ssrimplements none of that grammar.Verified against the published
@angular/router22.1.6,serialize(parse(x)):/priv)/priv/priv(/priv/priv;/priv/(priv)/priv/priv/1//y/priv/1/a/b)c/d/a/bThis project has already patched one case of this class. 85c18b4 (
fix(@angular/ssr): correctly handle routes with matrix parameters, closes #31457) addedstripMatrixParamsbecause URLs with matrix parameters failed to match their route. That is the same divergence surfacing as a functional bug. Parentheses and interior//are the remaining cases, and the general fix is smaller than the third special case would be.What is affected: the per-route
headers,statusandrenderModedeclared inapp.routes.server.tsare taken from a different route than the one that renders.headersis also spread last inhandleRendering, afterContent-Type, so those are attacker-selected too.What is NOT affected, stated plainly:
canActivateand the other router guards still run, because@angular/routersees an ordinary path. This is not an authorization bypass and I am not claiming one. What is bypassed is the response policy and the render-mode directive.Minimal Reproduction
src/app/app.routes.ts: client routespub,priv,priv/:id,privc,priv/:id/share, each pointing at a component that renders a marker naming its ownrouteConfig.path, plus{ path: '**', component: NotFound }.src/app/app.routes.server.ts:policy_frombelow is read off the response headers,renderedoff the response body.GET /priv)is one appended character. It replacesno-store, privatewithpublic, max-age=600, dropsX-Frame-Options: DENYandContent-Security-Policy: frame-ancestors 'none', and on/privc)server-renders a route whose configuration saysRenderMode.Client.The mangled path is not restricted to falling through to the catch-all. The mangled segment list is matched normally, so any route pattern that fits it is selectable:
Line three renders the account page under the public sub-route's cache policy.
Exception or Error
None. Nothing throws and nothing is logged; both matchers succeed and return different answers. That is what makes it hard to notice in an application's own tests.
Your Environment
Anything else relevant?
Scope. A differential sweep of 1575 paths against the running server produced 80 divergent paths in two root shapes: parenthesis/semicolon, and interior
//. Enumerated over printable ASCII, the single-character triggers are(,)and;. All three are legal path characters that proxies and CDNs forward unchanged.Suggested fix.
@angular/ssralready peer-depends on@angular/router, so normalising the pathname through the router's own grammar before tokenising makes the two matchers agree, inServerRouter.match:Checked against the payload set and a calibration set: the divergent paths collapse onto the canonical route, and
/priv,/pub,/pub/1,/nope,/a%2Fb,/%zzand/priv/./xtokenise exactly as they do today.Known residual of that fix: an application supplying a custom
UrlSerializerstill diverges, because the normalisation usesDefaultUrlSerializer. The durable fix is to build and query the route tree through the serializer the application injects. I am happy to open a PR with either shape.Prior art checked. All six
angular/angular-clisecurity advisories; the five SSR ones concernX-Forwarded-Prefix,createRequestUrlandCommonEnginepath traversal, none concerns route matching. Open PR #33248 centralises URL normalisation for prerender redirects,Locationheaders and the server manifest, but does not touchroutes/router.tsorroutes/route-tree.ts.Reporting history. Submitted to the Google OSS VRP as issue 559764571 on 2026-09-10. It was triaged and assigned, and the Bug Hunter Team invited public disclosure here on 2026-09-14. Filing it publicly at their request.
Disclosure. This report and its testing were produced with AI assistance. Every claim in it was executed against a real production build of
@angular/ssr22.1.8 and@angular/router22.1.6 rather than reasoned about, and the differential sweep carried a calibration set that showed no divergence.