Skip to content

fix(api): restrict /api/fetch to the hosts we actually fetch from - #513

Merged
joeyballentine merged 1 commit into
mainfrom
fix/ssrf-guard-api-fetch
Jul 31, 2026
Merged

fix(api): restrict /api/fetch to the hosts we actually fetch from#513
joeyballentine merged 1 commit into
mainfrom
fix/ssrf-guard-api-fetch

Conversation

@joeyballentine

Copy link
Copy Markdown
Contributor

/api/fetch passed its request body straight to fetch, so the dev server would request whatever it was pointed at. It is dev-only — the deployed site is a static export with no API routes, and the one caller guards on location.hostname === 'localhost' — so this is hardening a local tool rather than closing a hole on the live site.

Three layers, in src/lib/server/safe-fetch.ts:

  • A host allowlist. image-util.ts already knows the only three hosts we fetch HTML from; src/lib/fetchable-hosts.ts makes that list something both sides import instead of each keeping its own copy. The suffix match anchors on a dot, so imgbox.com.evil.com does not pass.
  • A resolved-address check, covering the ranges that are easy to miss by hand: IPv4-mapped IPv6, CGNAT, multicast and reserved space.
  • Manual redirect handling, revalidating every hop. Without it an allowed host can redirect us to link-local metadata and the allowlist means nothing.

This does not stop DNS rebinding, and says so in a comment rather than implying otherwise: fetch resolves independently of our lookup. The allowlist is what makes that acceptable — exploiting it needs authoritative DNS for one of three specific domains.

Rejected URLs now answer 400 instead of 500, so "unsupported link" and "upstream is down" are distinguishable.

`/api/fetch` passed its request body straight to `fetch`, so the dev
server would request whatever it was pointed at. It is dev-only — the
deployed site is a static export with no API routes, and the one caller
guards on `location.hostname === 'localhost'` — so this is hardening a
local tool rather than closing a hole on the live site.

Three layers, in `src/lib/server/safe-fetch.ts`:

- A host allowlist. `image-util.ts` already knows the only three hosts we
  fetch HTML from; `src/lib/fetchable-hosts.ts` makes that list something
  both sides import instead of each keeping its own copy. The suffix
  match anchors on a dot, so `imgbox.com.evil.com` does not pass.
- A resolved-address check, covering the ranges that are easy to miss by
  hand: IPv4-mapped IPv6, CGNAT, multicast and reserved space.
- Manual redirect handling, revalidating every hop. Without it an allowed
  host can redirect us to link-local metadata and the allowlist means
  nothing.

This does not stop DNS rebinding, and says so in a comment rather than
implying otherwise: `fetch` resolves independently of our lookup. The
allowlist is what makes that acceptable — exploiting it needs
authoritative DNS for one of three specific domains.

Rejected URLs now answer 400 instead of 500, so "unsupported link" and
"upstream is down" are distinguishable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@joeyballentine
joeyballentine merged commit 9c9bd5a into main Jul 31, 2026
4 checks passed
@joeyballentine
joeyballentine deleted the fix/ssrf-guard-api-fetch branch July 31, 2026 18:35
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