Skip to content

Serve only public portable objects from gateways by default - #1183

Merged
dahlia merged 2 commits into
fedify-dev:mainfrom
dahlia:fep-ef61/private-portable-objects
Sep 30, 2026
Merged

dahlia merged 2 commits into
fedify-dev:mainfrom
dahlia:fep-ef61/private-portable-objects

Conversation

@dahlia

@dahlia dahlia commented Sep 30, 2026

Copy link
Copy Markdown
Member

Closes #1153.

FEP-ef61 says a gateway must not serve a non-public object except to a request signed by an actor in the object's audience. Until now, an object dispatcher without an authorize() predicate served any portable object to anyone once its proof verified. Gateways store objects that other servers and clients created, so a private object could end up public without the application ever deciding it should be.

How it works

Without a predicate, a gateway request is now served only if the object is publicly addressed; otherwise the response is 404 Not Found, exactly as if the gateway did not store the object. Fedify does not check the audience itself, since only the application knows who is in a followers collection; serving anything else takes a predicate, as for ordinary objects. Gateway serving is unreleased, so the new default breaks no one.

  • Visibility is read from the JSON-LD document actually being served, not the Object instance. An object built by fromJsonLd() serializes its original JSON, which can disagree with a mutated instance.
  • The check runs before proof verification, so a requester outside the audience cannot trigger verification or tell a mis-signed private object (500) from a missing one (404).
  • The visibility check and the proof verifier share one memoized context loader, so a nondeterministic loader cannot make them read different contexts.
  • Actors are exempt, including those from object dispatchers. The exemption uses isActor() rather than FEP-2277's property-based classifier, under which a private Note with totalItems would pass as a collection. Collections and tombstones get no exemption.
  • to, cc, bto, bcc, and audience all count, with the public collection written as the full IRI, as:Public, or Public. Bare Public survives JSON-LD expansion as a relative @id, so Fedify treats that exact string as public.
  • Only the root is checked. Expansion drops some properties that are still served, so checking embedded objects would have promised more than it could deliver; the manual documents the limitation instead.

With a predicate, the predicate decides alone and a denial still gets 401 Unauthorized through onUnauthorized, as for ordinary objects. Allowed responses now carry Cache-Control: private, because HTTP Signatures do not get the caching rules that Authorization has, and a shared cache would otherwise hand one requester's response to the next.

An addressee may be an ap: URI while the signer is identified by a compatible identifier on another gateway, which is easy to get wrong by hand. The new RequestContext.isSignedByAudience() compares them canonically and takes an isMember callback for collections. The mock contexts in @fedify/testing implement it too.

Out of scope

Ordinary object URLs and collection dispatchers served through the gateway behave as before. The stale sentence in docs/manual/object.md saying collection dispatchers are unreachable through the gateway is left for #1150.

@netlify

netlify Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for fedify-json-schema canceled.

Name Link
🔨 Latest commit 5128454
🔍 Latest deploy log https://app.netlify.com/projects/fedify-json-schema/deploys/6abcaff58f3624000853a38f

@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Warning

Review limit reached

Next included review available in 27 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: c0dc6e9d-24cf-4a12-8b30-51aff6c6332e

📥 Commits

Reviewing files that changed from the base of the PR and between 177d4b0 and 5128454.

📒 Files selected for processing (17)
  • CHANGES.md
  • changes.d/fedify/portable-object-serving.md
  • changes.d/testing/is-signed-by-audience.md
  • docs/manual/access-control.md
  • docs/manual/object.md
  • packages/fedify/src/federation/audience.test.ts
  • packages/fedify/src/federation/audience.ts
  • packages/fedify/src/federation/context.ts
  • packages/fedify/src/federation/handler.ts
  • packages/fedify/src/federation/middleware.ts
  • packages/fedify/src/federation/portable-collection.test.ts
  • packages/fedify/src/federation/portable.test.ts
  • packages/fedify/src/testing/context.ts
  • packages/fedify/src/utils/docloader.test.ts
  • packages/testing/src/context.ts
  • packages/testing/src/mock.test.ts
  • packages/testing/src/mock.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 5983cd65-8c34-4dc6-8c2d-245947349f03

📥 Commits

Reviewing files that changed from the base of the PR and between e588a43 and 177d4b0.

📒 Files selected for processing (17)
  • CHANGES.md
  • changes.d/fedify/portable-object-serving.md
  • changes.d/testing/is-signed-by-audience.md
  • docs/manual/access-control.md
  • docs/manual/object.md
  • packages/fedify/src/federation/audience.test.ts
  • packages/fedify/src/federation/audience.ts
  • packages/fedify/src/federation/context.ts
  • packages/fedify/src/federation/handler.ts
  • packages/fedify/src/federation/middleware.ts
  • packages/fedify/src/federation/portable-collection.test.ts
  • packages/fedify/src/federation/portable.test.ts
  • packages/fedify/src/testing/context.ts
  • packages/fedify/src/utils/docloader.test.ts
  • packages/testing/src/context.ts
  • packages/testing/src/mock.test.ts
  • packages/testing/src/mock.ts

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change adds audience-signature checks to request and test contexts. Portable gateway responses now apply public-addressing, object-ID, proof, and authorization checks. The documentation and tests describe these rules and their response behavior.

Changes

Portable gateway access

Layer / File(s) Summary
Audience matching and request-context API
packages/fedify/src/federation/audience.ts, packages/fedify/src/federation/context.ts, packages/fedify/src/federation/middleware.ts, packages/fedify/src/federation/audience.test.ts, docs/manual/access-control.md
Audience checks recognize Public recipients, direct addressees, canonical portable-ID matches, and optional collection membership. RequestContext.isSignedByAudience() exposes this check.
Request and mock context implementations
packages/fedify/src/testing/context.ts, packages/testing/src/context.ts, packages/testing/src/mock.ts, packages/testing/src/mock.test.ts, changes.d/testing/is-signed-by-audience.md, CHANGES.md
Request and mock contexts provide default isSignedByAudience() implementations. Tests cover public addressing, signer matching, malformed portable IDs, and membership checks.
Portable gateway checks and documentation
packages/fedify/src/federation/handler.ts, packages/fedify/src/federation/middleware.ts, packages/fedify/src/federation/portable.test.ts, packages/fedify/src/federation/portable-collection.test.ts, docs/manual/object.md, changes.d/fedify/portable-object-serving.md, CHANGES.md
Gateway handling checks the expanded object ID and proof. Without an authorization predicate, non-actor objects must be publicly addressed. Configured authorization predicates result in private caching. Tests and documentation describe these conditions, including tombstones.

Document loader timeout test

Layer / File(s) Summary
Double-knock timeout test
packages/fedify/src/utils/docloader.test.ts
The timeout test increases its response delays, timeout, and follow-up wait while retaining its timeout and request-count assertions.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant Requester
  participant FederationImpl
  participant handlePortableObject
  Requester->>FederationImpl: Request portable actor or object
  FederationImpl->>handlePortableObject: Dispatch with actor or object kind
  handlePortableObject->>handlePortableObject: Check expanded ID, addressing, and proof
  handlePortableObject-->>FederationImpl: Return gateway response
  FederationImpl-->>Requester: Return response
Loading

Merge Risk: ⚪ Minimal · up to 177d4

Portable gateway objects are now served only when publicly addressed, unless an authorization predicate is configured. No blocking issue was found, so the change looks ready to merge.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 177d4

The change substantially tightens default access to private portable objects and prevents shared caching of predicate-authorized responses. Private access still depends on application authorization and membership policy. No newly introduced disclosure path was established, but incomplete coverage prevents treating the change as risk-free.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The independently reachable disclosure scope is the portable objects that an application exposes through registered gateway dispatchers. Anonymous callers can request those objects, but the new default restricts non-public non-actor responses. A configured predicate can still authorize broader access; the examined changes do not establish increased infrastructure, credential, or cross-tenant authority.

Security Findings and Attack Paths

  • inferred — The previous anonymous private-object retrieval path is narrowed: a matching non-public non-actor object without a predicate now exits through not-found before proof verification. This blocks that route to the payload and avoids exposing proof-verification success or failure for an otherwise hidden, successfully serialized object.

Trust Boundaries and Controls

  • observed — Private audience checks resolve the owner only after the request-signature verification call yields a key. Missing keys and key-owner fetch failures become unauthenticated. Direct recipients are compared canonically; only the application callback can grant collection-membership access.
  • observed — Configured predicates remain authoritative and run before serialization; the handler does not automatically enforce audience matching when one exists. Actor routes and runtime Actor instances are exempt from the default visibility gate. Runtime-versus-serialized actor type alignment remains unresolved, but no concrete bypass or worsening of the previous permissive exposure was established.

Resilience and Maintainability Implications

  • inferred — The examined serving transition has request-local intermediate state and terminates without constructing a payload response when identity, visibility, authorization, or required proof checks reject it. Sharing the serialized document and context loader reduces authorization/verification drift across awaits. Application-owned mutable objects and predicate reads remain outside that guarantee.

Hardening Proposals

  • proposed — For application integrations, authorize against the same stable object snapshot that the dispatcher serves, and keep collection-membership decisions scoped to that object's policy. Where custom serialization is supported, explicitly preserve alignment between runtime actor classification and the serialized root type.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.29% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 17 functions across 12 files. (5 skipped:… 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 clearly and concisely summarizes the main change: gateways serve only public portable objects by default.
Description check ✅ Passed The description directly explains the gateway access-control changes, default public-object behavior, authorization predicates, caching, and the new audience-signature API.
Linked Issues check ✅ Passed PR #1183 implements the coding requirements in [#1153]. handlePortableObject() checks the served JSON-LD root before proof verification and returns 404 Not Found for non-actor portable objects tha…
Out of Scope Changes check ✅ Passed The changes stay within [#1153] and FEP-ef61 gateway access control. The audience helpers, RequestContext.isSignedByAudience(), mock support, documentation, changelog entries, and tests support the …
Full details: Docstring Coverage

Explanation

Docstring coverage is 35.29% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 17 functions across 12 files. (5 skipped: 5 unsupported.)

✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch fep-ef61/private-portable-objects
🧪 Generate unit tests (beta)
  • Create a new PR

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.

@codecov

codecov Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.92453% with 15 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
packages/fedify/src/federation/handler.ts 85.71% 6 Missing and 1 partial ⚠️
packages/testing/src/context.ts 89.23% 4 Missing and 3 partials ⚠️
packages/testing/src/mock.ts 85.71% 0 Missing and 1 partial ⚠️
Files with missing lines Coverage Δ
packages/fedify/src/federation/audience.ts 100.00% <100.00%> (ø)
packages/fedify/src/federation/middleware.ts 87.42% <100.00%> (ø)
packages/fedify/src/testing/context.ts 76.26% <100.00%> (ø)
packages/testing/src/mock.ts 81.73% <85.71%> (ø)
packages/fedify/src/federation/handler.ts 84.94% <85.71%> (ø)
packages/testing/src/context.ts 68.07% <89.23%> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

dahlia added a commit to dahlia/fedify-fork that referenced this pull request Sep 30, 2026
The "double-knocking shares the timeout" step of the
getAuthenticatedDocumentLoader() timeout test gave each request 80 ms
under a 120 ms timeout, which left 40 ms to sign and send the second
knock.  On a busy CI runner, signing alone took longer than that, so the
timeout fired before the second request and the test saw only one.  It
failed twice in a row on Bun in
fedify-dev#1183.

Each knock now takes 200 ms under a 300 ms timeout.  The test still
tells a shared timeout from a per-request one, as the two knocks take
400 ms in total, but leaves about 100 ms on both sides.

Changelog: none
Assisted-by: Claude Code:claude-opus-5-5
FEP-ef61 forbids a gateway to serve a non-public object to anyone but
its intended audience, and a gateway may store objects that other
servers or clients created without the application ever deciding who
may see them.  Until now, an object dispatcher without an authorization
predicate served any portable object to anyone once its proof verified.

Through the gateway endpoint, an object dispatcher without a predicate
now serves a portable object only if the served JSON-LD document is
publicly addressed, i.e., its to, cc, bto, bcc, or audience has the
public collection (the full IRI, as:Public, or Public).  Any other
object gets 404 Not Found, as if the gateway did not store it, and the
decision is made before its proofs are verified, so that a requester
who may not see the object can neither trigger the verification nor
tell its result.  Actors, whether from the actor dispatcher or an object
dispatcher, are exempt as they have no audience.  Ordinary object URLs
are unchanged.

With a predicate, the predicate alone decides as before, and allowed
responses now carry Cache-Control: private, since other requesters may
be denied the same object.  The new RequestContext.isSignedByAudience()
method helps such predicates: it checks whether the request is signed
by an actor in an object's audience, comparing portable IDs
canonically, and takes an isMember option for collections such as
followers, which Fedify cannot resolve by itself.  The mock contexts of
@fedify/testing implement it as well.

The visibility check and the proof verification share one memoized
context loader, so that both interpret the document the same way.

Closes fedify-dev#1153
fedify-dev#288

Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Claude Code:claude-fable-5-1
Assisted-by: Codex:gpt-6-astra
Assisted-by: OpenCode:deepseek-flash
The "double-knocking shares the timeout" step of the
getAuthenticatedDocumentLoader() timeout test gave each request 80 ms
under a 120 ms timeout, which left 40 ms to sign and send the second
knock.  On a busy CI runner, signing alone took longer than that, so the
timeout fired before the second request and the test saw only one.  It
failed twice in a row on Bun in
fedify-dev#1183.

Each knock now takes 200 ms under a 300 ms timeout.  The test still
tells a shared timeout from a per-request one, as the two knocks take
400 ms in total, but leaves about 100 ms on both sides.

Changelog: none
Assisted-by: Claude Code:claude-opus-5-5
@dahlia
dahlia force-pushed the fep-ef61/private-portable-objects branch from 177d4b0 to 5128454 Compare September 30, 2026 06:45
@dahlia
dahlia merged commit 26ce985 into fedify-dev:main Sep 30, 2026
26 checks passed
@dahlia
dahlia deleted the fep-ef61/private-portable-objects branch September 30, 2026 07:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component/federation Federation object related

Projects

No open projects

Development

Successfully merging this pull request may close these issues.

Decide how gateways protect private portable objects

1 participant