Skip to content

@angular/ssr: the route matcher and @angular/router disagree, so per-route headers, status and renderMode come from a different route than the one rendered #34090

Description

@glivter

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions