Repository navigation
fix(security): SSRF guard for server-side fetches of user-chosen hosts (TECH-811) - #263
Merged
Merged
Conversation
… SSRF (TECH-811) The server fetched URLs a visitor can choose with no check on where the host points: the OAuth client during sign-in (metadata, PAR, token), did:web and handle .well-known lookups, foreign-PDS reads in the xrpc proxy (whose responses are passed back to the caller), and PDS calls in the app-password, group-account and group routes. isAllowedPdsUrl only checks the URL string, so a hostname resolving to 127.0.0.1 / 169.254.169.254 got through, and the OAuth client had no check at all. guardedFetch (src/lib/security/ssrf.ts) adds an undici dispatcher that refuses any connection to a non-public address (ipaddr.js range, IPv4-mapped judged as IPv4, NAT64/6to4 refused): hostnames are resolved once and every address must be public, the connection uses the checked address, literal IPs are checked before connecting, and every redirect hop is checked again. - atproto's safeFetchWrap isn't usable here: on Node >= 20 it passes the dispatcher on a Request, which Next's patched fetch rebuilds without it, so the guard would silently not apply. guardedFetch passes it on init. - An undici 6 Agent (what fetch-node ships) fails every request on newer Node; undici 7 is now a direct dependency, matching Node 24's bundled undici. ipaddr.js is a direct dependency too. - did.ts is also bundled for the browser, so it reads the guard from a process-wide slot that src/instrumentation.ts fills at server start. - ALLOW_PRIVATE_NETWORK_FETCH=true turns the guard off in local development only. Verified on a throwaway Vercel preview (Node 24.21.0, undici 7.29.1, fra1): the slot is filled on a route that never imports the guard; a public DID document fetches (200); localtest.me and 169.254.169.254 are refused with SsrfBlockedError; the xrpc proxy's foreign read and /api/resolve-did work. Tests: address classification; real loopback servers for literal IPs, DNS names, allowed targets and redirect hops (no connection is opened for a refused target); dispatcher on init; the development-only opt-out; did.ts using the slot; register(); and a contract test that fails on a new bare fetch of a PDS / DID-derived URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up to 3c9af82 (three subagents: bypasses, production breakage, tests). - Importing undici's package index installs its Agent as the process-wide fetch dispatcher (lib/global.js), and instrumentation loads the guard before any fetch, so every fetch in the app moved off Node's bundled undici. ssrf.ts now deep-imports Agent and buildConnector, which don't; types still come from the index. The build output no longer contains the global-dispatcher code. - did.ts failed open: with an empty slot it fell back to plain fetch on the server too. It now fails closed there; plain fetch remains for the browser. - IPv6 must be global unicast (2000::/3): ipaddr calls the IPv4-compatible block ::/96 (e.g. ::7f00:1, what the URL parser makes of [::127.0.0.1]) "unicast". - Tests: hostname cases use a stubbed resolver, so they don't depend on whether localhost also resolves to ::1 (it doesn't on Linux CI, where the redirect-hop test would have failed); new cases for an allowed hostname connecting and a hostname with one refused address among several; register() is checked before the test imports the guard; the server fails closed without the slot; importing the guard leaves the global dispatcher alone. The contract test now also fails on a bare fetch of anything but a fixed upstream in files that handle PDS URLs (catches fetch(url) via a variable) and on an atproto Agent built on a service URL. - Comments and AGENTS.md: the Node/undici version note, the deep imports, and fail-closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Caution Review failedThe pull request is closed. Finishing Touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
holkexyz
marked this pull request as ready for review
October 29, 2026 15:36
svc-agent
pushed a commit
that referenced
this pull request
Oct 29, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hotfix to
mainfor TECH-811, already onstaging(3c9af82, 464803e). After merge, main and staging hold identical files.Problem
The server fetched URLs a visitor can choose, with no check on where the host points:
did:webdocuments and handle/.well-known/atproto-didlookups (did.ts), reachable without an account via/api/resolve-did(s)getRecord,listRecords,getBlob), whose responses are passed back to the caller, so this was a read-back SSRF, not only blind probingget-record-server,resolve-core, the app-password and group-account sessions, and the group PDS routesisAllowedPdsUrlonly checks the URL string, so a hostname that resolves to127.0.0.1or169.254.169.254(e.g.localtest.me, any attacker DNS) got through. The OAuth client had no check at all.Fix
guardedFetch(src/lib/security/ssrf.ts) passes an undici dispatcher that refuses any connection to a non-public address:2000::/3; NAT64, 6to4 and Teredo refused). The connection uses the address that was checked, so there is no DNS-rebinding window.safeFetch, which also covers everyAgentbuilt on an OAuth session.Why it's built this way:
safeFetchWrap: on Node ≥ 20 it passes the dispatcher on aRequest, and Next's patched fetch rebuildsRequestinputs without it (next/dist/server/lib/patch-fetch.js:673), so the guard would silently not apply.guardedFetchpasses it oninit, which Next keeps.Agent/buildConnector: undici's package index replaces the process-wide fetch dispatcher when it loads, which would move every fetch in the app (Upstash, Resend, …) off Node's bundled undici.did.ts: it's also bundled for the browser, so it reads the guard from a process-wide slot that the newsrc/instrumentation.tsfills at server start. On the server an empty slot fails closed; the browser keeps plain fetch.ALLOW_PRIVATE_NETWORK_FETCH=trueturns the guard off there only, documented in.env.local.example.undici@^7.30.1andipaddr.js@^2.5.0are direct now. Lockfile: root undici 6.29.1 → 7.30.1 (6.29.1 stays nested under@atproto-labs/fetch-node), jsdom's 7.30.1 deduped, ipaddr.js 2.3 → 2.5. Nothing else moved.Testing
::7f00:1,169.254.169.254, CGNAT, mapped/NAT64/6to4init; the development-only opt-outdid.tsusing the slot, and failing closed on the server;register()installing the guardfetch(of a PDS/DID-derived URL, including via a variable in files that handle PDS URLs, and on an atprotoAgentbuilt on a service URL.tsc,typecheck:test,lint,lint:css, 1500 unit tests,build, Playwright (60 passed). The build output no longer contains undici's global-dispatcher code.localtest.meand169.254.169.254are refused withSsrfBlockedError/api/resolve-didworkdid:web:localtest.meanddid:web:169.254.169.254.nip.iorefused, and no "guard not installed" or import errors.Review
Three subagents reviewed it: bypasses, production breakage, and tests. They found the global-dispatcher side effect,
did.tsfailing open, the::/96IPv6 form, a redirect test that would have failed on Linux CI, a no-op-able instrumentation test, and contract gaps. All are fixed in 464803e.Verified fine:
localhost., nip.io, redirects and proxy env vars are all refused before connectingdispatcherNot yet exercised
A real sign-in through the guard (the OAuth client's PAR and token requests) on staging or production.
🤖 Generated with Claude Code


Confidence Score: 5/5
The PR appears safe to merge; no actionable defect was found.Summary
Adds connection-level checks for server requests to visitor-chosen hosts.
No actionable issues were found. Tests and builds were not run during this review. svc-agent noted that a full sign-in through PAR and token requests remains unexercised.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[OAuth and PDS requests] --> G[guardedFetch] B[Server DID and handle lookups] --> S{Guard installed?} S -->|Yes| G S -->|No| F[Refuse lookup] G --> H{Literal address?} H -->|Yes| C[Check address] H -->|No| D[Resolve and check every returned address] C --> P{Public address only?} D --> P P -->|No| R[Refuse connection] P -->|Yes| N[Connect using checked address]Reviews (1) · Last reviewed commit: "fix(security): harden the SSRF guard aft..." · Reviewed by Greptile