Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion graphql/actor.more.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { actorTable, pinTable } from "@hackerspub/models/schema";
import { schema } from "./mod.ts";
import {
createFedCtx,
type FedCtxLookupObject,
insertAccountWithActor,
insertNotePost,
insertRemoteActor,
Expand Down Expand Up @@ -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, []);
});
});

Expand Down
10 changes: 7 additions & 3 deletions graphql/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
64 changes: 63 additions & 1 deletion graphql/lookup.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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, []);
});
});
38 changes: 23 additions & 15 deletions graphql/lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
77 changes: 74 additions & 3 deletions graphql/poll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
});

Expand Down Expand Up @@ -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: "<p>Guest backfill?</p>",
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",
Expand Down Expand Up @@ -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",
});

Expand All @@ -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",
Expand Down Expand Up @@ -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",
});

Expand Down
13 changes: 8 additions & 5 deletions graphql/poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
Loading
Loading