Skip to content

fix(media): route storage upload plans through the SSRF-safe fetch guard - #517

Merged
DavidBabinec merged 1 commit into
mainfrom
fix/media-upload-plan-ssrf
Sep 10, 2026
Merged

fix(media): route storage upload plans through the SSRF-safe fetch guard#517
DavidBabinec merged 1 commit into
mainfrom
fix/media-upload-plan-ssrf

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

The bug

A plugin holding only media.storage.adapter supplies the step URLs in a media upload plan, and the executor fetched them with a raw fetch(). The grant therefore carried network.outbound reach it never asked for: arbitrary PUT/POST at loopback, private, link-local, or cloud-metadata addresses, with plugin-chosen headers and the media bytes as the body.

This is the write-side sibling of GHSA-rmm7. That fix guarded the read path in 0.0.18 and left the write path untouched, one call site away.

The fix

executeStep now calls guardedFetch, the same guard mediaStorageReader uses: internal addresses refused before a connection opens, connection pinned to the validated IP, every redirect hop re-checked. No host allowlist on either side, since an object-store endpoint is an arbitrary operator-chosen public host.

The new architecture gate fails on any bare fetch( under server/handlers/cms/, not just this call site, because fixing one call site is what let the class reopen. Also corrects the mediaStorageRegistry doc comment that asserted a networkAllowedHosts gate the code never implemented.

Reported as GHSA-9pq7-m5wf-r7f6.

Verification

Reproduced against a live loopback listener, before and after:

BEFORE  UPLOAD COMPLETED / listener: PUT /probe x-canary=1 bytes=[222,173,190,239]
AFTER   REFUSED: ... "127.0.0.1", which is a blocked address / listener: (none)
bun test   # 6851 pass, 0 fail
bun run build && bun run lint   # clean

The 0.0.18 fix for GHSA-rmm7 guarded the media storage READ path and left
its write sibling on a raw fetch. A plugin holding only
media.storage.adapter supplies the upload plan's step URLs, and the
executor fetched them directly, so the grant conveyed network.outbound
reach it never asked for: arbitrary PUT/POST at loopback, private,
link-local, or cloud-metadata addresses, carrying plugin-chosen headers
and the media bytes.

executeStep now calls guardedFetch, exactly as mediaStorageReader does:
internal addresses refused before a connection opens, connection pinned
to the validated IP, every redirect hop re-checked. No host allowlist on
either side, since an object-store endpoint is an arbitrary
operator-chosen public host.

Also corrects the mediaStorageRegistry doc comment, which asserted a
networkAllowedHosts gate the code never implemented.

The new architecture gate fails on any bare fetch( under
server/handlers/cms/. Fixing one call site is what let this reopen, so
the rule is file-scoped rather than call-site-scoped.
@DavidBabinec
DavidBabinec merged commit f6badf0 into main Sep 10, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant