Skip to content

fix(security): SSRF guard for server-side fetches of user-chosen hosts (TECH-811) - #263

Merged
holkexyz merged 2 commits into
mainfrom
holke/tech-811-ssrf-guard
Oct 29, 2026
Merged

holkexyz merged 2 commits into
mainfrom
holke/tech-811-ssrf-guard

Conversation

@svc-agent

@svc-agent svc-agent commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Hotfix to main for TECH-811, already on staging (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:

  • the OAuth client during sign-in: protected-resource and authorization-server metadata, PAR and token endpoints taken from that metadata
  • did:web documents and handle /.well-known/atproto-did lookups (did.ts), reachable without an account via /api/resolve-did(s)
  • foreign-PDS reads in the xrpc proxy (getRecord, listRecords, getBlob), whose responses are passed back to the caller, so this was a read-back SSRF, not only blind probing
  • PDS calls in get-record-server, resolve-core, the app-password and group-account sessions, and the group PDS routes

isAllowedPdsUrl only checks the URL string, so a hostname that resolves to 127.0.0.1 or 169.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:

  • Hostnames: resolved once, and every address must be public unicast (ipaddr.js; IPv4-mapped judged as IPv4; IPv6 must be global unicast 2000::/3; NAT64, 6to4 and Teredo refused). The connection uses the address that was checked, so there is no DNS-rebinding window.
  • Literal IPs: checked before connecting.
  • Redirects: every hop is checked again.
  • Call sites: all the paths above are switched. The OAuth client gets it through safeFetch, which also covers every Agent built on an OAuth session.

Why it's built this way:

  • Not atproto's safeFetchWrap: on Node ≥ 20 it passes the dispatcher on a Request, and Next's patched fetch rebuilds Request inputs without it (next/dist/server/lib/patch-fetch.js:673), so the guard would silently not apply. guardedFetch passes it on init, which Next keeps.
  • undici 7 as a direct dependency: the undici 6 that atproto's helper uses fails every request on newer Node. undici 7 matches Node 24's bundled undici and also works on Node 26.
  • Deep imports of 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 new src/instrumentation.ts fills at server start. On the server an empty slot fails closed; the browser keeps plain fetch.
  • Local development: ALLOW_PRIVATE_NETWORK_FETCH=true turns the guard off there only, documented in .env.local.example.
  • Dependencies: undici@^7.30.1 and ipaddr.js@^2.5.0 are 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

  • Unit tests against real loopback servers; a refused target opens no connection:
    • literal IPs, DNS names (with a stubbed resolver so CI and macOS behave the same), an allowed hostname connecting, a hostname with one refused address among several, and redirect hops
    • the address table, including ::7f00:1, 169.254.169.254, CGNAT, mapped/NAT64/6to4
    • dispatcher on init; the development-only opt-out
    • did.ts using the slot, and failing closed on the server; register() installing the guard
    • importing the guard leaves the global dispatcher alone
  • Contract test: fails on a bare fetch( of a PDS/DID-derived URL, including via a variable in files that handle PDS URLs, and on an atproto Agent built on a service URL.
  • Local checks: tsc, typecheck:test, lint, lint:css, 1500 unit tests, build, Playwright (60 passed). The build output no longer contains undici's global-dispatcher code.
  • Throwaway Vercel preview (Node 24.21.0, bundled undici 7.29.1, fra1), since removed:
    • 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
  • Staging (464803e): pages, resolve-did, foreign reads and the OAuth metadata answer 200. The logs show did:web:localtest.me and did:web:169.254.169.254.nip.io refused, 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.ts failing open, the ::/96 IPv6 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:

  • odd IPv4 encodings, localhost., nip.io, redirects and proxy env vars are all refused before connecting
  • Next's stale-revalidate path keeps the dispatcher
  • pooling, timeouts, 4 MB uploads, streaming and cancellation behave as before
  • Next's fetch cache key ignores dispatcher

Not 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

RetriggerView in GreptileConfidence 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.

  • Server fetches reject private destinations chosen through URLs.
  • OAuth sign-in requests check the addresses of their destination hosts.
  • DID and handle lookups use the server address guard.
  • PDS-derived reads and account calls reject private destinations.

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]
Loading

Reviews (1) · Last reviewed commit: "fix(security): harden the SSRF guard aft..." · Reviewed by Greptile

holkexyz and others added 2 commits October 29, 2026 16:30
… 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>
@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated
certified-app Ready Ready Preview Oct 29, 2026 3:31pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedipaddr.js@​2.3.0 ⏵ 2.5.0100100100 +182100

View full report

@holkexyz
holkexyz marked this pull request as ready for review October 29, 2026 15:36
@holkexyz
holkexyz merged commit a34ef37 into main Oct 29, 2026
7 checks passed
@holkexyz
holkexyz deleted the holke/tech-811-ssrf-guard branch 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

1 active deployment
Preview — 38ac2439 Deployed Oct 29, 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.

2 participants