Skip to content

Commit 1f6925f

Browse files
authored
fix(aggregator): wrap unexpected XRPC errors + harden PDS egress against SSRF (review round 1) (#2117)
* fix(aggregator): wrap unexpected XRPC errors and harden PDS egress against SSRF Finding 17 (Sol): a non-XRPC failure resolving the labeler policy (e.g. a D1 error) escaped `handleXrpc` unwrapped, hitting workerd's bare 500 without the CORS or `private, no-store` headers every aggregator response carries. Convert unexpected errors to a generic 500 through the same wrapper, logging the internal detail but never leaking it to the client. XRPCError handling is unchanged. SSRF follow-up: `pds-verify.ts` fetched the publisher-controlled PDS endpoint (from a DID document) following redirects with no scheme/DNS/reserved-address validation and no body-read deadline. Route it through the shared hardened egress (`resolveAndValidateExternalUrl` + injected `cloudflareDohResolver`): HTTPS-only, per-hop re-resolution under `redirect: "manual"` with a bounded redirect count, a wall-clock deadline spanning redirects and the body read, and fail-closed when no resolver is injected. Blocks are reported as a new permanent `PDS_ADDRESS_BLOCKED` reason; existing 404/5xx/too-large/invalid-proof and the transient network classification are preserved. * fix(aggregator): retry PDS verification on resolver-infra failures, don't dead-letter `assertFetchableUrl` collapsed every `SsrfError` from `resolveAndValidateExternalUrl` into the permanent `PDS_ADDRESS_BLOCKED`, but that helper also throws `SsrfError` when the injected DNS resolver itself fails (DoH network error / SERVFAIL / timeout) — transient infrastructure, not a disallowed publisher address. A DoH blip therefore permanently dead-lettered legitimate records and risked mass dead-lettering during a resolver outage. `SsrfError.code` is the same constant for both cases, so key on the message prefix the helper uses only for the resolver-threw path (`Could not resolve hostname:`): map that to the transient `PDS_NETWORK_ERROR` (retried), and keep genuine address blocks — private/reserved IP, blocked host, scheme — as the permanent `PDS_ADDRESS_BLOCKED`. A test asserts the transient mapping so a future wording change in the shared core helper breaks loudly rather than silently mis-classifying. Also adds a redirect-hop-limit boundary test. * fix(aggregator): retry PDS verification on empty DNS answer, don't dead-letter An empty resolver answer (NXDOMAIN / NOERROR-NODATA / CNAME-only) makes `resolveAndValidateExternalUrl` throw `SsrfError("Hostname resolved to no addresses")`, which the classifier mapped to the permanent PDS_ADDRESS_BLOCKED — so a host mid-DNS-propagation lost the record to a dead-letter. Treat the empty-answer message as a transient resolution failure (PDS_NETWORK_ERROR, retried) alongside the existing resolver-threw case; a genuinely-gone host still dead-letters via retry exhaustion. Genuine private/reserved/blocked-host addresses stay permanent. Keyed on the shared helper's message as a module constant with a test, matching the resolver-failure-prefix approach. * fix(aggregator): delay re-delivery on transient DNS-resolution failure The records queue re-delivers immediately (no `retry_delay`), so classifying an empty DNS answer / resolver outage as transient only helped if the retry waited for propagation — otherwise all `max_retries` (5) burn in seconds and the record DLQs as UNEXPECTED_ERROR before DNS settles. `PdsVerificationError` now carries an optional `retryAfterSeconds`, set (to 60s) only on the transient DNS-resolution path; the consumer passes it as `retry({ delaySeconds })`. Other transient retries (network, 5xx, timeout) stay immediate. Delay strategy: fixed 60s. With max_retries: 5 that spreads retries across a ~5-minute propagation window. Chosen for reconciliation with labeler #2113.
1 parent 08237b8 commit 1f6925f

8 files changed

Lines changed: 553 additions & 54 deletions

File tree

apps/aggregator/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
"@atcute/xrpc-server": "catalog:",
3333
"@atcute/xrpc-server-cloudflare": "catalog:",
3434
"@emdash-cms/registry-lexicons": "workspace:*",
35-
"@emdash-cms/registry-moderation": "workspace:*"
35+
"@emdash-cms/registry-moderation": "workspace:*",
36+
"emdash": "workspace:*"
3637
},
3738
"devDependencies": {
3839
"@cloudflare/vite-plugin": "catalog:",

apps/aggregator/src/pds-verify.ts

Lines changed: 221 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,40 @@
2020
import type { PublicKey } from "@atcute/crypto";
2121
import { type AtprotoDid, isDid } from "@atcute/lexicons/syntax";
2222
import { verifyRecord } from "@atcute/repo";
23+
import { type DnsResolver, resolveAndValidateExternalUrl, SsrfError } from "emdash/security/ssrf";
2324

2425
const DEFAULT_TIMEOUT_MS = 15_000;
2526
/** 5 MB ceiling. Records and their proofs are tiny (sub-KB typical); this is
2627
* a defence against a hostile or broken PDS streaming an unbounded body. */
2728
const DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
29+
/** Redirect hops we'll follow before giving up. The PDS endpoint is
30+
* publisher-controlled, so redirects are an SSRF pivot: every hop is
31+
* re-validated, and this caps the chain. */
32+
const MAX_PDS_REDIRECTS = 3;
33+
/** Message prefix `resolveAndValidateExternalUrl` uses when the injected DNS
34+
* resolver itself throws (DoH network error / SERVFAIL / timeout) — a transient
35+
* infrastructure failure, distinct from a disallowed address. Keyed on here to
36+
* classify those as retryable; see `assertFetchableUrl`. */
37+
const RESOLVER_FAILURE_PREFIX = "Could not resolve hostname:";
38+
/** Message `resolveAndValidateExternalUrl` uses for an empty DNS answer
39+
* (NXDOMAIN / NOERROR-NODATA / CNAME-only). Transient: a host mid-propagation
40+
* would otherwise be permanently dead-lettered; a genuinely-gone host instead
41+
* dead-letters via retry exhaustion. Not a disallowed address. */
42+
const EMPTY_ANSWER_MESSAGE = "Hostname resolved to no addresses";
43+
/** Delay, in seconds, before the consumer re-delivers a message that failed on a
44+
* transient DNS resolution (empty answer / resolver outage). The records queue
45+
* re-delivers immediately by default, so without this a host mid-propagation
46+
* burns all `max_retries` in seconds and lands in the DLQ before DNS settles.
47+
* A fixed pause spreads the retries across a propagation window. */
48+
const RESOLUTION_RETRY_DELAY_SECONDS = 60;
2849

2950
export type VerificationFailureReason =
3051
| "PDS_NETWORK_ERROR"
3152
| "PDS_HTTP_ERROR"
3253
| "RECORD_NOT_FOUND"
3354
| "RESPONSE_TOO_LARGE"
34-
| "INVALID_PROOF";
55+
| "INVALID_PROOF"
56+
| "PDS_ADDRESS_BLOCKED";
3557

3658
export class PdsVerificationError extends Error {
3759
override readonly name = "PdsVerificationError";
@@ -40,6 +62,10 @@ export class PdsVerificationError extends Error {
4062
message: string,
4163
readonly status?: number,
4264
override readonly cause?: unknown,
65+
/** When set, the consumer re-delivers the message after this many seconds
66+
* instead of immediately. Set only for transient DNS-resolution failures
67+
* that need time to propagate; other transient retries stay immediate. */
68+
readonly retryAfterSeconds?: number,
4369
) {
4470
super(message);
4571
}
@@ -51,12 +77,20 @@ export interface FetchAndVerifyOptions {
5177
collection: string;
5278
rkey: string;
5379
publicKey: PublicKey;
54-
/** Default 15s. Aborts the fetch if the PDS is slow. */
80+
/** Default 15s wall-clock budget covering every redirect hop AND the body
81+
* read — a slow-drip body is bounded by this, not just the initial fetch. */
5582
timeoutMs?: number;
5683
/** Default 5 MB. Rejects with `RESPONSE_TOO_LARGE` if exceeded. */
5784
maxResponseBytes?: number;
5885
/** Inject for tests; defaults to `globalThis.fetch`. */
5986
fetch?: typeof fetch;
87+
/**
88+
* SSRF-safe hostname resolver applied to the PDS endpoint and every redirect
89+
* target before we connect. Production injects `cloudflareDohResolver`; tests
90+
* stub it. Absent means fail closed — the publisher-controlled fetch is
91+
* refused rather than issued unprotected.
92+
*/
93+
resolveHostname?: DnsResolver;
6094
}
6195

6296
export interface VerifiedPdsRecord {
@@ -74,6 +108,16 @@ export async function fetchAndVerifyRecord(
74108
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
75109
const maxResponseBytes = opts.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
76110

111+
// Fail closed: without a resolver we can't validate the publisher-controlled
112+
// endpoint against the SSRF blocklist, so we refuse rather than fetch blind.
113+
const resolveHostname = opts.resolveHostname;
114+
if (!resolveHostname) {
115+
throw new PdsVerificationError(
116+
"PDS_ADDRESS_BLOCKED",
117+
"refusing PDS fetch: no SSRF-safe hostname resolver was provided",
118+
);
119+
}
120+
77121
if (!isAtprotoDid(opts.did)) {
78122
// Caller is expected to have validated this upstream (the resolver
79123
// rejects non-DID strings before reaching here), but the verifier's
@@ -84,7 +128,7 @@ export async function fetchAndVerifyRecord(
84128
);
85129
}
86130
const url = buildGetRecordUrl(opts.pds, opts.did, opts.collection, opts.rkey);
87-
const carBytes = await fetchCar(fetchImpl, url, timeoutMs, maxResponseBytes);
131+
const carBytes = await fetchCar(fetchImpl, url, timeoutMs, maxResponseBytes, resolveHostname);
88132

89133
try {
90134
const result = await verifyRecord({
@@ -117,54 +161,179 @@ function buildGetRecordUrl(pds: string, did: string, collection: string, rkey: s
117161
return url.toString();
118162
}
119163

164+
function isRedirectStatus(status: number): boolean {
165+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
166+
}
167+
168+
/**
169+
* True when an `SsrfError` message denotes a transient resolution failure (the
170+
* resolver threw, or the DNS answer was empty) rather than a disallowed address.
171+
* Keyed on the shared helper's messages; guarded by tests so a wording change
172+
* there fails loudly instead of silently re-classifying.
173+
*/
174+
function isTransientResolutionFailure(message: string): boolean {
175+
return message.startsWith(RESOLVER_FAILURE_PREFIX) || message === EMPTY_ANSWER_MESSAGE;
176+
}
177+
178+
/**
179+
* Reject the URL unless it's HTTPS and resolves to a public address. The PDS
180+
* endpoint (and every redirect target) is publisher-controlled, so this is the
181+
* SSRF boundary: HTTP is refused (no plaintext downgrade to an internal
182+
* listener the resolver can't see), and `resolveAndValidateExternalUrl` resolves
183+
* the hostname and rejects any private/reserved address. IP/address logic lives
184+
* in the shared helper — never duplicated here.
185+
*/
186+
async function assertFetchableUrl(url: string, resolver: DnsResolver): Promise<void> {
187+
let parsed: URL;
188+
try {
189+
parsed = new URL(url);
190+
} catch {
191+
throw new PdsVerificationError("PDS_ADDRESS_BLOCKED", `invalid PDS URL: ${url}`);
192+
}
193+
if (parsed.protocol !== "https:") {
194+
throw new PdsVerificationError("PDS_ADDRESS_BLOCKED", `PDS URL must be https: ${url}`);
195+
}
196+
try {
197+
await resolveAndValidateExternalUrl(url, { resolver });
198+
} catch (err) {
199+
if (err instanceof SsrfError) {
200+
// `resolveAndValidateExternalUrl` throws `SsrfError` for two very
201+
// different classes of situation: a disallowed address (permanent —
202+
// dead-letter) and a resolution failure (transient — retry). The
203+
// transient class is a DoH network error / SERVFAIL / timeout (the
204+
// resolver threw, prefixed `RESOLVER_FAILURE_PREFIX`) or an empty DNS
205+
// answer (`EMPTY_ANSWER_MESSAGE` — a host mid-propagation). `SsrfError.code`
206+
// is the same constant for all, so the only discriminator is the message.
207+
// Tests assert both transient mappings so a future wording change in the
208+
// shared helper breaks loudly here rather than silently dead-lettering
209+
// legitimate records during a DoH blip or DNS propagation.
210+
if (isTransientResolutionFailure(err.message)) {
211+
throw new PdsVerificationError(
212+
"PDS_NETWORK_ERROR",
213+
`PDS host resolution failed: ${err.message}`,
214+
undefined,
215+
err,
216+
RESOLUTION_RETRY_DELAY_SECONDS,
217+
);
218+
}
219+
throw new PdsVerificationError(
220+
"PDS_ADDRESS_BLOCKED",
221+
`PDS address rejected: ${err.message}`,
222+
undefined,
223+
err,
224+
);
225+
}
226+
throw err;
227+
}
228+
}
229+
120230
async function fetchCar(
121231
fetchImpl: typeof fetch,
122-
url: string,
232+
initialUrl: string,
123233
timeoutMs: number,
124234
maxBytes: number,
235+
resolver: DnsResolver,
125236
): Promise<Uint8Array> {
237+
// One wall-clock budget spanning every redirect hop and the body read, so a
238+
// PDS that dribbles headers-then-body can't hold the request open past it.
126239
const controller = new AbortController();
127240
const timer = setTimeout(() => controller.abort(), timeoutMs);
128-
let response: Response;
129241
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,
145-
);
242+
let currentUrl = initialUrl;
243+
for (let hop = 0; ; hop++) {
244+
// Re-resolve and re-validate every hop: an allowed public host can
245+
// 30x to a private one, so validating only the initial URL is a hole.
246+
await assertFetchableUrl(currentUrl, resolver);
247+
248+
let response: Response;
249+
try {
250+
response = await fetchImpl(currentUrl, {
251+
signal: controller.signal,
252+
// Intercept redirects so each target is re-validated before we
253+
// follow it, rather than letting fetch chase them unchecked.
254+
redirect: "manual",
255+
headers: { accept: "application/vnd.ipld.car" },
256+
});
257+
} catch (err) {
258+
// Whether the fetch threw because we aborted (timeout) or because
259+
// the network failed at the OS layer, the right caller behaviour
260+
// is the same: retry. Lump them under PDS_NETWORK_ERROR.
261+
throw new PdsVerificationError(
262+
"PDS_NETWORK_ERROR",
263+
err instanceof Error && err.name === "AbortError"
264+
? `PDS fetch aborted after ${timeoutMs}ms`
265+
: `PDS fetch failed: ${err instanceof Error ? err.message : String(err)}`,
266+
undefined,
267+
err,
268+
);
269+
}
270+
271+
if (isRedirectStatus(response.status)) {
272+
// Free the redirect response's socket before following.
273+
await response.body?.cancel().catch(() => {});
274+
if (hop >= MAX_PDS_REDIRECTS) {
275+
throw new PdsVerificationError(
276+
"PDS_ADDRESS_BLOCKED",
277+
`PDS exceeded ${MAX_PDS_REDIRECTS} redirects`,
278+
);
279+
}
280+
const location = response.headers.get("location");
281+
if (!location) {
282+
throw new PdsVerificationError(
283+
"PDS_HTTP_ERROR",
284+
`PDS redirect without a Location header`,
285+
response.status,
286+
);
287+
}
288+
// The publisher fully controls this header; a malformed value
289+
// (e.g. `http://` with an empty authority) makes `new URL` throw a
290+
// raw TypeError. Classify it as a blocked redirect rather than
291+
// letting it escape as an UNEXPECTED_ERROR the publisher can force.
292+
try {
293+
currentUrl = new URL(location, currentUrl).toString();
294+
} catch {
295+
throw new PdsVerificationError(
296+
"PDS_ADDRESS_BLOCKED",
297+
`PDS redirect Location is not a valid URL: ${location}`,
298+
);
299+
}
300+
continue;
301+
}
302+
303+
if (response.status === 404) {
304+
// Distinct from a generic 4xx. The publisher may have deleted the
305+
// record between Jetstream emitting and us fetching, which is the
306+
// common cause; other 4xx (auth, bad request) suggest programming
307+
// errors. Both end up dead-lettered by the consumer (the audit
308+
// trail is useful even for legitimate races so operators can spot
309+
// systematic Jetstream-vs-PDS skew); the distinct reason code keeps
310+
// them queryable separately.
311+
throw new PdsVerificationError(
312+
"RECORD_NOT_FOUND",
313+
`PDS returned 404 for ${currentUrl}`,
314+
404,
315+
);
316+
}
317+
if (!response.ok) {
318+
throw new PdsVerificationError(
319+
"PDS_HTTP_ERROR",
320+
`PDS returned ${response.status} for ${currentUrl}`,
321+
response.status,
322+
);
323+
}
324+
325+
return await readCarBody(response, maxBytes, timeoutMs);
326+
}
146327
} finally {
147328
clearTimeout(timer);
148329
}
330+
}
149331

150-
if (response.status === 404) {
151-
// Distinct from a generic 4xx. The publisher may have deleted the
152-
// record between Jetstream emitting and us fetching, which is the
153-
// common cause; other 4xx (auth, bad request) suggest programming
154-
// errors. Both end up dead-lettered by the consumer (the audit trail
155-
// is useful even for legitimate races so operators can spot
156-
// systematic Jetstream-vs-PDS skew); the distinct reason code keeps
157-
// them queryable separately.
158-
throw new PdsVerificationError("RECORD_NOT_FOUND", `PDS returned 404 for ${url}`, 404);
159-
}
160-
if (!response.ok) {
161-
throw new PdsVerificationError(
162-
"PDS_HTTP_ERROR",
163-
`PDS returned ${response.status} for ${url}`,
164-
response.status,
165-
);
166-
}
167-
332+
async function readCarBody(
333+
response: Response,
334+
maxBytes: number,
335+
timeoutMs: number,
336+
): Promise<Uint8Array> {
168337
// Buffer the body up to the size limit. Don't trust Content-Length; a
169338
// hostile or buggy PDS could under-report and stream more bytes than
170339
// advertised.
@@ -175,8 +344,7 @@ async function fetchCar(
175344
const chunks: Uint8Array[] = [];
176345
let total = 0;
177346
try {
178-
// biome-ignore lint/correctness/noConstantCondition: drains the stream
179-
while (true) {
347+
for (;;) {
180348
const { done, value } = await reader.read();
181349
if (done) break;
182350
total += value.byteLength;
@@ -193,7 +361,18 @@ async function fetchCar(
193361
await reader.cancel().catch(() => {
194362
/* swallow — we already have a primary error to surface */
195363
});
196-
throw err;
364+
// Our own errors (too-large) carry their classification; pass them
365+
// through. Anything else — a dropped socket, or the wall-clock deadline
366+
// aborting a slow-drip body mid-download — is transient: retry.
367+
if (err instanceof PdsVerificationError) throw err;
368+
throw new PdsVerificationError(
369+
"PDS_NETWORK_ERROR",
370+
err instanceof Error && err.name === "AbortError"
371+
? `PDS body read aborted after ${timeoutMs}ms`
372+
: `PDS stream failed mid-download: ${err instanceof Error ? err.message : String(err)}`,
373+
undefined,
374+
err,
375+
);
197376
} finally {
198377
reader.releaseLock();
199378
}

0 commit comments

Comments
 (0)