Skip to content

fix(api): stop /api/grade from fetching arbitrary internal URLs - #33

Merged
lautaropaske merged 1 commit into
mainfrom
lautaropaske/fix-grade-ssrf-multipart
Aug 31, 2026
Merged

lautaropaske merged 1 commit into
mainfrom
lautaropaske/fix-grade-ssrf-multipart

Conversation

@lautaropaske

Copy link
Copy Markdown
Contributor

/api/grade?url= handed a caller-supplied string straight to fetch(): no scheme check, no address check, no timeout and no size cap, on a route with maxDuration: 300. Anything the function could reach, a visitor could reach through it — 169.254.169.254 included — and read back as a 400 or a grade. The multipart branch had the matching problem: with bodyParser: false nothing bounded the stream it buffered.

The feature is "paste a link to your CV", so a host allowlist isn't on the table. fetchRemoteResume guards where the connection actually lands:

  • https only, no embedded credentials, port 443 only — an arbitrary port turns this into an internal port scanner.
  • Every resolved address is checked against loopback, RFC1918, CGNAT, link-local, benchmarking, documentation, multicast and reserved space, v4 and v6, including the transition formats that hide a v4 address inside a v6 one (::ffff:, NAT64, 6to4). If any record for a host is private the host is refused, so a round-robin mixing public and private answers doesn't get a second roll.
  • The socket is pinned to the address we validated, with servername and Host still set to the name, so a DNS rebind can't swap the metadata endpoint in between the check and the connect. Validate-then-fetch() leaves that window open.
  • Redirects followed by hand, three at most, each hop re-parsed and re-resolved from scratch — a Location is a fresh caller-supplied URL.
  • 20s deadline across all hops, and a 10MB cap on both content-length and the running total, since a chunked response declares nothing.

Failures carry stable codes, so the route answers 4xx instead of falling through to the 500, and getErrorMessage maps them to copy. It now reads the code before looking at the status: our own 413 knows the route's limit, while Vercel's plain-text 413 is still the one that mentions its 4.5MB payload cap.

Behavior change worth flagging

A genuinely slow origin now fails with ResumeFetchTimeout at 20s instead of grinding against the 300s route budget. maxDuration: 300 is untouched — the model call still lives under it.

Testing

22 new cases, all network-free: address classification both ways, and the URL shapes refused before a socket opens — decimal (https://2130706433/) and hex loopback encodings among them, which new URL() normalizes and the resolver would otherwise reach happily. Plus the route's own contract: 400 for loopback/metadata/non-https, 413 for an upload over the cap.

Verified separately against real origins, kept out of the suite so CI doesn't depend on third-party hosts: a real public PDF still downloads, a redirect to a public host still follows, a public hostname resolving to 127.0.0.1 is blocked, and both size-cap branches and both deadline branches fire.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Xa76D7n4CLkdKt99Su1HRy

`/api/grade?url=` handed a caller-supplied string straight to `fetch()`: no
scheme check, no address check, no timeout and no size cap, on a route with
`maxDuration: 300`. Anything the function could reach, a visitor could reach
through it — `169.254.169.254` included — and read back as a 400 or a grade.
The multipart branch had the matching problem: with `bodyParser: false`
nothing bounded the stream it buffered.

The feature is "paste a link to your CV", so a host allowlist isn't on the
table. `fetchRemoteResume` guards where the connection actually lands:

- https only, no embedded credentials, port 443 only — an arbitrary port
  turns this into an internal port scanner.
- Every resolved address is checked against loopback, RFC1918, CGNAT,
  link-local, benchmarking, documentation, multicast and reserved space, v4
  and v6, including the transition formats that hide a v4 address inside a v6
  one. If any record for a host is private the host is refused, so a
  round-robin that mixes public and private answers doesn't get a second roll.
- The socket is pinned to the address we validated, with `servername` and
  `Host` still set to the name, so a DNS rebind can't swap the metadata
  endpoint in between the check and the connect. Validate-then-`fetch()`
  leaves that window open.
- Redirects are followed by hand, three at most, each hop re-parsed and
  re-resolved from scratch — a `Location` is a fresh caller-supplied URL.
- 20s deadline across all hops, and a 10MB cap on both `content-length` and
  the running total, since a chunked response declares nothing.

Failures carry stable codes, so the route answers 4xx instead of falling
through to the 500, and `getErrorMessage` maps them to copy. It now reads the
code before looking at the status: our own 413 knows the route's limit, while
Vercel's plain-text 413 is still the one that mentions its 4.5MB payload cap.

Tests are network-free: address classification both ways, and the URL shapes
refused before a socket opens — decimal and hex loopback encodings among them,
which `new URL()` normalizes and the resolver would otherwise reach happily.
The timeout and both size-cap branches were checked against real origins
locally; those cases stay out of the suite rather than make CI depend on
third-party hosts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xa76D7n4CLkdKt99Su1HRy
@vercel

vercel Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
open-silver Ready Ready Preview Aug 31, 2026 7:42pm

Request Review

@lautaropaske
lautaropaske merged commit 36ff5d8 into main Aug 31, 2026
3 checks passed
@lautaropaske
lautaropaske deleted the lautaropaske/fix-grade-ssrf-multipart branch August 31, 2026 20:14

This branch was successfully deployed

1 active deployment
Preview — da9f6e82 Deployed Aug 31, 2026 by vercel[bot]
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