Skip to content

Refuse federation lookups for unauthenticated GraphQL callers#278

Merged
dahlia merged 3 commits into
hackers-pub:mainfrom
dahlia:graphql/auth-fedi-lookups
May 2, 2026
Merged

Refuse federation lookups for unauthenticated GraphQL callers#278
dahlia merged 3 commits into
hackers-pub:mainfrom
dahlia:graphql/auth-fedi-lookups

Conversation

@dahlia

@dahlia dahlia commented May 2, 2026

Copy link
Copy Markdown
Member

Resolves #277.

Why

Five GraphQL resolvers branched on ctx.account == null only to pick a documentLoader, then still called fedCtx.lookupObject and persistActor/persistPost when the local DB lookup missed. An unauthenticated client could feed arbitrary handles or URLs and force the server into outbound WebFinger/ActivityPub fetches, persisting whatever came back as actor or post rows. That exposed the server to spam-driven row growth and attacker-controlled outbound requests, with federation-log noise as a secondary concern.

What changed

When ctx.account == null, the following sites now short-circuit to null after the local DB checks and before any federation work:

  • actorByHandle in graphql/actor.ts
  • lookupActorByUrl in graphql/lookup.ts, reached via actorByUrl
  • lookupPostByUrl in graphql/lookup.ts, reached via searchObjectsearchAsUrl
  • searchAsHandle in graphql/search.ts, reached via searchObject (beyond the original issue scope; same guest-triggered federation path)
  • Question.poll in graphql/poll.ts (surfaced from a Codex branch-level review; narrower than the others because the IRI is a known local Question's iri rather than caller input, but it still triggered a guest-driven outbound fetch and persistPost)

Authenticated paths are unchanged.

Out of scope

lookupRemoteFollower in graphql/webfinger.ts is intentionally not gated. It backs the cross-instance “follow from another instance” flow, where the caller has no local account by definition: a remote user pastes their handle to get back a remote-follow URL. It also does not call persistActor or persistPost, so the persistence concerns from the issue do not apply. Outbound-fetch concerns there can be handled separately with rate limiting or caching.

The legacy Fresh route at web/routes/search.tsx mirrors the same pattern but lives on the pre-migration stack and falls outside this GraphQL-focused fix.

UX trade-off

The mention hover cards from #90/#276 will fall back to “Could not load profile” for guests on remote actors that no signed-in user has pulled into the local DB yet. Acceptable per the issue.

Tests

deno task test reports 369/0 (was 362 before this branch).

New guest-path tests install a recording lookupObject via a new option on createFedCtx in test/postgres.ts, then assert the resolver returns null and that nothing was recorded. Without the gate, every call site under test records one entry, so the assertions show the gate ran instead of passing because an unrelated lookup returned null.

Three pre-existing Question.poll backfill tests in graphql/poll.test.ts moved from makeGuestContext to makeUserContext so they keep covering the authenticated backfill path; a new dedicated test, Question.poll refuses federation lookup for guests, locks in the guest contract.

The fixture stubs getDocumentLoader on createFedCtx too. The returned DocumentLoader rejects when invoked, so a test that needs genuine document loading fails with a named error instead of silently returning nothing.

Test plan

  • deno task test passes locally
  • deno task check passes (lint + format + tsc; also runs in the pre-commit hook)
  • Manual: guest hover card for a remote actor absent from the local DB shows “Could not load profile”
  • Manual: signed-in hover card for the same actor resolves normally
  • Manual: guest search for a remote post URL returns null from searchObject (no redirect)

dahlia added 3 commits May 2, 2026 16:36
Extends createFedCtx in test/postgres.ts with an optional
lookupObject option, so upcoming guest-gate tests can install a
spy and assert that federation lookup was not invoked.  The
default value is a function that throws a clearly-named Error,
giving a meaningful failure if a code path unexpectedly calls
fedCtx.lookupObject without an explicit override (instead of the
previous TypeError from a missing method).

This is a test-fixture-only change; no production behavior is
affected and the existing 362 tests still pass.  It is the first
step toward restricting unauthenticated federation lookups
described in hackers-pub#277.

Assisted-by: Claude Code:claude-opus-4-7
Three GraphQL entry points (plus searchAsHandle, surfaced in
review) previously branched on ctx.account == null only to pick
which documentLoader to use, but still called fedCtx.lookupObject
followed by persistActor / persistPost.  An unauthenticated
caller could therefore feed arbitrary handles or URLs to:

  * actorByHandle in graphql/actor.ts
  * lookupActorByUrl in graphql/lookup.ts (via actorByUrl)
  * lookupPostByUrl in graphql/lookup.ts (via searchObject /
    searchAsUrl)
  * searchAsHandle in graphql/search.ts (via searchObject)

…and force the server to perform outbound WebFinger / ActivityPub
fetches and persist whatever object came back.  The concrete
risks are spam-driven actor / post row growth, attacker-controlled
outbound requests, and noise in federation logs.

After this change, all four sites short-circuit to null when
ctx.account is null, immediately after the local-database checks
and before any federation lookup.  Authenticated paths are
unchanged.  Tests use the recording lookupObject hook on
createFedCtx to assert that no federation call happens for
guest contexts.

The mention hover-card flow from the previous PR will gracefully
fall back to "Could not load profile" for guests on remote actors
that haven't been pulled into the local DB by a signed-in user
yet — an acceptable trade-off per the issue.

The lookupRemoteFollower resolver in graphql/webfinger.ts is
intentionally left out of this commit: its purpose is the
cross-instance "follow from another instance" UX where guest
invocation is intrinsic, and it does not persist actors or posts.

hackers-pub#277

Assisted-by: Claude Code:claude-opus-4-7
The Question.poll resolver in graphql/poll.ts had the same shape
as the four call sites already gated for guest callers: a local
"do we already have it?" check, then a fall-through to
ctx.fedCtx.lookupObject(question.iri) + persistPost when the poll
subobject was missing.  The IRI here is a known local Question's
own iri rather than attacker-supplied input, so this is narrower
than the originally listed entry points — but it still lets
unauthenticated callers trigger outbound fetches and persist a
poll subobject under their request.

When ctx.account is null, the resolver now short-circuits to
null right after the early returns for question.poll and
question.sharedPostId, before any federation work happens.

The three pre-existing backfill tests previously used
makeGuestContext (success path, non-Question rejection, lookup
failure).  After the gate they would all return null without
exercising the production code path they were written for, so
they switch to makeUserContext and continue covering the
authenticated branch.  A new dedicated test "Question.poll
refuses federation lookup for guests" installs a recording
lookupObject and asserts the resolver returns null with no
federation call.

createFedCtx in test/postgres.ts gains a getDocumentLoader stub
that resolves to a DocumentLoader which rejects when invoked.
This is needed because the converted authenticated tests now
reach `await ctx.fedCtx.getDocumentLoader(...)`, but they only
override `lookupObject` (not the loader), and the loader value is
never actually consumed in those tests since `lookupObject`
returns synthetic vocab objects directly.  The rejection on
invocation makes any genuinely-needed real load fail loudly.

Surfaced from a Codex branch-level review of the previous commit.

hackers-pub#277

Assisted-by: Claude Code:claude-opus-4-7
Assisted-by: Codex:gpt-5.5
@dahlia dahlia self-assigned this May 2, 2026
@dahlia dahlia added the bug Something isn't working label May 2, 2026
@coderabbitai

coderabbitai Bot commented May 2, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR restricts unauthenticated federation lookups across five GraphQL resolvers by adding early-return guards when ctx.account is absent, preventing guest requests from triggering outbound network fetches or database persistence. Test infrastructure is enhanced to support federation lookup recording, and resolver test suites are updated to verify the new guard behavior.

Changes

Guest-Blocking Federation Lookups

Layer / File(s) Summary
Test Infrastructure
test/postgres.ts
createFedCtx is enhanced with optional lookupObject override, FedCtxLookupObject type alias is added, and getDocumentLoader() method is stubbed to support federation lookup recording in tests.
Core Resolver Guards
graphql/actor.ts, graphql/lookup.ts, graphql/poll.ts, graphql/search.ts
Five resolvers (actorByHandle, lookupActorByUrl, lookupPostByUrl, Question.poll, searchAsHandle) now return null when ctx.account == null, blocking guest access to federation lookups. Authenticated paths now consistently use ctx.fedCtx.getDocumentLoader({ identifier: ctx.account.id }) instead of conditional loader selection.
Test Coverage & Assertions
graphql/actor.more.test.ts, graphql/lookup.test.ts, graphql/poll.test.ts, graphql/search.test.ts
New tests verify that guests receive null without triggering federation lookups (using recording lookupObject). Existing backfill tests are updated to use authenticated context (makeUserContext) instead of guest context to maintain federation coverage for signed-in users.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

Suggested labels

enhancement

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: preventing unauthenticated GraphQL callers from triggering federation lookups.
Linked Issues check ✅ Passed All primary objectives from #277 are met: guest-gated federation lookups in actorByHandle, lookupActorByUrl, lookupPostByUrl, searchAsHandle, and Question.poll with new test coverage.
Out of Scope Changes check ✅ Passed All changes align with the stated scope; lookupRemoteFollower and web/routes/search.tsx are intentionally excluded as documented.
Description check ✅ Passed The pull request description clearly explains the security issue (unauthenticated clients forcing federation lookups and persistence), identifies all affected resolvers, documents the changes made, and provides test coverage details.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dahlia

dahlia commented May 2, 2026

Copy link
Copy Markdown
Member Author

@codex review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a security measure to prevent unauthenticated guest users from triggering outbound federation lookups. Changes were made to the GraphQL resolvers for actors, posts, polls, and search to return null for guests instead of proceeding with federation fetches. Corresponding test cases were added to verify this behavior and existing tests were updated to use authenticated contexts where federation is required. I have no feedback to provide.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@dahlia dahlia merged commit 31fb451 into hackers-pub:main May 2, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Restrict unauthenticated federation lookups

1 participant