2020import type { PublicKey } from "@atcute/crypto" ;
2121import { type AtprotoDid , isDid } from "@atcute/lexicons/syntax" ;
2222import { verifyRecord } from "@atcute/repo" ;
23+ import {
24+ cloudflareDohResolver ,
25+ type DnsResolver ,
26+ resolveAndValidateExternalUrl ,
27+ SsrfError ,
28+ } from "emdash/security/ssrf" ;
2329
2430const DEFAULT_TIMEOUT_MS = 15_000 ;
2531/** 5 MB ceiling. Records and their proofs are tiny (sub-KB typical); this is
2632 * a defence against a hostile or broken PDS streaming an unbounded body. */
2733const DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024 ;
34+ /** Redirect hops we'll follow before giving up. Each hop is independently
35+ * re-validated against the SSRF egress rules before it is fetched. */
36+ const MAX_PDS_REDIRECTS = 3 ;
2837
2938export type VerificationFailureReason =
3039 | "PDS_NETWORK_ERROR"
3140 | "PDS_HTTP_ERROR"
3241 | "RECORD_NOT_FOUND"
3342 | "RESPONSE_TOO_LARGE"
34- | "INVALID_PROOF" ;
43+ | "INVALID_PROOF"
44+ | "PDS_HOST_BLOCKED" ;
3545
3646export class PdsVerificationError extends Error {
3747 override readonly name = "PdsVerificationError" ;
@@ -57,6 +67,10 @@ export interface FetchAndVerifyOptions {
5767 maxResponseBytes ?: number ;
5868 /** Inject for tests; defaults to `globalThis.fetch`. */
5969 fetch ?: typeof fetch ;
70+ /** Resolves each hop's hostname so its addresses can be checked against the
71+ * private/reserved-IP blocklist before the request is made. Inject for
72+ * tests; defaults to the DoH resolver used by artifact acquisition. */
73+ resolveHostname ?: DnsResolver ;
6074}
6175
6276export interface VerifiedPdsRecord {
@@ -73,6 +87,7 @@ export async function fetchAndVerifyRecord(
7387 const fetchImpl = opts . fetch ?? fetch ;
7488 const timeoutMs = opts . timeoutMs ?? DEFAULT_TIMEOUT_MS ;
7589 const maxResponseBytes = opts . maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES ;
90+ const resolveHostname = opts . resolveHostname ?? cloudflareDohResolver ;
7691
7792 if ( ! isAtprotoDid ( opts . did ) ) {
7893 // Caller is expected to have validated this upstream (the resolver
@@ -84,7 +99,7 @@ export async function fetchAndVerifyRecord(
8499 ) ;
85100 }
86101 const url = buildGetRecordUrl ( opts . pds , opts . did , opts . collection , opts . rkey ) ;
87- const carBytes = await fetchCar ( fetchImpl , url , timeoutMs , maxResponseBytes ) ;
102+ const carBytes = await fetchCar ( fetchImpl , url , timeoutMs , maxResponseBytes , resolveHostname ) ;
88103
89104 try {
90105 const result = await verifyRecord ( {
@@ -117,31 +132,126 @@ function buildGetRecordUrl(pds: string, did: string, collection: string, rkey: s
117132 return url . toString ( ) ;
118133}
119134
135+ /**
136+ * Reject a PDS URL that is not HTTPS or whose host resolves to a
137+ * private/reserved address. The publisher controls the PDS endpoint (and any
138+ * redirect it serves), so this reuses the same DNS-aware SSRF validator that
139+ * guards artifact acquisition rather than duplicating any address logic.
140+ */
141+ async function assertAllowedPdsUrl ( url : string , resolveHostname : DnsResolver ) : Promise < void > {
142+ let parsed : URL ;
143+ try {
144+ parsed = new URL ( url ) ;
145+ } catch {
146+ throw new PdsVerificationError ( "PDS_HOST_BLOCKED" , `PDS URL is not a valid URL: ${ url } ` ) ;
147+ }
148+ if ( parsed . protocol !== "https:" ) {
149+ throw new PdsVerificationError (
150+ "PDS_HOST_BLOCKED" ,
151+ `PDS URL must use https, got ${ parsed . protocol } ` ,
152+ ) ;
153+ }
154+ try {
155+ await resolveAndValidateExternalUrl ( url , { resolver : resolveHostname } ) ;
156+ } catch ( err ) {
157+ if ( err instanceof SsrfError ) {
158+ throw new PdsVerificationError (
159+ "PDS_HOST_BLOCKED" ,
160+ `PDS host rejected: ${ err . message } ` ,
161+ undefined ,
162+ err ,
163+ ) ;
164+ }
165+ throw err ;
166+ }
167+ }
168+
169+ /**
170+ * Fetch `initialUrl`, following redirects manually so every hop — the
171+ * publisher-controlled PDS endpoint and any `Location` it names — is
172+ * re-validated against the SSRF egress rules before the request is made. A
173+ * hop pointing at a forbidden scheme or address rejects with
174+ * `PDS_HOST_BLOCKED`.
175+ */
176+ async function fetchWithRedirectGuard (
177+ fetchImpl : typeof fetch ,
178+ initialUrl : string ,
179+ signal : AbortSignal ,
180+ timeoutMs : number ,
181+ resolveHostname : DnsResolver ,
182+ ) : Promise < Response > {
183+ let currentUrl = initialUrl ;
184+ for ( let hop = 0 ; ; hop ++ ) {
185+ await assertAllowedPdsUrl ( currentUrl , resolveHostname ) ;
186+
187+ let response : Response ;
188+ try {
189+ response = await fetchImpl ( currentUrl , {
190+ signal,
191+ redirect : "manual" ,
192+ headers : { accept : "application/vnd.ipld.car" } ,
193+ } ) ;
194+ } catch ( err ) {
195+ // Whether the fetch threw because we aborted (timeout) or because the
196+ // network failed at the OS layer, the right caller behaviour is the
197+ // same: retry. Lump them under PDS_NETWORK_ERROR.
198+ throw new PdsVerificationError (
199+ "PDS_NETWORK_ERROR" ,
200+ err instanceof Error && err . name === "AbortError"
201+ ? `PDS fetch aborted after ${ timeoutMs } ms`
202+ : `PDS fetch failed: ${ err instanceof Error ? err . message : String ( err ) } ` ,
203+ undefined ,
204+ err ,
205+ ) ;
206+ }
207+
208+ if ( response . status < 300 || response . status >= 400 ) return response ;
209+
210+ if ( hop >= MAX_PDS_REDIRECTS ) {
211+ throw new PdsVerificationError (
212+ "PDS_HTTP_ERROR" ,
213+ `PDS exceeded ${ MAX_PDS_REDIRECTS } redirects` ,
214+ response . status ,
215+ ) ;
216+ }
217+ const location = response . headers . get ( "location" ) ;
218+ if ( location === null ) {
219+ throw new PdsVerificationError (
220+ "PDS_HTTP_ERROR" ,
221+ `PDS redirect ${ response . status } without a location header` ,
222+ response . status ,
223+ ) ;
224+ }
225+ try {
226+ currentUrl = new URL ( location , currentUrl ) . toString ( ) ;
227+ } catch {
228+ throw new PdsVerificationError (
229+ "PDS_HOST_BLOCKED" ,
230+ `PDS redirect location is not a valid URL: ${ location } ` ,
231+ response . status ,
232+ ) ;
233+ }
234+ }
235+ }
236+
120237async function fetchCar (
121238 fetchImpl : typeof fetch ,
122239 url : string ,
123240 timeoutMs : number ,
124241 maxBytes : number ,
242+ resolveHostname : DnsResolver ,
125243) : Promise < Uint8Array > {
244+ const deadline = Date . now ( ) + timeoutMs ;
126245 const controller = new AbortController ( ) ;
127246 const timer = setTimeout ( ( ) => controller . abort ( ) , timeoutMs ) ;
128247 let response : Response ;
129248 try {
130- response = await fetchImpl ( url , {
131- signal : controller . signal ,
132- headers : { accept : "application/vnd.ipld.car" } ,
133- } ) ;
134- } catch ( err ) {
135- // Whether the fetch threw because we aborted (timeout) or because the
136- // network failed at the OS layer, the right caller behaviour is the
137- // same: retry. Lump them under PDS_NETWORK_ERROR.
138- throw new PdsVerificationError (
139- "PDS_NETWORK_ERROR" ,
140- err instanceof Error && err . name === "AbortError"
141- ? `PDS fetch aborted after ${ timeoutMs } ms`
142- : `PDS fetch failed: ${ err instanceof Error ? err . message : String ( err ) } ` ,
143- undefined ,
144- err ,
249+ response = await fetchWithRedirectGuard (
250+ fetchImpl ,
251+ url ,
252+ controller . signal ,
253+ timeoutMs ,
254+ resolveHostname ,
145255 ) ;
146256 } finally {
147257 clearTimeout ( timer ) ;
@@ -172,12 +282,34 @@ async function fetchCar(
172282 if ( ! reader ) {
173283 throw new PdsVerificationError ( "INVALID_PROOF" , "PDS response body is null" ) ;
174284 }
285+ return readCarBody ( reader , maxBytes , deadline , timeoutMs ) ;
286+ }
287+
288+ /**
289+ * Buffer the CAR body, bounding both its size (`maxBytes`) and its total wall
290+ * time (`deadline`) — the header-phase abort timer is already cleared by the
291+ * time we stream, so without this a slow-drip PDS could hold the read open
292+ * indefinitely. Each read is raced against the remaining budget; exhausting it
293+ * rejects as a transient `PDS_NETWORK_ERROR` so the consumer retries.
294+ */
295+ async function readCarBody (
296+ reader : ReadableStreamDefaultReader < Uint8Array > ,
297+ maxBytes : number ,
298+ deadline : number ,
299+ timeoutMs : number ,
300+ ) : Promise < Uint8Array > {
175301 const chunks : Uint8Array [ ] = [ ] ;
176302 let total = 0 ;
177303 try {
178- // biome-ignore lint/correctness/noConstantCondition: drains the stream
179- while ( true ) {
180- const { done, value } = await reader . read ( ) ;
304+ for ( ; ; ) {
305+ const remaining = deadline - Date . now ( ) ;
306+ if ( remaining <= 0 ) {
307+ throw new PdsVerificationError (
308+ "PDS_NETWORK_ERROR" ,
309+ `PDS body read exceeded ${ timeoutMs } ms` ,
310+ ) ;
311+ }
312+ const { done, value } = await readWithDeadline ( reader , remaining , timeoutMs ) ;
181313 if ( done ) break ;
182314 total += value . byteLength ;
183315 if ( total > maxBytes ) {
@@ -193,10 +325,11 @@ async function fetchCar(
193325 await reader . cancel ( ) . catch ( ( ) => {
194326 /* swallow — we already have a primary error to surface */
195327 } ) ;
196- // A read error after headers (socket drop, stream abort) is transient —
197- // re-wrap it so the consumer retries rather than dead-lettering it as an
198- // unexpected failure. Our own PdsVerificationErrors (e.g. too-large)
199- // carry their own classification and pass through.
328+ // A read error after headers (socket drop, stream abort, stall) is
329+ // transient — re-wrap it so the consumer retries rather than
330+ // dead-lettering it as an unexpected failure. Our own
331+ // PdsVerificationErrors (too-large, budget exceeded) carry their own
332+ // classification and pass through.
200333 if ( err instanceof PdsVerificationError ) throw err ;
201334 throw new PdsVerificationError (
202335 "PDS_NETWORK_ERROR" ,
@@ -217,6 +350,44 @@ async function fetchCar(
217350 return out ;
218351}
219352
353+ /**
354+ * Race a single `reader.read()` against a per-read timeout. Handlers are
355+ * attached to the read promise so a read that loses the race cannot later
356+ * surface as an unhandled rejection.
357+ */
358+ function readWithDeadline (
359+ reader : ReadableStreamDefaultReader < Uint8Array > ,
360+ remainingMs : number ,
361+ timeoutMs : number ,
362+ ) : Promise < ReadableStreamReadResult < Uint8Array > > {
363+ return new Promise ( ( resolve , reject ) => {
364+ let settled = false ;
365+ const timer = setTimeout ( ( ) => {
366+ if ( settled ) return ;
367+ settled = true ;
368+ reject (
369+ new PdsVerificationError ( "PDS_NETWORK_ERROR" , `PDS body read exceeded ${ timeoutMs } ms` ) ,
370+ ) ;
371+ } , remainingMs ) ;
372+ void reader . read ( ) . then (
373+ ( result ) => {
374+ if ( settled ) return undefined ;
375+ settled = true ;
376+ clearTimeout ( timer ) ;
377+ resolve ( result ) ;
378+ return undefined ;
379+ } ,
380+ ( err : unknown ) => {
381+ if ( settled ) return undefined ;
382+ settled = true ;
383+ clearTimeout ( timer ) ;
384+ reject ( err ) ;
385+ return undefined ;
386+ } ,
387+ ) ;
388+ } ) ;
389+ }
390+
220391/**
221392 * Map a `PdsVerificationError.reason` to "should the consumer retry?". Network
222393 * blips and 5xx are transient; everything else is permanent (forensics + ack).
0 commit comments