diff --git a/src/lib/pds-fetch.ts b/src/lib/pds-fetch.ts index dc707d5..274403e 100644 --- a/src/lib/pds-fetch.ts +++ b/src/lib/pds-fetch.ts @@ -1,4 +1,7 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; import * as CID from "@atcute/cid"; +import { isAuthEnabled } from "../atproto/env.ts"; import { AppError } from "./errors.ts"; import { resolvePdsEndpoint } from "./identity.ts"; import { LIMITS } from "./limits.ts"; @@ -8,6 +11,7 @@ type BlobFetchReason = | "pds-resolve-failed" | "pds-not-found" | "non-https-pds" + | "blocked-host" | "fetch-failed" | "timeout" | "upstream-error" @@ -27,6 +31,76 @@ interface VerifiedBlob { mimeType: string; } +/** + * Private, loopback, link-local, ULA, and CGNAT ranges — the addresses an SSRF + * payload would aim at to reach internal services or the cloud metadata endpoint + * (169.254.169.254). + */ +export function isPrivateIp(ip: string): boolean { + if (isIP(ip) === 4) { + const parts = ip.split("."); + const a = Number(parts[0]); + const b = Number(parts[1]); + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 169 && b === 254) || // link-local incl. cloud metadata + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 100 && b >= 64 && b <= 127) // CGNAT + ); + } + const v6 = ip.toLowerCase(); + if (v6 === "::1" || v6 === "::") return true; + if (v6.startsWith("fe80") || v6.startsWith("fc") || v6.startsWith("fd")) { + return true; // link-local + unique-local + } + if (v6.startsWith("::ffff:")) return isPrivateIp(v6.slice(7)); // mapped IPv4 + return false; +} + +/** + * SSRF guard for the resolved PDS host. A DID document can point its + * serviceEndpoint at any host (e.g. via did:web), so before connecting we reject + * hosts that resolve to private/reserved addresses. `allowLocal` (dev only) + * turns the guard off so a local PDS works. Throws a generic BlobFetchError so + * the caller can't be used as a probe of internal network reachability. + */ +export async function assertSafePdsHost( + hostname: string, + allowLocal: boolean, +): Promise { + if (allowLocal) return; + if (isIP(hostname)) { + if (isPrivateIp(hostname)) { + throw new BlobFetchError("blocked-host", 502, "Blob not found"); + } + return; + } + const addrs = await lookup(hostname, { all: true }); + for (const { address } of addrs) { + if (isPrivateIp(address)) { + throw new BlobFetchError("blocked-host", 502, "Blob not found"); + } + } +} + +/** + * Fetch a blob from its owning PDS and verify it. + * + * The PDS is resolved from the DID, then `com.atproto.sync.getBlob` is called + * with these guarantees: + * + * - SSRF guard: only HTTPS endpoints, except localhost for dev + * - Timeout: aborts after `LIMITS.blobProxy.timeoutMs` + * - Size cap: rejects on Content-Length, then enforces during streaming + * (the streaming check is the actual security boundary — a malicious PDS + * can lie about Content-Length) + * - CID verification: hashes the bytes and rejects if they don't match the + * requested CID (a malicious PDS could otherwise serve arbitrary content + * for any CID) + */ export async function fetchVerifiedBlob( did: string, cid: string, @@ -56,17 +130,19 @@ export async function fetchVerifiedBlob( ); } + // localhost over http is a dev-only convenience; in prod every PDS is HTTPS. + const allowLocal = !isAuthEnabled(); const pdsUrl = new URL(pdsEndpoint); - const isLocal = + const isLocalhost = pdsUrl.hostname === "localhost" || pdsUrl.hostname === "127.0.0.1"; - // SSRF guard: HTTPS only (localhost allowed for dev). - if (pdsUrl.protocol !== "https:" && !isLocal) { + if (pdsUrl.protocol !== "https:" && !(allowLocal && isLocalhost)) { throw new BlobFetchError( "non-https-pds", 502, "PDS endpoint must be HTTPS", ); } + await assertSafePdsHost(pdsUrl.hostname, allowLocal); const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; @@ -76,23 +152,20 @@ export async function fetchVerifiedBlob( signal: AbortSignal.timeout(LIMITS.blobProxy.timeoutMs), }); } catch (err) { + // Normalize timeout vs connection-refused vs other to one generic 502 so + // the proxy can't be probed for internal port/host reachability. (Timing + // still leaks somewhat; the per-IP rate limit caps that.) if (err instanceof Error && err.name === "TimeoutError") { - throw new BlobFetchError("timeout", 504, "PDS request timed out"); + throw new BlobFetchError("timeout", 502, "Blob not found"); } - throw new BlobFetchError( - "fetch-failed", - 502, - "Failed to fetch blob from PDS", - ); + throw new BlobFetchError("fetch-failed", 502, "Blob not found"); } if (!upstream.ok) { + // Was `upstream.status`: passing the upstream status through leaked it as + // an oracle. Collapse every non-2xx to the same generic 502. await upstream.body?.cancel().catch(() => {}); - throw new BlobFetchError( - "upstream-error", - upstream.status, - "Blob not found", - ); + throw new BlobFetchError("upstream-error", 502, "Blob not found"); } const announced = upstream.headers.get("content-length"); @@ -105,7 +178,7 @@ export async function fetchVerifiedBlob( upstream.headers.get("content-type") ?? "application/octet-stream"; if (upstream.body === null) { - throw new BlobFetchError("upstream-error", 502, "PDS returned empty body"); + throw new BlobFetchError("upstream-error", 502, "Blob not found"); } const reader = upstream.body.getReader(); @@ -125,7 +198,7 @@ export async function fetchVerifiedBlob( } } catch (err) { if (err instanceof BlobFetchError) throw err; - throw new BlobFetchError("fetch-failed", 502, "Failed to read blob body"); + throw new BlobFetchError("fetch-failed", 502, "Blob not found"); } const data = new Uint8Array(total); diff --git a/tests/lib/pds-fetch.test.ts b/tests/lib/pds-fetch.test.ts index a236376..4fbb048 100644 --- a/tests/lib/pds-fetch.test.ts +++ b/tests/lib/pds-fetch.test.ts @@ -1,7 +1,12 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; import * as CID from "@atcute/cid"; import { LIMITS } from "../../src/lib/limits.ts"; -import { BlobFetchError, fetchVerifiedBlob } from "../../src/lib/pds-fetch.ts"; +import { + assertSafePdsHost, + BlobFetchError, + fetchVerifiedBlob, + isPrivateIp, +} from "../../src/lib/pds-fetch.ts"; // resolveEndpoint and fetchFn are passed as DI, not module-mocked, to keep // this file independent of how identity.ts is mocked elsewhere. @@ -311,7 +316,7 @@ describe("fetchVerifiedBlob — DID resolution", () => { }); describe("fetchVerifiedBlob — upstream errors", () => { - test("propagates upstream non-2xx status", async () => { + test("normalizes any upstream non-2xx to a generic 502 (no status oracle)", async () => { const data = bytes([1]); const cid = await cidFor(data); const fetchFn = asFetch( @@ -326,7 +331,7 @@ describe("fetchVerifiedBlob — upstream errors", () => { ); await expect(promise).rejects.toMatchObject({ reason: "upstream-error", - statusCode: 404, + statusCode: 502, }); }); @@ -349,7 +354,7 @@ describe("fetchVerifiedBlob — upstream errors", () => { }); }); - test("translates AbortSignal.timeout to a timeout error", async () => { + test("translates AbortSignal.timeout to a generic 502 (no timing oracle via status)", async () => { const data = bytes([1]); const cid = await cidFor(data); const fetchFn = asFetch(async () => { @@ -366,7 +371,98 @@ describe("fetchVerifiedBlob — upstream errors", () => { ); await expect(promise).rejects.toMatchObject({ reason: "timeout", - statusCode: 504, + statusCode: 502, }); }); }); + +describe("isPrivateIp", () => { + test("true for private, loopback, link-local, ULA, CGNAT, mapped", () => { + for (const ip of [ + "0.0.0.0", + "10.0.0.1", + "127.0.0.1", + "169.254.169.254", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "100.64.0.1", + "::1", + "fd00::1", + "fe80::1", + "::ffff:127.0.0.1", + ]) { + expect(isPrivateIp(ip)).toBe(true); + } + }); + + test("false for public addresses (including near-miss ranges)", () => { + for (const ip of [ + "1.1.1.1", + "8.8.8.8", + "172.15.0.1", + "172.32.0.1", + "192.169.0.1", + "99.1.1.1", + "2606:4700:4700::1111", + ]) { + expect(isPrivateIp(ip)).toBe(false); + } + }); +}); + +describe("assertSafePdsHost", () => { + test("rejects literal private/reserved IPs when local is disallowed", async () => { + for (const ip of ["169.254.169.254", "10.0.0.1", "127.0.0.1", "::1"]) { + await expect(assertSafePdsHost(ip, false)).rejects.toMatchObject({ + reason: "blocked-host", + statusCode: 502, + }); + } + }); + + test("allows literal public IPs", async () => { + await expect(assertSafePdsHost("1.1.1.1", false)).resolves.toBeUndefined(); + await expect( + assertSafePdsHost("2606:4700:4700::1111", false), + ).resolves.toBeUndefined(); + }); + + test("allowLocal bypasses the guard entirely (dev)", async () => { + await expect( + assertSafePdsHost("169.254.169.254", true), + ).resolves.toBeUndefined(); + }); +}); + +describe("fetchVerifiedBlob — SSRF host guard (prod)", () => { + test("blocks a PDS resolving to a private IP and never fetches", async () => { + // Force auth-on so the localhost/private allowance is disabled. + const savedUrl = process.env["PUBLIC_URL"]; + const savedKey = process.env["OAUTH_PRIVATE_KEY_PATH"]; + process.env["PUBLIC_URL"] = "https://example.com"; + process.env["OAUTH_PRIVATE_KEY_PATH"] = "/tmp/key.json"; + const fetchFn = asFetch(async () => new Response("ok")); + try { + const data = bytes([1]); + const cid = await cidFor(data); + const promise = fetchVerifiedBlob( + TEST_DID, + cid, + fetchFn, + async () => "https://169.254.169.254", + ); + await expect(promise).rejects.toMatchObject({ + reason: "blocked-host", + statusCode: 502, + }); + await promise.catch(() => {}); + expect(fetchFn).not.toHaveBeenCalled(); + } finally { + if (savedUrl === undefined) delete process.env["PUBLIC_URL"]; + else process.env["PUBLIC_URL"] = savedUrl; + if (savedKey === undefined) delete process.env["OAUTH_PRIVATE_KEY_PATH"]; + else process.env["OAUTH_PRIVATE_KEY_PATH"] = savedKey; + } + }); +});