GHSA-34pj-2622-jvxq @apostrophecms/file pretty-URL Vulnerable to Unauthenticated SSRF via Host header
SummaryWhen `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: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`):`lib/stream-proxy.js` (excerpt):`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.PoCThree 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):`docker-compose.yml`:`exploit.sh` (unauthenticated attacker on the host):Build & run:Observed output (`apostrophe@4.30.0`, clean stack):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.ImpactUnauthenticated 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 fixStop 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')`: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.
CVSS v3.1 score: 3.7