Skip to content

Angular SSR route-policy confusion can expose client-only data under public cache headers #33555

Description

@SkyZeroZx

Description

Angular SSR can select server-route metadata from one route while Angular Router renders a different route when the request URL contains certain ambiguous path forms.

Example:

/profile;
/profile//public

In both cases, a route configured as RenderMode.Client can be unexpectedly rendered on the server while inheriting public cache headers from another ServerRoute.

Minimal Reproduction

Minimal configuration

import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  {
    path: 'profile',
    renderMode: RenderMode.Client,
    headers: {
      'Cache-Control': 'private, no-store',
    },
  },
  {
    path: 'profile/public',
    renderMode: RenderMode.Server,
    headers: {
      'Cache-Control': 'public, max-age=300',
    },
  },
  {
    path: '**',
    renderMode: RenderMode.Server,
    headers: {
      'Cache-Control': 'public, max-age=300',
    },
  },
];

The /profile component reads a benign request-derived marker through the SSR REQUEST token.

Steps to reproduce

Request the normal client-only route:

curl -i \
  -H 'Cookie: session=PRIVATE_VALUE' \
  http://localhost:4000/profile

The initial HTML does not contain the request-derived value.

Request either crafted path:

curl -i \
  -H 'Cookie: session=PRIVATE_VALUE' \
  'http://localhost:4000/profile;'
curl -i \
  -H 'Cookie: session=PRIVATE_VALUE' \
  http://localhost:4000/profile//public

Actual behavior

The crafted requests can:

  • render the /profile component on the server;
  • expose request-derived data in the initial HTML;
  • apply Cache-Control: public metadata belonging to another server route.

Expected behavior

The route used to select renderMode, status, and response headers must always correspond to the route whose body is rendered.

A route configured as RenderMode.Client should not be server-rendered through an alternative URL representation.

Your Environment

Angular 22.X

Anything else relevant?

This was previously reported at https://issuetracker.google.com/u/1/issues/518988455

Activity

  1. added this to the needsTriage milestone on Jul 12, 2026
  2. added
    needs: repro stepsWe cannot reproduce the issue with the information given
    and removed
    needs: repro stepsWe cannot reproduce the issue with the information given
    on Jul 13, 2026
  3. glivter commented on Sep 14, 2026

    @glivter

    Confirming this independently, and apologising for the duplicate: I filed #34090 two months after this and my prior-art sweep missed it. A single search for "route-policy" finds this issue immediately. I had searched my own vocabulary, "route-tree tokenisation" and "matcher divergence", rather than the words a reporter would use. That is on me, and I am closing #34090 in favour of this one.

    Posting the additional coverage here so it is not lost, since some of it is not in the original report.

    The other grammar has a name. The divergence is RouteTree.getPathSegments():

    private getPathSegments(route: string): string[] {
      return route.split('/').filter(Boolean).map(decodeURIComponent);
    }

    against @angular/router's DefaultUrlSerializer, in which (, ), ; and // are metacharacters and unparseable input is silently discarded. Verified against the published @angular/router 22.1.6, serialize(parse(x)):

    input router resolves to
    /page) /page
    /page( /page
    /page; /page
    /(page) /page
    /a/1//b /a/1
    /a/b)c/d /a/b

    Parentheses are part of the class. This issue names ; and //. ( and ) do the same thing, and /(profile) is a valid spelling of /profile because angular/angular#64507 deliberately made an unnamed (...) group mean the primary outlet. Enumerated over printable ASCII, the single-character triggers are exactly (, ) and ;. A differential sweep of 1575 paths produced 80 divergent paths in two root shapes.

    The attacker picks which route's policy to borrow, not just the catch-all. The mangled segment list is matched normally, so any route pattern that fits it is selectable. With a long-lived public sub-route under a private prefix, which applications routinely add for share links and print views:

    /priv/1         Cache-Control=no-store, private                     rendered=priv/:id
    /priv/1/share   Cache-Control=public, max-age=31536000, immutable   rendered=priv/:id/share
    /priv/1//share  Cache-Control=public, max-age=31536000, immutable   rendered=priv/:id
    

    Line three is the account page under a year-long immutable label.

    Content-Type and status are attacker-selected too, because handleRendering spreads the matched route's headers after Content-Type and takes status from the matched route.

    What is NOT affected: canActivate and the other router guards still run, because @angular/router sees an ordinary path. Not an authorization bypass.

    There is a fix in #34091, which normalises the pathname through the router's own serializer in ServerRouter.match before tokenising, so both matchers agree. It covers ;, // and the parentheses in one place rather than case by case, the way stripMatrixParams handled the matrix-parameter symptom in 85c18b4. Tests cover the divergent spellings and were confirmed to fail before the change. I will repoint it at this issue.

  4. added a commit that references this issue on Sep 14, 2026
    6d6decb
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