Skip to content

Commit 5913738

Browse files
committed
fix(labeler): route the PDS record fetch through an SSRF egress guard
The PDS endpoint is resolved from a publisher-controlled DID document, and the CAR fetch followed redirects with the default fetch policy and no scheme, DNS, or reserved-address checks. A hostile DID/PDS document could point the endpoint (or a redirect it serves) at a private/reserved address or a non-HTTPS host, turning the verifier into a blind SSRF probe. Validate every hop before fetching, sharing the DoH resolver (`cloudflareDohResolver`) that artifact acquisition uses and routing each hop through core's `resolveAndValidateExternalUrl`: HTTPS-only, DoH resolution, and private/reserved-IP rejection. Redirects are now followed manually (`redirect: "manual"`) so each `Location` is re-validated per-hop. A blocked hop rejects with the new permanent `PDS_HOST_BLOCKED` reason (dead-lettered, never retried). This shares the resolver but NOT artifact acquisition's address predicate: `fetchVerifiedResource`'s stricter `isForbiddenAddress` is not exported from @emdash-cms/registry-verification, so this path uses core's `isPrivateIp`, which blocks loopback, RFC1918, link-local/metadata, and IPv4-mapped/NAT64 forms but not CGNAT (100.64.0.0/10), benchmarking (198.18.0.0/15), or multicast/reserved (224.0.0.0/4, ff00::/8) — an accepted residual for PDS egress; closing it would mean exporting a published-package predicate. Also bound the body read by a wall-clock budget: the header-phase abort timer is cleared once headers arrive, so each streamed chunk is now raced against the remaining fetch timeout, preventing a slow-drip PDS from holding the read open indefinitely. The resolver is threaded through the discovery consumer's closure deps; the existing 404/5xx/size status classification the delete and retry paths depend on is preserved.
1 parent ed43eca commit 5913738

5 files changed

Lines changed: 417 additions & 24 deletions

File tree

apps/labeler/src/discovery-consumer.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
PlcDidDocumentResolver,
3333
} from "@atcute/identity-resolver";
3434
import type { LabelSigner } from "@emdash-cms/registry-moderation";
35+
import { cloudflareDohResolver, type DnsResolver } from "emdash/security/ssrf";
3536

3637
import {
3738
AssessmentDispatchError,
@@ -94,6 +95,9 @@ export interface DiscoveryConsumerDeps {
9495
* (assessment-dispatch.ts). */
9596
assessmentWorkflow: AssessmentWorkflowBinding;
9697
fetch?: typeof fetch;
98+
/** Resolves each PDS hop's hostname for the SSRF egress guard; defaults to
99+
* the DoH resolver used by artifact acquisition. */
100+
resolveHostname?: DnsResolver;
97101
now?: () => Date;
98102
/**
99103
* Optional override for the record-verification step. Used by tests to
@@ -107,13 +111,15 @@ export interface DiscoveryConsumerDeps {
107111
cid: string;
108112
didDocumentResolver: DidDocumentResolverLike;
109113
fetch?: typeof fetch;
114+
resolveHostname?: DnsResolver;
110115
}) => Promise<VerifiedPdsRecord>;
111116
/** Override for the delete-path absence check; defaults to
112117
* `confirmRecordAbsent`. Returns `true` when the record is verifiably gone. */
113118
confirmDeleted?: (opts: {
114119
uri: string;
115120
didDocumentResolver: DidDocumentResolverLike;
116121
fetch?: typeof fetch;
122+
resolveHostname?: DnsResolver;
117123
}) => Promise<boolean>;
118124
}
119125

@@ -137,6 +143,7 @@ export type DiscoveryDeadLetterReason =
137143
| "RESPONSE_TOO_LARGE"
138144
| "INVALID_PROOF"
139145
| "PDS_HTTP_ERROR"
146+
| "PDS_HOST_BLOCKED"
140147
| "DELETE_RECORD_PRESENT"
141148
| RecordVerificationFailureReason
142149
| "UNEXPECTED_ERROR";
@@ -215,6 +222,7 @@ export async function processDiscoveryMessage(
215222
uri,
216223
didDocumentResolver: deps.didDocumentResolver,
217224
...(deps.fetch ? { fetch: deps.fetch } : {}),
225+
...(deps.resolveHostname ? { resolveHostname: deps.resolveHostname } : {}),
218226
});
219227
if (!absent) {
220228
await writeDeadLetter(
@@ -349,6 +357,7 @@ async function verifyAndCreateRun(
349357
cid: job.cid,
350358
didDocumentResolver: deps.didDocumentResolver,
351359
...(deps.fetch ? { fetch: deps.fetch } : {}),
360+
...(deps.resolveHostname ? { resolveHostname: deps.resolveHostname } : {}),
352361
});
353362

354363
await createSubject(deps.db, {
@@ -521,6 +530,7 @@ function mapPdsReason(reason: VerificationFailureReason): DiscoveryDeadLetterRea
521530
case "RESPONSE_TOO_LARGE":
522531
case "INVALID_PROOF":
523532
case "PDS_HTTP_ERROR":
533+
case "PDS_HOST_BLOCKED":
524534
return reason;
525535
case "PDS_NETWORK_ERROR":
526536
throw new Error(
@@ -554,6 +564,9 @@ async function createProductionDiscoveryDeps(env: Env): Promise<DiscoveryConsume
554564
// bound wrapper rather than letting pds-verify.ts fall back to bare
555565
// global `fetch`.
556566
fetch: boundFetch,
567+
// SSRF egress guard for the publisher-controlled PDS endpoint and any
568+
// redirect it serves — the same DoH resolver artifact acquisition uses.
569+
resolveHostname: cloudflareDohResolver,
557570
};
558571
}
559572

apps/labeler/src/pds-verify.ts

Lines changed: 195 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,28 @@
2020
import type { PublicKey } from "@atcute/crypto";
2121
import { type AtprotoDid, isDid } from "@atcute/lexicons/syntax";
2222
import { verifyRecord } from "@atcute/repo";
23+
import {
24+
cloudflareDohResolver,
25+
type DnsResolver,
26+
resolveAndValidateExternalUrl,
27+
SsrfError,
28+
} from "emdash/security/ssrf";
2329

2430
const 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. */
2733
const 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

2938
export 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

3646
export 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

6276
export 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+
120237
async 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

Comments
 (0)