diff --git a/graphql/actor.more.test.ts b/graphql/actor.more.test.ts index 9e1d39635..1189a2e5c 100644 --- a/graphql/actor.more.test.ts +++ b/graphql/actor.more.test.ts @@ -8,6 +8,7 @@ import { actorTable, pinTable } from "@hackerspub/models/schema"; import { schema } from "./mod.ts"; import { createFedCtx, + type FedCtxLookupObject, insertAccountWithActor, insertNotePost, insertRemoteActor, @@ -245,17 +246,50 @@ test("actorByUrl prefers an IRI match over a colliding url match", async () => { test("actorByUrl returns null for an unknown URL without federation lookup", async () => { await withRollback(async (tx) => { + const lookupCalls: string[] = []; + const recordingLookup: FedCtxLookupObject = (uri) => { + lookupCalls.push(uri.toString()); + return Promise.resolve(null); + }; + const fedCtx = createFedCtx(tx, { lookupObject: recordingLookup }); const result = await execute({ schema, document: actorByUrlQuery, variableValues: { url: "https://example.invalid/users/nobody" }, - contextValue: makeGuestContext(tx), + contextValue: makeGuestContext(tx, { fedCtx }), onError: "NO_PROPAGATE", }); assert.equal(result.errors, undefined); assert.deepEqual(toPlainJson(result.data), { actorByUrl: null, }); + assert.deepEqual(lookupCalls, []); + }); +}); + +test("actorByHandle returns null for an unknown remote handle without federation lookup", async () => { + await withRollback(async (tx) => { + const lookupCalls: string[] = []; + const recordingLookup: FedCtxLookupObject = (uri) => { + lookupCalls.push(uri.toString()); + return Promise.resolve(null); + }; + const fedCtx = createFedCtx(tx, { lookupObject: recordingLookup }); + const result = await execute({ + schema, + document: actorByHandleQuery, + variableValues: { + handle: "@nobody@unknown.example", + allowLocalHandle: false, + }, + contextValue: makeGuestContext(tx, { fedCtx }), + onError: "NO_PROPAGATE", + }); + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + actorByHandle: null, + }); + assert.deepEqual(lookupCalls, []); }); }); diff --git a/graphql/actor.ts b/graphql/actor.ts index 2cee782bc..3bea27c59 100644 --- a/graphql/actor.ts +++ b/graphql/actor.ts @@ -645,9 +645,13 @@ builder.queryFields((t) => ({ ); } if (actor) return actor; - const documentLoader = ctx.account == null - ? ctx.fedCtx.documentLoader - : await ctx.fedCtx.getDocumentLoader({ identifier: ctx.account.id }); + // Guests must not trigger federation lookups: they would let + // unauthenticated callers spawn outbound WebFinger / actor fetches + // and persist arbitrary remote actors. + if (ctx.account == null) return null; + const documentLoader = await ctx.fedCtx.getDocumentLoader({ + identifier: ctx.account.id, + }); const actorObject = await ctx.fedCtx.lookupObject( handle, { documentLoader }, diff --git a/graphql/lookup.test.ts b/graphql/lookup.test.ts index 83a71476e..959b97c79 100644 --- a/graphql/lookup.test.ts +++ b/graphql/lookup.test.ts @@ -1,9 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { lookupPostByUrl, parseHttpUrl } from "./lookup.ts"; +import { lookupActorByUrl, lookupPostByUrl, parseHttpUrl } from "./lookup.ts"; import { + createFedCtx, insertAccountWithActor, insertNotePost, + insertRemoteActor, makeGuestContext, withRollback, } from "../test/postgres.ts"; @@ -74,3 +76,63 @@ test("lookupPostByUrl() ignores local share rows when matching URLs", async () = assert.equal(ignoredShare, null); }); }); + +test("lookupPostByUrl() refuses federation lookup for guests", async () => { + await withRollback(async (tx) => { + const lookupCalls: string[] = []; + const fedCtx = createFedCtx(tx, { + lookupObject: (uri) => { + lookupCalls.push(uri.toString()); + return Promise.resolve(null); + }, + }); + const ctx = makeGuestContext(tx, { fedCtx }); + + const result = await lookupPostByUrl( + ctx, + new URL("https://unknown.example/posts/missing"), + ); + + assert.equal(result, null); + assert.deepEqual(lookupCalls, []); + }); +}); + +test("lookupActorByUrl() returns a local actor by IRI", async () => { + await withRollback(async (tx) => { + const remote = await insertRemoteActor(tx, { + username: "lookupactoriri", + name: "Lookup Actor IRI", + host: "remote.example", + }); + + const found = await lookupActorByUrl( + makeGuestContext(tx), + new URL(remote.iri), + ); + + assert.ok(found != null); + assert.equal(found.id, remote.id); + }); +}); + +test("lookupActorByUrl() refuses federation lookup for guests", async () => { + await withRollback(async (tx) => { + const lookupCalls: string[] = []; + const fedCtx = createFedCtx(tx, { + lookupObject: (uri) => { + lookupCalls.push(uri.toString()); + return Promise.resolve(null); + }, + }); + const ctx = makeGuestContext(tx, { fedCtx }); + + const result = await lookupActorByUrl( + ctx, + new URL("https://unknown.example/users/missing"), + ); + + assert.equal(result, null); + assert.deepEqual(lookupCalls, []); + }); +}); diff --git a/graphql/lookup.ts b/graphql/lookup.ts index 881894f4b..9c1ddf744 100644 --- a/graphql/lookup.ts +++ b/graphql/lookup.ts @@ -17,8 +17,10 @@ export function parseHttpUrl(raw: string): URL | null { /** * Look up a post by URL. Checks the local database first (excluding share - * rows), then attempts federation lookup if not found locally. Returns - * the original post row (without extra relations) or `null`. + * rows). Authenticated callers fall back to a federation lookup; guests + * receive `null` after the local miss to avoid attacker-driven outbound + * fetches and arbitrary remote-post persistence. Returns the original + * post row (without extra relations) or `null`. */ export async function lookupPostByUrl( ctx: UserContext, @@ -34,11 +36,13 @@ export async function lookupPostByUrl( }); if (existing != null) return existing; - const documentLoader = ctx.account == null - ? ctx.fedCtx.documentLoader - : await ctx.fedCtx.getDocumentLoader({ - identifier: ctx.account.id, - }); + // Guests must not trigger federation lookups: they would let unauthenticated + // callers spawn outbound fetches and persist arbitrary remote posts. + if (ctx.account == null) return null; + + const documentLoader = await ctx.fedCtx.getDocumentLoader({ + identifier: ctx.account.id, + }); let object; try { @@ -61,9 +65,11 @@ export async function lookupPostByUrl( * Look up an actor by URL. Tries the local database first, matching the URL * against the actor's canonical `iri` and falling back to the human-facing * `url` (the latter is nullable and non-unique on the actor table, so the - * `iri` match takes precedence). Falls back to a federation lookup if - * nothing matches locally; returns the persisted actor row, or `null` when - * the URL doesn't resolve to a fediverse actor. + * `iri` match takes precedence). Authenticated callers fall back to a + * federation lookup; guests receive `null` after the local miss to avoid + * attacker-driven outbound fetches and arbitrary remote-actor persistence. + * Returns the persisted actor row, or `null` when the URL doesn't resolve + * to a fediverse actor. */ export async function lookupActorByUrl( ctx: UserContext, @@ -81,11 +87,13 @@ export async function lookupActorByUrl( }); if (byUrl != null) return byUrl; - const documentLoader = ctx.account == null - ? ctx.fedCtx.documentLoader - : await ctx.fedCtx.getDocumentLoader({ - identifier: ctx.account.id, - }); + // Guests must not trigger federation lookups: they would let unauthenticated + // callers spawn outbound fetches and persist arbitrary remote actors. + if (ctx.account == null) return null; + + const documentLoader = await ctx.fedCtx.getDocumentLoader({ + identifier: ctx.account.id, + }); let object; try { diff --git a/graphql/poll.test.ts b/graphql/poll.test.ts index 65c32ba6e..d3571e522 100644 --- a/graphql/poll.test.ts +++ b/graphql/poll.test.ts @@ -439,6 +439,11 @@ test("shared Question wrappers can resolve the original poll", async () => { test("Question.poll backfills missing remote poll rows", async () => { await withRollback(async (tx) => { + const viewer = await insertAccountWithActor(tx, { + username: "backfillpollviewer", + name: "Backfill Poll Viewer", + email: "backfillpollviewer@example.com", + }); const author = await insertRemoteActor(tx, { username: "backfillpollauthor", name: "Backfill Poll Author", @@ -492,7 +497,7 @@ test("Question.poll backfills missing remote poll rows", async () => { schema, document: questionPollQuery, variableValues: { id: encodeGlobalID("Question", questionId) }, - contextValue: makeGuestContext(tx, { fedCtx }), + contextValue: makeUserContext(tx, viewer.account, { fedCtx }), onError: "NO_PROPAGATE", }); @@ -533,8 +538,69 @@ test("Question.poll backfills missing remote poll rows", async () => { }); }); +test("Question.poll refuses federation lookup for guests", async () => { + await withRollback(async (tx) => { + const author = await insertRemoteActor(tx, { + username: "guestbackfillauthor", + name: "Guest Backfill Author", + host: "remote.example", + iri: "https://remote.example/users/guestbackfillauthor", + }); + const questionId = generateUuidV7(); + const questionIri = `https://remote.example/objects/${questionId}`; + const published = new Date("2026-04-15T00:00:00.000Z"); + + await tx.insert(postTable).values( + { + id: questionId, + iri: questionIri, + type: "Question", + visibility: "public", + actorId: author.id, + name: "Guest backfill?", + contentHtml: "

Guest backfill?

", + language: "en", + tags: {}, + emojis: {}, + url: questionIri, + published, + updated: published, + } satisfies NewPost, + ); + + const lookupCalls: string[] = []; + const fedCtx = createFedCtx(tx, { + lookupObject: (uri) => { + lookupCalls.push(uri.toString()); + return Promise.resolve(null); + }, + }); + + const result = await execute({ + schema, + document: questionPollQuery, + variableValues: { id: encodeGlobalID("Question", questionId) }, + contextValue: makeGuestContext(tx, { fedCtx }), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + node: { + poll: null, + }, + }); + assert.deepEqual(lookupCalls, []); + }); +}); + test("Question.poll ignores non-Question remote backfills", async () => { await withRollback(async (tx) => { + const viewer = await insertAccountWithActor(tx, { + username: "nonquestionbackfillviewer", + name: "Non-Question Backfill Viewer", + email: "nonquestionbackfillviewer@example.com", + }); const author = await insertRemoteActor(tx, { username: "nonquestionbackfillauthor", name: "Non-Question Backfill Author", @@ -578,7 +644,7 @@ test("Question.poll ignores non-Question remote backfills", async () => { schema, document: questionPollQuery, variableValues: { id: encodeGlobalID("Question", questionId) }, - contextValue: makeGuestContext(tx, { fedCtx }), + contextValue: makeUserContext(tx, viewer.account, { fedCtx }), onError: "NO_PROPAGATE", }); @@ -599,6 +665,11 @@ test("Question.poll ignores non-Question remote backfills", async () => { test("Question.poll returns null when remote backfill fails", async () => { await withRollback(async (tx) => { + const viewer = await insertAccountWithActor(tx, { + username: "failedbackfillpollviewer", + name: "Failed Backfill Poll Viewer", + email: "failedbackfillpollviewer@example.com", + }); const author = await insertRemoteActor(tx, { username: "failedbackfillpollauthor", name: "Failed Backfill Poll Author", @@ -636,7 +707,7 @@ test("Question.poll returns null when remote backfill fails", async () => { schema, document: questionPollQuery, variableValues: { id: encodeGlobalID("Question", questionId) }, - contextValue: makeGuestContext(tx, { fedCtx }), + contextValue: makeUserContext(tx, viewer.account, { fedCtx }), onError: "NO_PROPAGATE", }); diff --git a/graphql/poll.ts b/graphql/poll.ts index 26633c48e..7cf0810a7 100644 --- a/graphql/poll.ts +++ b/graphql/poll.ts @@ -293,12 +293,15 @@ builder.drizzleObjectField(Question, "poll", (t) => if (question.poll != null) return question.poll; if (question.sharedPostId != null) return null; + // Guests must not trigger federation lookups: they would let + // unauthenticated callers spawn outbound fetches and persist remote + // poll subobjects on demand. + if (ctx.account == null) return null; + try { - const documentLoader = ctx.account == null - ? undefined - : await ctx.fedCtx.getDocumentLoader({ - identifier: ctx.account.id, - }); + const documentLoader = await ctx.fedCtx.getDocumentLoader({ + identifier: ctx.account.id, + }); const postObject = await ctx.fedCtx.lookupObject(question.iri, { documentLoader, }); diff --git a/graphql/search.test.ts b/graphql/search.test.ts index 6cf02c6be..31a52aa00 100644 --- a/graphql/search.test.ts +++ b/graphql/search.test.ts @@ -3,6 +3,8 @@ import test from "node:test"; import { execute, parse } from "graphql"; import { schema } from "./mod.ts"; import { + createFedCtx, + type FedCtxLookupObject, insertAccountWithActor, insertNotePost, makeGuestContext, @@ -101,3 +103,53 @@ test("searchObject resolves local note URLs to canonical note routes", async () }); }); }); + +test("searchObject returns null for an unknown URL without federation lookup", async () => { + await withRollback(async (tx) => { + const lookupCalls: string[] = []; + const recordingLookup: FedCtxLookupObject = (uri) => { + lookupCalls.push(uri.toString()); + return Promise.resolve(null); + }; + const fedCtx = createFedCtx(tx, { lookupObject: recordingLookup }); + + const result = await execute({ + schema, + document: searchObjectQuery, + variableValues: { query: "https://unknown.example/posts/missing" }, + contextValue: makeGuestContext(tx, { fedCtx }), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + searchObject: null, + }); + assert.deepEqual(lookupCalls, []); + }); +}); + +test("searchObject returns null for an unknown remote handle without federation lookup", async () => { + await withRollback(async (tx) => { + const lookupCalls: string[] = []; + const recordingLookup: FedCtxLookupObject = (uri) => { + lookupCalls.push(uri.toString()); + return Promise.resolve(null); + }; + const fedCtx = createFedCtx(tx, { lookupObject: recordingLookup }); + + const result = await execute({ + schema, + document: searchObjectQuery, + variableValues: { query: "@nobody@unknown.example" }, + contextValue: makeGuestContext(tx, { fedCtx }), + onError: "NO_PROPAGATE", + }); + + assert.equal(result.errors, undefined); + assert.deepEqual(toPlainJson(result.data), { + searchObject: null, + }); + assert.deepEqual(lookupCalls, []); + }); +}); diff --git a/graphql/search.ts b/graphql/search.ts index 6d83d8943..84e28ba87 100644 --- a/graphql/search.ts +++ b/graphql/search.ts @@ -108,10 +108,15 @@ async function searchAsHandle(ctx: UserContext, query: string) { return { url: redirectUrl }; } + // Guests must not trigger federation lookups: they would let unauthenticated + // callers spawn outbound WebFinger / actor fetches and persist arbitrary + // remote actors. + if (ctx.account == null) return null; + // Try to fetch remote actor using federation context - const documentLoader = ctx.account == null - ? ctx.fedCtx.documentLoader - : await ctx.fedCtx.getDocumentLoader({ identifier: ctx.account.id }); + const documentLoader = await ctx.fedCtx.getDocumentLoader({ + identifier: ctx.account.id, + }); let object; try { diff --git a/test/postgres.ts b/test/postgres.ts index b835e6005..dc3dfd95a 100644 --- a/test/postgres.ts +++ b/test/postgres.ts @@ -364,11 +364,36 @@ export function createTestEmailTransport(): TestEmailTransport { }; } +export type FedCtxLookupObject = RequestContext["lookupObject"]; + +// Authenticated paths in production code call `getDocumentLoader` and pass +// the returned `DocumentLoader` to `lookupObject` and `persistPost`. Tests +// almost always override `lookupObject` to return a synthetic vocab object +// directly, so the document loader itself is never invoked: the stub below +// is only there to make `getDocumentLoader` resolve without throwing. +const stubAuthenticatedDocumentLoader = () => + Promise.reject( + new Error( + "createFedCtx default authenticated DocumentLoader was invoked; " + + "tests should override fedCtx.lookupObject so the loader stays unused.", + ), + ); + export function createFedCtx( tx: Transaction, - options: { kv?: UserContext["kv"] } = {}, + options: { + kv?: UserContext["kv"]; + lookupObject?: FedCtxLookupObject; + } = {}, ): RequestContext { const kv = options.kv ?? createTestKv().kv; + const lookupObject: FedCtxLookupObject = options.lookupObject ?? (() => { + throw new Error( + "createFedCtx default lookupObject was called; pass " + + "options.lookupObject to opt in or override fedCtx.lookupObject " + + "explicitly.", + ); + }); return { host: "localhost", @@ -406,6 +431,10 @@ export function createFedCtx( "http://localhost/", ); }, + getDocumentLoader() { + return Promise.resolve(stubAuthenticatedDocumentLoader); + }, + lookupObject, sendActivity() { return Promise.resolve(undefined); },