CVE-2026-53607: apostrophecms apostrophe · Server-Side Request Forgery (SSRF)
Affects apostrophecms apostropheSource CVE Database V5
Not known to be exploited · CVSS 3.7 Low.
Data as of 30 Oct 2026, 11:52 UTC
- EPSS
- 0.2% chance of exploitation in 30 days – higher than 19% of CVEsExploit Prediction Scoring System: the likelihood attackers will use this flaw soon.
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:NExploitation evidence
Disclosed: evidence found.
CVE published 12 Jun 2026
Detectable: no evidence on record.
No public nuclei template indexed
Weaponized: no evidence on record.
No public exploit code indexed in Metasploit or Exploit-DB
Exploited in the wildExploited: no evidence on record.
Not in CISA KEV and no source reports exploitation
Observed by OffSeq MirageSeen by Mirage: no evidence on record.
No attack traffic recorded by OffSeq Mirage sensors
A missing step is not evidence of safety. Sources: cve.org (opens in a new tab), the Metasploit, Exploit-DB and nuclei-templates index as of 30 Oct 2026.
What to do
Affected software
| Product | Affected | Fixed in |
|---|---|---|
| apostrophecmsapostrophe | Affected
| Fixed in– |
AI-suggested
Administrators should consider disabling the prettyUrls option in @apostrophecms/file or avoid using local uploadfs storage until a fix is released. Monitor the vendor advisory for updates. Since this vulnerability requires prettyUrls enabled and local storage, changing these configurations mitigates the risk. Avoid exposing the apostrophe server to untrusted networks where attackers can send arbitrary Host headers.
Description
### 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