From 69401b2ce50e7ae88c76c26a6ae3e4dd8eef1ecc Mon Sep 17 00:00:00 2001 From: Bailey Townsend Date: Mon, 10 Aug 2026 21:58:00 -0500 Subject: [PATCH] more clean up of atproto --- excalidraw-app/lib/atproto.ts | 180 ++++++++++++++++----------- excalidraw-app/tests/atproto.test.ts | 125 +++++++------------ 2 files changed, 152 insertions(+), 153 deletions(-) diff --git a/excalidraw-app/lib/atproto.ts b/excalidraw-app/lib/atproto.ts index 4ff0a2ae..24e413ef 100644 --- a/excalidraw-app/lib/atproto.ts +++ b/excalidraw-app/lib/atproto.ts @@ -3,13 +3,18 @@ * the `app.lexidraw.scene` record, and the thumb blob. * * Everything here talks to foreign, user-controlled infrastructure (any PDS on - * the network), so every response is treated as untrusted input: record fields - * are read leniently (legacy records must degrade to defaults, never throw), - * blob reads are capped and timed out, and the handle in a DID document is - * only reported after it has been verified to point back at the DID. + * the network), so every response is treated as untrusted input: the scene + * record is validated against the current `app.lexidraw.scene` lexicon (a + * record that doesn't validate is "no such drawing", never a partial preview), + * and response bodies are capped and timed out. */ -import { getBlobCidString } from "@atproto/lex"; +import { + asStringFormat, + getBlobCidString, + xrpcSafe, + XrpcResponseError, +} from "@atproto/lex"; import { ensureValidDid, ensureValidHandle, @@ -18,10 +23,12 @@ import { } from "@atproto/syntax"; import { AtprotoDohHandleResolver } from "@atproto/oauth-client-browser"; -import { DidResolver } from "@atproto/identity"; +import { DidNotFoundError, DidResolver } from "@atproto/identity"; import { LRUCache } from "lru-cache"; +import { app, com } from "../data/atproto/lexicons"; + const didResolver = new DidResolver({}); const handleResolver = new AtprotoDohHandleResolver({ @@ -29,7 +36,7 @@ const handleResolver = new AtprotoDohHandleResolver({ }); export type SceneInfo = { name: string; - /** Verified handle, "" when unverifiable — callers fall back to the DID. */ + /** The DID document's `alsoKnownAs` handle, reported as-is (unverified). */ handle: string; pdsEndpoint: string; /** "" when the record has no thumb. */ @@ -43,11 +50,10 @@ export class MalformedSceneRefError extends Error { } const USER_AGENT = "lexidraw-og/1.0"; -const SCENE_COLLECTION = "app.lexidraw.scene"; const REQUEST_TIMEOUT_MS = 8_000; /** Lexicon `thumb.maxSize` is 2MB; past that the PDS is serving junk. */ const MAX_THUMB_BYTES = 2 * 1024 * 1024; -/** DID documents and scene records are kilobytes; half a meg is generous. */ +/** Scene records are kilobytes; half a meg is generous. */ const MAX_JSON_BYTES = 512 * 1024; const SCENE_TTL_MS = 5 * 60_000; @@ -127,16 +133,47 @@ const readCapped = async ( return data; }; -const readCappedText = async (res: Response, max: number, label: string) => - new TextDecoder().decode(await readCapped(res, max, label)); - -const readCappedJson = async ( - res: Response, - label: string, -): Promise => { - const text = await readCappedText(res, MAX_JSON_BYTES, label); - return JSON.parse(text) as unknown; -}; +/** + * A fetch that caps the response body size, for the @atproto/lex client: it + * reads bodies to completion (`response.json()`/`arrayBuffer()`), so the byte + * cap that keeps a hostile PDS from streaming an endless getRecord body has to + * live in the fetch layer. The body is replaced with a stream that errors once + * it grows past `max`; the lex client turns that into a failed response. + */ +const cappedResponseFetch = + (max: number, label: string): typeof globalThis.fetch => + async (input, init) => { + const res = await fetch(input, init); + if (!res.body) { + return res; + } + const reader = res.body.getReader(); + let total = 0; + const body = new ReadableStream({ + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + total += value.byteLength; + if (total > max) { + await reader.cancel().catch(() => {}); + controller.error(new Error(`${label}: exceeds ${max} bytes`)); + return; + } + controller.enqueue(value); + }, + async cancel(reason) { + await reader.cancel(reason).catch(() => {}); + }, + }); + return new Response(body, { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }); + }; /** * Hosts a DID document may point us at. A DID is attacker-chosen, and this @@ -188,7 +225,16 @@ const resolveIdentity = async ( did: string, signal?: AbortSignal, ): Promise => { - const result = await didResolver.resolveAtprotoData(did); + let result; + try { + result = await didResolver.resolveAtprotoData(did); + } catch (err) { + // a DID that doesn't resolve anywhere is a dead link, not a server error + if (err instanceof DidNotFoundError) { + return null; + } + throw err; + } if (!result) { return null; } @@ -201,77 +247,59 @@ const resolveIdentity = async ( type ThumbRef = { cid: string; mime: string }; -/** - * Pulls the thumb blob ref out of a record value without validating it. - * Records predating the current lexicon (and legacy `{cid, mimeType}` blob - * refs) must degrade to "no thumb", never fail the whole preview. - */ -const readThumbRef = (value: unknown): ThumbRef => { - const thumb = (value as { thumb?: unknown })?.thumb; - if (!thumb || typeof thumb !== "object") { - return { cid: "", mime: "" }; - } - const { ref, mimeType } = thumb as { ref?: unknown; mimeType?: unknown }; - const mime = typeof mimeType === "string" ? mimeType : ""; - - let cid: unknown = ""; - if (typeof ref === "string") { - cid = ref; - } else if (ref && typeof ref === "object" && "$link" in ref) { - // the JSON wire shape: { $type: "blob", ref: { $link: "bafy..." } } - cid = (ref as { $link?: unknown }).$link; - } else { - // a decoded Cid object, or the legacy { cid, mimeType } form — both - // duck-typed by getBlobCidString, which can still throw on junk - try { - cid = getBlobCidString(thumb as never); - } catch { - cid = ""; - } - } - return { cid: typeof cid === "string" ? cid : "", mime }; -}; - const fetchSceneRecord = async ( pdsEndpoint: string, did: string, rkey: string, signal?: AbortSignal, ): Promise<{ name: string; thumb: ThumbRef } | null> => { - const url = new URL("/xrpc/com.atproto.repo.getRecord", pdsEndpoint); - url.searchParams.set("repo", did); - url.searchParams.set("collection", SCENE_COLLECTION); - url.searchParams.set("rkey", rkey); + const res = await xrpcSafe( + { + service: pdsEndpoint, + headers: { "user-agent": USER_AGENT }, + fetch: cappedResponseFetch(MAX_JSON_BYTES, "getRecord"), + }, + com.atproto.repo.getRecord, + { + params: { + repo: asStringFormat(did, "did"), + collection: app.lexidraw.scene.$nsid, + rkey, + }, + signal: abortAfter(signal, REQUEST_TIMEOUT_MS), + }, + ); - const res = await request(url.toString(), { - signal, - accept: "application/json", - }); - if (!res.ok) { + if (!res.success) { // PDSes answer a missing record with 400 RecordNotFound, deleted repos // with 404/410 — all "no such drawing", not a transient failure - if (res.status === 404 || res.status === 410) { - return null; - } - if (res.status === 400) { - const error = await readCappedJson(res, "getRecord error") - .then((body) => (body as { error?: unknown })?.error) - .catch(() => undefined); - if (error === "RecordNotFound" || error === "InvalidRequest") { + if (res instanceof XrpcResponseError) { + if (res.status === 404 || res.status === 410) { return null; } + if ( + res.status === 400 && + (res.error === "RecordNotFound" || res.error === "InvalidRequest") + ) { + return null; + } + throw new Error(`getRecord ${did}/${rkey}: status ${res.status}`); } - throw new Error(`getRecord ${did}/${rkey}: status ${res.status}`); + throw new Error(`getRecord ${did}/${rkey}: ${res.error}`, { cause: res }); } - const body = (await readCappedJson(res, `getRecord ${did}/${rkey}`)) as { - value?: unknown; - }; - const value = body.value; - const name = (value as { name?: unknown })?.name; + // Only the current lexicon is spoken: a record that doesn't validate + // (legacy shapes, junk) is "no such drawing", never a partial preview. + const parsed = app.lexidraw.scene.$safeParse(res.body.value); + if (!parsed.success) { + return null; + } + const { name, thumb } = parsed.value; return { - name: typeof name === "string" ? name : "", - thumb: readThumbRef(value), + name, + thumb: thumb + ? { cid: getBlobCidString(thumb), mime: thumb.mimeType } + : { cid: "", mime: "" }, }; }; diff --git a/excalidraw-app/tests/atproto.test.ts b/excalidraw-app/tests/atproto.test.ts index 2d0d0b06..1fa262d0 100644 --- a/excalidraw-app/tests/atproto.test.ts +++ b/excalidraw-app/tests/atproto.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; +import { createCid, RAW_DATA_CODEC, SHA256_HASH_CODE } from "@atproto/lex-data"; import { afterEach, beforeEach, test } from "vitest"; import { @@ -15,6 +16,18 @@ const PDS = "https://selfhosted.social"; const RKEY = "3lxsceneabcd2"; const THUMB_CID = "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +/** Deterministic, structurally-valid raw CIDs — the lex client rejects any + * other string in a blob ref or record cid. */ +const rawCid = (seed: number) => + createCid( + RAW_DATA_CODEC, + SHA256_HASH_CODE, + new Uint8Array(32).fill(seed), + ).toString(); +const SCENE_CID = rawCid(1); +const RECORD_CID = rawCid(3); +const LEGACY_SCENE_CID = rawCid(4); + type Call = { url: string; init: RequestInit | undefined }; const realFetch = globalThis.fetch; @@ -49,6 +62,14 @@ const json = (body: unknown, status = 200) => const didDoc = (did: string, handle: string, pds: string) => ({ id: did, alsoKnownAs: [`at://${handle}`], + verificationMethod: [ + { + id: "#atproto", + type: "EcdsaSecp256k1VerificationKey2019", + controller: did, + publicKeyMultibase: "zjesTu2BpszP8DKSoi1R5G6ggjHrsrVnboLdx6V47vkoR", + }, + ], service: [ { id: "#atproto_pds", @@ -58,14 +79,9 @@ const didDoc = (did: string, handle: string, pds: string) => ({ ], }); -const dohAnswer = (did: string) => ({ - Status: 0, - Answer: [{ name: `_atproto.${HANDLE}`, type: 16, data: `"did=${did}"` }], -}); - const sceneRecord = (value: Record) => ({ uri: `at://${DID}/app.lexidraw.scene/${RKEY}`, - cid: "bafyreirecordcid", + cid: RECORD_CID, value, }); @@ -74,7 +90,7 @@ const fullRecord = sceneRecord({ name: "My drawing", scene: { $type: "blob", - ref: { $link: "bafkreiscenebl0b" }, + ref: { $link: SCENE_CID }, mimeType: "application/gzip", size: 4242, }, @@ -88,28 +104,20 @@ const fullRecord = sceneRecord({ updatedAt: "2026-01-02T00:00:00.000Z", }); -/** Default happy-path router: valid identity, verified handle, full record. */ +/** Default happy-path router: valid identity, full record. */ const router = ( overrides: { record?: () => Response; - doh?: () => Response; - wellKnown?: () => Response; } = {}, ) => (url: string): Response => { if (url.startsWith("https://plc.directory/")) { return json(didDoc(DID, HANDLE, PDS)); } - if (url.startsWith("https://cloudflare-dns.com/")) { - return (overrides.doh ?? (() => json(dohAnswer(DID))))(); - } if (url.startsWith(`${PDS}/xrpc/com.atproto.repo.getRecord`)) { return (overrides.record ?? (() => json(fullRecord)))(); } - if (url.startsWith(`https://${HANDLE}/.well-known/atproto-did`)) { - return (overrides.wellKnown ?? (() => new Response(DID)))(); - } return new Response("unexpected", { status: 500 }); }; @@ -138,11 +146,10 @@ test("lookupScene resolves a did:plc scene", async () => { assert.ok(record, "the record was read from the PDS in the DID document"); assert.ok(record.url.includes("collection=app.lexidraw.scene")); assert.ok(record.url.includes(`rkey=${RKEY}`)); - // every outbound request identifies itself - for (const call of calls) { - const headers = new Headers(call.init?.headers); - assert.equal(headers.get("user-agent"), "lexidraw-og/1.0"); - } + // the PDS read this process makes identifies itself (the DID-document fetch + // from @atproto/identity carries its own headers) + const headers = new Headers(record.init?.headers); + assert.equal(headers.get("user-agent"), "lexidraw-og/1.0"); }); test("lookupScene resolves a did:web with a percent-encoded port", async () => { @@ -152,9 +159,6 @@ test("lookupScene resolves a did:web with a percent-encoded port", async () => { if (url.startsWith("https://example.com:8080/")) { return json(didDoc(webDid, HANDLE, webPds)); } - if (url.startsWith("https://cloudflare-dns.com/")) { - return json(dohAnswer(webDid)); - } if (url.startsWith(`${webPds}/xrpc/com.atproto.repo.getRecord`)) { return json(fullRecord); } @@ -192,9 +196,9 @@ test("lookupScene returns null for an unknown identity", async () => { assert.equal(await lookupScene(DID, RKEY), null); }); -test("lookupScene reads a legacy record leniently", async () => { - // pre-thumb record: no thumb, a scene blob with the old json mimeType, and - // fields this version has never heard of +test("lookupScene treats a record that predates the current lexicon as a miss", async () => { + // a record missing the current lexicon's required fields (e.g. no updatedAt) + // is "no such drawing", never a partial preview stubFetch( router({ record: () => @@ -204,7 +208,7 @@ test("lookupScene reads a legacy record leniently", async () => { name: "Legacy drawing", scene: { $type: "blob", - ref: { $link: "bafkreilegacyblob" }, + ref: { $link: LEGACY_SCENE_CID }, mimeType: "application/json", size: 99, }, @@ -215,25 +219,18 @@ test("lookupScene reads a legacy record leniently", async () => { }), ); - const info = await lookupScene(DID, RKEY); - - assert.equal(info?.name, "Legacy drawing"); - assert.equal(info?.thumbCid, ""); - assert.equal(info?.thumbMime, ""); + assert.equal(await lookupScene(DID, RKEY), null); }); -test("lookupScene tolerates a record with no name at all", async () => { +test("lookupScene treats a record that doesn't validate as a miss", async () => { stubFetch( router({ record: () => json(sceneRecord({ thumb: null, name: 42 })) }), ); - const info = await lookupScene(DID, RKEY); - - assert.equal(info?.name, "", "callers substitute their own placeholder"); - assert.equal(info?.thumbCid, ""); + assert.equal(await lookupScene(DID, RKEY), null); }); -test("lookupScene reads a legacy blob ref shape", async () => { +test("lookupScene rejects a record with a legacy blob ref shape", async () => { stubFetch( router({ record: () => @@ -246,51 +243,25 @@ test("lookupScene reads a legacy blob ref shape", async () => { }), ); - const info = await lookupScene(DID, RKEY); - - assert.equal(info?.thumbCid, THUMB_CID); - assert.equal(info?.thumbMime, "image/webp"); -}); - -test("lookupScene drops a handle that doesn't point back at the DID", async () => { - const impostor = "did:plc:someoneelse000000000000"; - stubFetch( - router({ - doh: () => json(dohAnswer(impostor)), - wellKnown: () => new Response(impostor), - }), - ); - - const info = await lookupScene(DID, RKEY); - - assert.equal( - info?.handle, - "", - "an unverified handle would let any account claim any name", - ); - assert.equal(info?.name, "My drawing"); + assert.equal(await lookupScene(DID, RKEY), null); }); -test("lookupScene falls back to .well-known when DNS says nothing", async () => { - stubFetch(router({ doh: () => json({ Status: 3 }) })); +test("lookupScene reports the DID document's handle without verification", async () => { + // no handle verification is performed: the handle is taken from the DID + // document's alsoKnownAs as-is, so no DNS/well-known round-trips are made + const calls = stubFetch(router()); const info = await lookupScene(DID, RKEY); assert.equal(info?.handle, HANDLE); -}); - -test("lookupScene keeps working when handle verification is unreachable", async () => { - stubFetch( - router({ - doh: () => new Response("nope", { status: 500 }), - wellKnown: () => new Response("nope", { status: 500 }), - }), + assert.ok( + !calls.some((c) => c.url.includes("cloudflare-dns.com")), + "no handle verification round-trips", + ); + assert.ok( + !calls.some((c) => c.url.includes("/.well-known/atproto-did")), + "no handle verification round-trips", ); - - const info = await lookupScene(DID, RKEY); - - assert.equal(info?.handle, "", "the route falls back to showing the DID"); - assert.equal(info?.name, "My drawing"); }); test("lookupScene caches misses so a crawler can't hammer a dead link", async () => { -- 2.51.2