From a7ffbea2fced34c05d73aba8abf67d59a3d55a78 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 15:57:13 +0900 Subject: [PATCH 01/24] Accept actorless FeatureRequest activities Authenticate actorless FEP-7aa9 requests against the owner of the referenced collection, then use that owner as the actor passed to the listener. Preserve the signed document and carry the authenticated owner through queued processing and retries. Keep transient collection lookup failures retryable, and reject the activity on permanent failures. Add regression coverage for HTTP Signatures, Linked Data Signatures and Object Integrity Proofs, mixed signatures, forged ownership, queue retries and lookup errors. Fixes https://github.com/fedify-dev/fedify/issues/1289 General inbox principal handling is deferred to: https://github.com/fedify-dev/fedify/issues/1290 Codex assisted the implementation and tests and reviewed the plan with gpt-6-astra. Claude Code assisted the fixes from code review. Assisted-by: Codex:gpt-6.1-sol Assisted-by: Claude Code:claude-opus-5-5 --- CHANGES.md | 10 + .../fedify/actorless-feature-requests.md | 9 + .../src/federation/feature-request.test.ts | 462 ++++++++++++++++++ .../fedify/src/federation/feature-request.ts | 81 +++ packages/fedify/src/federation/handler.ts | 78 +++ packages/fedify/src/federation/inbox.ts | 6 + packages/fedify/src/federation/middleware.ts | 12 + packages/fedify/src/federation/queue.ts | 9 + 8 files changed, 667 insertions(+) create mode 100644 changes.d/fedify/actorless-feature-requests.md create mode 100644 packages/fedify/src/federation/feature-request.test.ts create mode 100644 packages/fedify/src/federation/feature-request.ts diff --git a/CHANGES.md b/CHANGES.md index dc4947b62..43be2ebd7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,16 @@ Version 2.4.3 To be released. +### @fedify/fedify + + - Fixed the inbox rejecting FEP-7aa9 `FeatureRequest` activities without + an `actor`, allowing applications to receive collection inclusion + requests from Mastodon. Requests are accepted only when authenticated + as the referenced collection's owner. [[#1289], [#1291]] + +[#1289]: https://github.com/fedify-dev/fedify/issues/1289 +[#1291]: https://github.com/fedify-dev/fedify/pull/1291 + Version 2.4.2 ------------- diff --git a/changes.d/fedify/actorless-feature-requests.md b/changes.d/fedify/actorless-feature-requests.md new file mode 100644 index 000000000..30b4890c0 --- /dev/null +++ b/changes.d/fedify/actorless-feature-requests.md @@ -0,0 +1,9 @@ +--- +links: + '#1289': https://github.com/fedify-dev/fedify/issues/1289 + '#1291': https://github.com/fedify-dev/fedify/pull/1291 +--- + - Fixed the inbox rejecting FEP-7aa9 `FeatureRequest` activities without + an `actor`, allowing applications to receive collection inclusion + requests from Mastodon. Requests are accepted only when authenticated + as the referenced collection's owner. [[#1289], [#1291]] diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts new file mode 100644 index 000000000..a2d1d50c0 --- /dev/null +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -0,0 +1,462 @@ +import { mockDocumentLoader, test } from "@fedify/fixture"; +import { + Activity, + Create, + FeaturedCollection, + FeatureRequest, + Person, +} from "@fedify/vocab"; +import { FetchError, UrlError } from "@fedify/vocab-runtime"; +import { assert, assertEquals, assertRejects } from "@std/assert"; +import { signRequest } from "../sig/http.ts"; +import { signJsonLd, verifyJsonLd } from "../sig/ld.ts"; +import { signObject, verifyObject } from "../sig/proof.ts"; +import { + ed25519Multikey, + ed25519PrivateKey, + rsaPrivateKey2, + rsaPrivateKey3, + rsaPublicKey2, + rsaPublicKey3, +} from "../testing/keys.ts"; +import { + createInboxContext, + createRequestContext, +} from "../testing/context.ts"; +import { ActivityListenerSet } from "./activity-listener.ts"; +import type { InboxContext } from "./context.ts"; +import { handleInbox } from "./handler.ts"; +import { MemoryKvStore } from "./kv.ts"; +import { createFederation } from "./middleware.ts"; +import type { MessageQueue } from "./mq.ts"; +import type { InboxMessage } from "./queue.ts"; +import { + hasFeatureRequestSignature, + resolveFeatureRequestActor, +} from "./feature-request.ts"; +import type { InboxVerificationAttempt } from "../sig/verification.ts"; + +const owner = new URL("https://example.com/person2"); +const collectionId = new URL("https://example.com/featured/1"); +const target = new URL("https://receiver.example/users/bob"); + +async function lookupFailingCollection(error: Error): Promise { + const context = createRequestContext({ + federation: createFederation({ kv: new MemoryKvStore() }), + url: target, + data: undefined, + documentLoader: () => Promise.reject(error), + contextLoader: mockDocumentLoader, + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = () => Promise.resolve(new Response(null, { status: 404 })); + try { + return await resolveFeatureRequestActor( + context, + new FeatureRequest({ instrument: collectionId }), + ); + } finally { + globalThis.fetch = originalFetch; + } +} + +test("actorless FeatureRequest preserves collection lookup failures for retry", async () => { + const outage = new Error("Collection temporarily unavailable"); + assertEquals( + await assertRejects(() => lookupFailingCollection(outage)), + outage, + ); +}); + +for (const status of [400, 401, 403, 404, 410, 408, 429, 503]) { + test(`actorless FeatureRequest classifies collection HTTP ${status}`, async () => { + const error = new FetchError( + collectionId, + `HTTP ${status}`, + new Response(null, { status }), + ); + if (status === 408 || status === 429 || status >= 500) { + assertEquals( + await assertRejects(() => lookupFailingCollection(error)), + error, + ); + } else { + assertEquals(await lookupFailingCollection(error), null); + } + }); +} + +test("actorless FeatureRequest rejects disallowed collection URLs", async () => { + assertEquals( + await lookupFailingCollection(new UrlError("Private address")), + null, + ); +}); + +test("actorless FeatureRequest preserves collection DNS failures for retry", async () => { + const error = new UrlError("DNS lookup failed", { reason: "dns" }); + assertEquals( + await assertRejects(() => lookupFailingCollection(error)), + error, + ); +}); + +type Authentication = "http" | "ld" | "proof" | "mixed" | "unsigned"; + +async function deliver( + authentication: Authentication, + options: { + collection?: FeaturedCollection | Person; + request?: Activity; + queued?: boolean; + skip?: boolean; + } = {}, +) { + const collection = options.collection ?? new FeaturedCollection({ + id: collectionId, + attribution: owner, + }); + const activity = options.request ?? new FeatureRequest({ + id: new URL("https://example.com/requests/1"), + object: target, + instrument: collectionId, + }); + let collectionAvailable = true; + const documentLoader = async (url: string) => { + if (url === collectionId.href) { + if (!collectionAvailable) throw new Error("Collection unavailable"); + return { + document: await collection.toJsonLd(), + documentUrl: url, + contextUrl: null, + }; + } + if (url === rsaPublicKey2.id!.href) { + return { + document: await rsaPublicKey2.clone({ + owner: new URL("https://example.com/person"), + }).toJsonLd(), + documentUrl: url, + contextUrl: null, + }; + } + return await mockDocumentLoader(url); + }; + const body = authentication === "mixed" + ? await signJsonLd( + await (await signObject( + activity, + ed25519PrivateKey, + ed25519Multikey.id!, + { contextLoader: mockDocumentLoader }, + )).toJsonLd(), + rsaPrivateKey2, + rsaPublicKey2.id!, + { contextLoader: mockDocumentLoader }, + ) + : authentication === "ld" + ? await signJsonLd( + await activity.toJsonLd(), + rsaPrivateKey3, + rsaPublicKey3.id!, + { + contextLoader: mockDocumentLoader, + }, + ) + : authentication === "proof" + ? await (await signObject( + activity, + ed25519PrivateKey, + ed25519Multikey.id!, + { + contextLoader: mockDocumentLoader, + }, + )).toJsonLd() + : await activity.toJsonLd(); + let request = new Request("https://receiver.example/inbox", { + method: "POST", + body: JSON.stringify(body), + }); + if (authentication === "http") { + request = await signRequest(request, rsaPrivateKey3, rsaPublicKey3.id!); + } + const messages: InboxMessage[] = []; + const queue: MessageQueue | undefined = options.queued + ? { + enqueue(message) { + assertEquals(message.type, "inbox"); + messages.push(message as InboxMessage); + return Promise.resolve(); + }, + async listen() {}, + } + : undefined; + const kv = new MemoryKvStore(); + const federation = createFederation({ + kv, + queue, + documentLoaderFactory: () => documentLoader, + contextLoaderFactory: () => mockDocumentLoader, + }); + const received: Activity[] = []; + const originals: unknown[] = []; + let failOnce = false; + const listener = ( + context: InboxContext, + receivedActivity: Activity, + ) => { + received.push(receivedActivity); + if ("activity" in context) originals.push(context.activity); + if (failOnce) { + failOnce = false; + throw new Error("Retry this request"); + } + }; + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(FeatureRequest, listener); + const context = createRequestContext({ + federation, + request, + url: new URL(request.url), + data: undefined, + documentLoader, + contextLoader: mockDocumentLoader, + }); + const listeners = new ActivityListenerSet>(); + listeners.add(FeatureRequest, listener); + listeners.add(Create, listener); + const response = await handleInbox(request, { + recipient: null, + context, + inboxContextFactory(recipient, original, activityId, activityType) { + return { + ...createInboxContext({ ...context, clone: undefined, recipient }), + activity: original, + activityId, + activityType, + }; + }, + kv, + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"], + publicKey: ["_fedify", "publicKey"], + acceptSignatureNonce: ["_fedify", "acceptSignatureNonce"], + }, + actorDispatcher: () => new Person({ id: target }), + inboxListeners: listeners, + onNotFound: () => new Response(null, { status: 404 }), + queue, + signatureTimeWindow: { minutes: 5 }, + skipSignatureVerification: options.skip ?? false, + }); + return { + response, + received, + originals, + body, + messages, + async replay(retry = false) { + collectionAvailable = false; + failOnce = retry; + await federation.processQueuedTask(undefined, messages[0]); + if (retry) { + assertEquals(messages.length, 2); + await federation.processQueuedTask(undefined, messages[1]); + } + }, + }; +} + +for (const authentication of ["http", "ld", "proof", "mixed"] as const) { + test(`actorless FeatureRequest accepts ${authentication} authentication`, async () => { + const result = await deliver(authentication); + assertEquals(result.response.status, 202); + assertEquals(result.received.length, 1); + assertEquals(result.received[0].actorId, owner); + assertEquals(result.originals, [result.body]); + assertEquals((result.body as { actor?: unknown }).actor, undefined); + if (authentication === "ld") { + assert( + await verifyJsonLd(result.body, { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }), + ); + } else if (authentication === "proof" || authentication === "mixed") { + const { signature: _, ...withoutSignature } = result.body as Record< + string, + unknown + >; + assert( + await verifyObject(Activity, withoutSignature, { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }) != null, + ); + } + }); + + test(`actorless FeatureRequest rejects ${authentication} signer/owner mismatch`, async () => { + const result = await deliver(authentication, { + collection: new FeaturedCollection({ + id: collectionId, + attribution: new URL("https://example.com/person3"), + }), + }); + assertEquals(result.response.status, 401); + assertEquals(result.received, []); + }); + + test(`actorless FeatureRequest preserves ${authentication} actor and raw payload through queue retry`, async () => { + const result = await deliver(authentication, { queued: true }); + assertEquals(result.response.status, 202); + assertEquals(result.received, []); + assertEquals(result.messages[0].featureRequestActor, owner.href); + assertEquals(result.messages[0].activity, result.body); + await result.replay(true); + assertEquals(result.received.map((activity) => activity.actorId), [ + owner, + owner, + ]); + assertEquals(result.messages[1].featureRequestActor, owner.href); + assertEquals(result.messages[1].activity, result.body); + }); +} + +test("actorless FeatureRequest requires an actual signature, not an empty proof set", async () => { + const result = await deliver("unsigned"); + assertEquals(result.response.status, 401); + assertEquals(result.received, []); +}); + +test("actorless FeatureRequest respects explicit signature verification bypass", async () => { + const result = await deliver("unsigned", { skip: true }); + assertEquals(result.response.status, 202); + assertEquals(result.received[0].actorId, owner); +}); + +for ( + const [name, collection] of [ + ["missing owner", new FeaturedCollection({ id: collectionId })], + [ + "multiple owners", + new FeaturedCollection({ + id: collectionId, + attributions: [owner, target], + }), + ], + [ + "wrong id", + new FeaturedCollection({ + id: new URL("https://example.com/other"), + attribution: owner, + }), + ], + ["wrong type", new Person({ id: collectionId })], + ] as const +) { + test(`actorless FeatureRequest rejects a collection with ${name}`, async () => { + const result = await deliver("http", { collection }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + }); +} + +test("actorless FeatureRequest does not trust an embedded collection owner", async () => { + const result = await deliver("http", { + collection: new FeaturedCollection({ + id: collectionId, + attribution: target, + }), + request: new FeatureRequest({ + id: new URL("https://example.com/requests/1"), + object: target, + instrument: new FeaturedCollection({ + id: collectionId, + attribution: owner, + }), + }), + }); + assertEquals(result.response.status, 401); + assertEquals(result.received, []); +}); + +test("actorless FeatureRequest cannot use a signature from a rejected proof attempt", async () => { + const result = await deliver("proof", { + request: new FeatureRequest({ + id: new URL("https://example.com/requests/1"), + object: target, + instrument: collectionId, + attribution: target, + }), + }); + assertEquals(result.response.status, 401); + assertEquals(result.received, []); +}); + +test("actorless activity types other than FeatureRequest still fail", async () => { + const result = await deliver("ld", { + request: new Create({ id: new URL("https://example.com/requests/1") }), + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); +}); + +test("actorful FeatureRequest retains existing actor authentication", async () => { + const result = await deliver("http", { + request: new FeatureRequest({ + id: new URL("https://example.com/requests/1"), + actor: target, + object: target, + instrument: collectionId, + }), + }); + assertEquals(result.response.status, 401); + assertEquals(result.received, []); +}); + +test("actorless FeatureRequest portable authentication requires the owner's DID key", () => { + const did = "did:key:z6MknSLrJoTcukLrE435h2FyQkJnv8cGxvgxU1xJ3zKSu8jL"; + const actor = new URL(`ap://${encodeURIComponent(did)}/actor`); + const attempts = (keyId: URL): InboxVerificationAttempt[] => [{ + mechanism: "objectIntegrity", + subject: { id: null, pointer: "" }, + status: "verified", + checks: [], + signatures: [{ + mechanism: "objectIntegrity", + proofId: null, + proofIndex: 0, + declaredKeyId: keyId.href, + triedKeys: [], + status: "verified", + key: { + type: "multikey", + id: keyId, + controllerId: actor, + publicKey: ed25519Multikey.publicKey, + }, + }], + }]; + assert( + !hasFeatureRequestSignature( + attempts(new URL("https://gateway.example/key")), + actor, + "objectIntegrity", + ), + ); + assert( + hasFeatureRequestSignature( + attempts(new URL(`${did}#key`)), + actor, + "objectIntegrity", + ), + ); + assert( + !hasFeatureRequestSignature( + attempts(new URL(`${did}#key`)), + actor, + "linkedData", + ), + ); +}); diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts new file mode 100644 index 000000000..c58683e61 --- /dev/null +++ b/packages/fedify/src/federation/feature-request.ts @@ -0,0 +1,81 @@ +import type { Context } from "./context.ts"; +import { FeaturedCollection, type FeatureRequest } from "@fedify/vocab"; +import { + type DocumentLoader, + FetchError, + UrlError, +} from "@fedify/vocab-runtime"; +import type { InboxVerificationAttempt } from "../sig/verification.ts"; +import { + getPortableDid, + isPortableId, + isSameObjectId, +} from "../sig/portable-key-id.ts"; + +// FIXME: Replace this FeatureRequest-specific authentication bridge with the +// general inbox principal handling: https://github.com/fedify-dev/fedify/issues/1290 +export async function resolveFeatureRequestActor( + context: Context, + request: FeatureRequest, +): Promise { + if (request.instrumentIds.length !== 1) return null; + const id = request.instrumentIds[0]; + // Never infer ownership from an instrument embedded by the sender. + // lookupObject() may hide loader failures while trying other locations. + // Preserve them if lookup fails so temporary outages remain retryable. + let loadFailure: { error: unknown } | undefined; + const captureFailure = + (loader: DocumentLoader): DocumentLoader => async (...args) => { + try { + return await loader(...args); + } catch (error) { + loadFailure = { error }; + throw error; + } + }; + const collection = await context.lookupObject(id, { + documentLoader: captureFailure(context.documentLoader), + contextLoader: captureFailure(context.contextLoader), + }); + if (collection == null && loadFailure != null) { + const error = loadFailure.error; + const status = error instanceof FetchError ? error.response?.status : null; + const permanent = + error instanceof UrlError && error.reason === "disallowed" || + status != null && status >= 400 && status < 500 && + status !== 408 && status !== 429; + if (!permanent) throw error; + } + if ( + !(collection instanceof FeaturedCollection) || collection.id == null || + !isSameObjectId(collection.id, id) || collection.attributionIds.length !== 1 + ) return null; + return collection.attributionIds[0]; +} + +// TODO: Replace this signature observation scan with shared inbox principals. +// https://github.com/fedify-dev/fedify/issues/1290 +export function hasFeatureRequestSignature( + attempts: readonly InboxVerificationAttempt[], + actor: URL, + mechanism: "linkedData" | "objectIntegrity", +): boolean { + const portable = isPortableId(actor) || /^did:/i.test(actor.href); + return attempts.some((attempt) => + attempt.subject.pointer === "" && attempt.mechanism === mechanism && + attempt.status === "verified" && attempt.signatures.some((signature) => { + const key = signature.key; + const owner = key.type === "cryptographicKey" + ? key.ownerId + : key.controllerId; + if (owner == null) return false; + if (!portable) return owner.href === actor.href; + // A gateway's web key cannot authenticate a portable actor. Mirror + // the DID binding used by Object Integrity Proof verification. + const did = getPortableDid(actor); + return mechanism === "objectIntegrity" && did != null && key.id != null && + /^(?:did:|ap(?:\+ef61)?:\/\/)/i.test(key.id.href) && + getPortableDid(owner) === did && getPortableDid(key.id) === did; + }) + ); +} diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 14ecec4ee..b34e9ef7f 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -12,6 +12,7 @@ import { Collection, CollectionPage, type CryptographicKey, + FeatureRequest, getTypeId, isActor, Link, @@ -67,6 +68,10 @@ import { wrapContextLoaderForJsonLd, } from "../sig/ld.ts"; import { doesActorOwnKey } from "../sig/owner.ts"; +import { + hasFeatureRequestSignature, + resolveFeatureRequestActor, +} from "./feature-request.ts"; import { getCanonicalPortableId, getPortableDid, @@ -2246,6 +2251,62 @@ async function handleInboxInternal( proofVerified = true; } } + // TODO: Replace this type-specific authentication branch with shared inbox + // principals: https://github.com/fedify-dev/fedify/issues/1290 + let featureRequestActor: URL | undefined; + if (activity instanceof FeatureRequest && activity.actorIds.length === 0) { + featureRequestActor = await resolveFeatureRequestActor(ctx, activity) ?? + undefined; + if (featureRequestActor == null) { + return await respondInvalidActivity( + new TypeError( + "FeatureRequest must reference a collection with one owner.", + ), + ); + } + if ( + ldSigVerified && !skipSignatureVerification && + (isPortableActivity(activity.clone({ actor: featureRequestActor })) || + !hasFeatureRequestSignature( + observation.verification.attempts, + featureRequestActor, + "linkedData", + )) + ) { + // A relay's LDS must not hide the owner's proof, and the missing actor + // must not bypass the portable proof policy. Verify the original + // document, never the inferred clone. + try { + await verifyObject(Activity, jsonWithoutSig, { + ...signatureObservation, + contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), + documentLoader: ctx.documentLoader, + keyCache, + meterProvider, + tracerProvider, + verifyPortableObject: ctx.verifyPortableObject, + }); + } catch (error) { + if (!isPermanentActivityParseError(error)) throw error; + return await respondInvalidActivity(error); + } + } + proofVerified = hasFeatureRequestSignature( + observation.verification.attempts, + featureRequestActor, + "objectIntegrity", + ); + activityVerified = proofVerified || hasFeatureRequestSignature( + observation.verification.attempts, + featureRequestActor, + "linkedData", + ); + if (!activityVerified && !skipSignatureVerification) { + // verifyObject() can return an actorless object with zero proofs. A + // successful parse is not authentication: fall back to HTTP signatures. + activity = null; + } + } let httpSigKey: CryptographicKey | null = null; // Nonce verification is deferred until after actor/key ownership is checked // to avoid consuming nonces on requests that will be rejected anyway. @@ -2421,6 +2482,22 @@ async function handleInboxInternal( return await respondInvalidActivity(error); } } + if (activity instanceof FeatureRequest && activity.actorIds.length === 0) { + featureRequestActor ??= await resolveFeatureRequestActor(ctx, activity) ?? + undefined; + if (featureRequestActor == null) { + return await respondInvalidActivity( + new TypeError( + "FeatureRequest must reference a collection with one owner.", + ), + ); + } + // This is an internal view for actor/key checks and listener helpers. + // Raw and normalized signed payloads remain untouched for forwarding. + // TODO: Stop synthesizing actor once listeners can access authenticated + // principals: https://github.com/fedify-dev/fedify/issues/1290 + activity = activity.clone({ actor: featureRequestActor }); + } observation.activity = activity; observation.stage = "policy"; observation.result = { disposition: "rejected", reason: "authentication" }; @@ -2723,6 +2800,7 @@ async function handleInboxInternal( ? compactedJson : undefined, ldSignatureVerified: hasLdSignature ? ldSigVerified : undefined, + featureRequestActor: featureRequestActor?.href, activity, recipient, inboxListeners, diff --git a/packages/fedify/src/federation/inbox.ts b/packages/fedify/src/federation/inbox.ts index 7c2f1b048..58d072247 100644 --- a/packages/fedify/src/federation/inbox.ts +++ b/packages/fedify/src/federation/inbox.ts @@ -59,6 +59,10 @@ export interface RouteActivityParameters { * @internal */ ldSignatureVerified?: boolean; + // TODO: Replace this field with queue metadata for authenticated principals. + // https://github.com/fedify-dev/fedify/issues/1290 + /** The actor authenticated for an actorless FeatureRequest. @internal */ + featureRequestActor?: string; activity: Activity; recipient: string | null; inboxListeners?: ActivityListenerSet>; @@ -110,6 +114,7 @@ export async function routeActivity( originalJson, normalizedActivity, ldSignatureVerified, + featureRequestActor, activity, recipient, inboxListeners, @@ -218,6 +223,7 @@ export async function routeActivity( // while preserving the original payload for forwarding. ...(normalizedActivity == null ? {} : { normalizedActivity }), ...(ldSignatureVerified == null ? {} : { ldSignatureVerified }), + ...(featureRequestActor == null ? {} : { featureRequestActor }), identifier: recipient, attempt: 0, started: new Date().toISOString(), diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 414c0c3a0..05c386ab5 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -12,6 +12,7 @@ import type { import { Activity, CryptographicKey, + FeatureRequest, getTypeId, lookupObject, Multikey, @@ -2563,6 +2564,17 @@ export class FederationImpl parseInput, parseContext, ); + // TODO: Replace inferred actor restoration with shared principal replay. + // https://github.com/fedify-dev/fedify/issues/1290 + if ( + activity instanceof FeatureRequest && + activity.actorIds.length === 0 && + message.featureRequestActor != null + ) { + activity = activity.clone({ + actor: new URL(message.featureRequestActor), + }); + } activityType = getTypeId(activity).href; span.setAttribute("activitypub.activity.type", activityType); listenerSpan.setAttribute("activitypub.activity.type", activityType); diff --git a/packages/fedify/src/federation/queue.ts b/packages/fedify/src/federation/queue.ts index 77b08365e..8228a9bd2 100644 --- a/packages/fedify/src/federation/queue.ts +++ b/packages/fedify/src/federation/queue.ts @@ -171,6 +171,15 @@ export interface InboxMessage { * @internal */ readonly ldSignatureVerified?: boolean; + // TODO: Replace this field with queue metadata for authenticated principals. + // https://github.com/fedify-dev/fedify/issues/1290 + /** + * The collection owner authenticated by the producer for an actorless + * FeatureRequest. Workers restore only the parsed actor, preserving the + * original signed payload. Queue backends are trusted like the payload. + * @internal + */ + readonly featureRequestActor?: string; readonly started: string; readonly attempt: number; readonly identifier: string | null; From 0f477c5d974b0faf19129e4e338b521ff93c62ed Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 16:47:15 +0900 Subject: [PATCH 02/24] Recheck collection owner after HTTP fallback Discard the inferred FeatureRequest owner when HTTP authentication reparses the activity. A changing context loader can select another instrument, so resolve the final collection owner before checking the HTTP signing key. Cover this mismatch with a regression test. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4236845678 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 51 ++++++++++++++++++- packages/fedify/src/federation/handler.ts | 5 ++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index a2d1d50c0..ffd065cf7 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -110,6 +110,7 @@ async function deliver( request?: Activity; queued?: boolean; skip?: boolean; + reparsedInstrument?: URL; } = {}, ) { const collection = options.collection ?? new FeaturedCollection({ @@ -122,8 +123,37 @@ async function deliver( instrument: collectionId, }); let collectionAvailable = true; + let collectionLookedUp = false; + const contextUrl = "https://example.com/changing-context"; + const contextLoader = async (url: string) => { + if (url === contextUrl) { + return { + document: { + "@context": { + featured: collectionLookedUp + ? new URL(".", options.reparsedInstrument!).href + : new URL(".", collectionId).href, + }, + }, + documentUrl: url, + contextUrl: null, + }; + } + return await mockDocumentLoader(url); + }; const documentLoader = async (url: string) => { + if (url === options.reparsedInstrument?.href) { + return { + document: await new FeaturedCollection({ + id: options.reparsedInstrument, + attribution: new URL("https://example.com/person3"), + }).toJsonLd(), + documentUrl: url, + contextUrl: null, + }; + } if (url === collectionId.href) { + collectionLookedUp = true; if (!collectionAvailable) throw new Error("Collection unavailable"); return { document: await collection.toJsonLd(), @@ -173,6 +203,15 @@ async function deliver( }, )).toJsonLd() : await activity.toJsonLd(); + if (options.reparsedInstrument != null) { + const document = body as Record; + document["@context"] = [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/fep/7aa9", + contextUrl, + ]; + document.instrument = "featured:1"; + } let request = new Request("https://receiver.example/inbox", { method: "POST", body: JSON.stringify(body), @@ -196,7 +235,7 @@ async function deliver( kv, queue, documentLoaderFactory: () => documentLoader, - contextLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => contextLoader, }); const received: Activity[] = []; const originals: unknown[] = []; @@ -220,7 +259,7 @@ async function deliver( url: new URL(request.url), data: undefined, documentLoader, - contextLoader: mockDocumentLoader, + contextLoader, }); const listeners = new ActivityListenerSet>(); listeners.add(FeatureRequest, listener); @@ -267,6 +306,14 @@ async function deliver( }; } +test("actorless FeatureRequest binds HTTP signer to the reparsed collection owner", async () => { + const result = await deliver("http", { + reparsedInstrument: new URL("https://example.com/other-featured/1"), + }); + assertEquals(result.response.status, 401); + assertEquals(result.received, []); +}); + for (const authentication of ["http", "ld", "proof", "mixed"] as const) { test(`actorless FeatureRequest accepts ${authentication} authentication`, async () => { const result = await deliver(authentication); diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index b34e9ef7f..1e06f8c0b 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -2481,6 +2481,11 @@ async function handleInboxInternal( if (!isPermanentActivityParseError(error)) throw error; return await respondInvalidActivity(error); } + // A context loader may change the instrument during this second parse. + // Resolve its owner again before checking the HTTP signature's key. + // TODO: Remove this invalidation with shared inbox principals: + // https://github.com/fedify-dev/fedify/issues/1290 + featureRequestActor = undefined; } if (activity instanceof FeatureRequest && activity.actorIds.length === 0) { featureRequestActor ??= await resolveFeatureRequestActor(ctx, activity) ?? From a5b2a6d2f8d2e75ab30cb0c42bae360a879feb0d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 16:50:19 +0900 Subject: [PATCH 03/24] Keep transient collection failures retryable Retain retryable loader failures across WebFinger fallback attempts. Only discard them after finding a FeaturedCollection with the requested ID and one owner, so later permanent errors or invalid objects cannot hide a temporary outage. Cover failed fallbacks and valid recovery. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4236845680 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 88 +++++++++++++++++++ .../fedify/src/federation/feature-request.ts | 27 +++--- 2 files changed, 102 insertions(+), 13 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index ffd065cf7..deeb6be3f 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -101,6 +101,94 @@ test("actorless FeatureRequest preserves collection DNS failures for retry", asy ); }); +async function lookupFallbackCollection( + candidate: FeaturedCollection | Person | Error, + outage: Error, +): Promise { + const fallbackUrl = "https://example.com/fallback-collection"; + const context = createRequestContext({ + federation: createFederation({ kv: new MemoryKvStore() }), + url: target, + data: undefined, + documentLoader: async (url) => { + if (url === collectionId.href) throw outage; + assertEquals(url, fallbackUrl); + if (candidate instanceof Error) throw candidate; + return { + document: await candidate.toJsonLd(), + documentUrl: url, + contextUrl: null, + }; + }, + contextLoader: mockDocumentLoader, + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = () => + Promise.resolve(Response.json({ + subject: collectionId.href, + links: [{ + rel: "self", + type: "application/activity+json", + href: fallbackUrl, + }], + })); + try { + return await resolveFeatureRequestActor( + context, + new FeatureRequest({ instrument: collectionId }), + ); + } finally { + globalThis.fetch = originalFetch; + } +} + +for ( + const [label, candidate] of [ + [ + "permanent HTTP failure", + new FetchError( + collectionId, + "HTTP 404", + new Response(null, { status: 404 }), + ), + ], + ["wrong type", new Person({ id: collectionId })], + [ + "wrong ID", + new FeaturedCollection({ + id: new URL("https://example.com/wrong"), + attribution: owner, + }), + ], + ["missing owner", new FeaturedCollection({ id: collectionId })], + [ + "multiple owners", + new FeaturedCollection({ + id: collectionId, + attributions: [owner, target], + }), + ], + ] as const +) { + test(`actorless FeatureRequest preserves an outage after fallback ${label}`, async () => { + const outage = new Error("Collection temporarily unavailable"); + assertEquals( + await assertRejects(() => lookupFallbackCollection(candidate, outage)), + outage, + ); + }); +} + +test("actorless FeatureRequest accepts a valid fallback despite an earlier outage", async () => { + assertEquals( + await lookupFallbackCollection( + new FeaturedCollection({ id: collectionId, attribution: owner }), + new Error("Collection temporarily unavailable"), + ), + owner, + ); +}); + type Authentication = "http" | "ld" | "proof" | "mixed" | "unsigned"; async function deliver( diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index c58683e61..75d78cb9f 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -22,14 +22,21 @@ export async function resolveFeatureRequestActor( const id = request.instrumentIds[0]; // Never infer ownership from an instrument embedded by the sender. // lookupObject() may hide loader failures while trying other locations. - // Preserve them if lookup fails so temporary outages remain retryable. - let loadFailure: { error: unknown } | undefined; + // Preserve retryable failures until a valid collection has been found. + let retryableFailure: { error: unknown } | undefined; const captureFailure = (loader: DocumentLoader): DocumentLoader => async (...args) => { try { return await loader(...args); } catch (error) { - loadFailure = { error }; + const status = error instanceof FetchError + ? error.response?.status + : null; + const permanent = + error instanceof UrlError && error.reason === "disallowed" || + status != null && status >= 400 && status < 500 && + status !== 408 && status !== 429; + if (!permanent) retryableFailure ??= { error }; throw error; } }; @@ -37,19 +44,13 @@ export async function resolveFeatureRequestActor( documentLoader: captureFailure(context.documentLoader), contextLoader: captureFailure(context.contextLoader), }); - if (collection == null && loadFailure != null) { - const error = loadFailure.error; - const status = error instanceof FetchError ? error.response?.status : null; - const permanent = - error instanceof UrlError && error.reason === "disallowed" || - status != null && status >= 400 && status < 500 && - status !== 408 && status !== 429; - if (!permanent) throw error; - } if ( !(collection instanceof FeaturedCollection) || collection.id == null || !isSameObjectId(collection.id, id) || collection.attributionIds.length !== 1 - ) return null; + ) { + if (retryableFailure != null) throw retryableFailure.error; + return null; + } return collection.attributionIds[0]; } From 9b84ddaa645527412305378a1003024d27b3ca8a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 17:11:15 +0900 Subject: [PATCH 04/24] Bind queued owners to authenticated instruments Store the collection ID alongside the inferred FeatureRequest owner. Reject replay when parsing changes the instrument, actor presence or activity type, rather than delivering an owner authenticated for a different collection. Preserve the original payload and queue retries. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4236971474 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 32 +++++++++++++++---- packages/fedify/src/federation/handler.ts | 3 ++ packages/fedify/src/federation/inbox.ts | 8 ++++- packages/fedify/src/federation/middleware.ts | 16 +++++++--- packages/fedify/src/federation/queue.ts | 8 +++++ 5 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index deeb6be3f..29a2302d4 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -199,6 +199,7 @@ async function deliver( queued?: boolean; skip?: boolean; reparsedInstrument?: URL; + replayedInstrument?: URL; } = {}, ) { const collection = options.collection ?? new FeaturedCollection({ @@ -218,9 +219,14 @@ async function deliver( return { document: { "@context": { - featured: collectionLookedUp - ? new URL(".", options.reparsedInstrument!).href - : new URL(".", collectionId).href, + featured: new URL( + ".", + !collectionAvailable && options.replayedInstrument != null + ? options.replayedInstrument + : collectionLookedUp && options.reparsedInstrument != null + ? options.reparsedInstrument + : collectionId, + ).href, }, }, documentUrl: url, @@ -291,11 +297,14 @@ async function deliver( }, )).toJsonLd() : await activity.toJsonLd(); - if (options.reparsedInstrument != null) { + if ( + options.reparsedInstrument != null || options.replayedInstrument != null + ) { const document = body as Record; document["@context"] = [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/fep/7aa9", + ...(Array.isArray(document["@context"]) + ? document["@context"] + : [document["@context"]]), contextUrl, ]; document.instrument = "featured:1"; @@ -402,6 +411,17 @@ test("actorless FeatureRequest binds HTTP signer to the reparsed collection owne assertEquals(result.received, []); }); +test("actorless FeatureRequest rejects an instrument changed by queue contexts", async () => { + const result = await deliver("http", { + queued: true, + replayedInstrument: new URL("https://example.com/other-featured/1"), + }); + assertEquals(result.response.status, 202); + await result.replay(); + assertEquals(result.received, []); + assertEquals(result.messages[0].activity, result.body); +}); + for (const authentication of ["http", "ld", "proof", "mixed"] as const) { test(`actorless FeatureRequest accepts ${authentication} authentication`, async () => { const result = await deliver(authentication); diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 1e06f8c0b..5e2f07c5e 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -2806,6 +2806,9 @@ async function handleInboxInternal( : undefined, ldSignatureVerified: hasLdSignature ? ldSigVerified : undefined, featureRequestActor: featureRequestActor?.href, + featureRequestInstrument: featureRequestActor == null + ? undefined + : activity.instrumentId?.href, activity, recipient, inboxListeners, diff --git a/packages/fedify/src/federation/inbox.ts b/packages/fedify/src/federation/inbox.ts index 58d072247..4faef2dc1 100644 --- a/packages/fedify/src/federation/inbox.ts +++ b/packages/fedify/src/federation/inbox.ts @@ -63,6 +63,8 @@ export interface RouteActivityParameters { // https://github.com/fedify-dev/fedify/issues/1290 /** The actor authenticated for an actorless FeatureRequest. @internal */ featureRequestActor?: string; + /** The instrument authenticated with the inferred actor. @internal */ + featureRequestInstrument?: string; activity: Activity; recipient: string | null; inboxListeners?: ActivityListenerSet>; @@ -115,6 +117,7 @@ export async function routeActivity( normalizedActivity, ldSignatureVerified, featureRequestActor, + featureRequestInstrument, activity, recipient, inboxListeners, @@ -223,7 +226,10 @@ export async function routeActivity( // while preserving the original payload for forwarding. ...(normalizedActivity == null ? {} : { normalizedActivity }), ...(ldSignatureVerified == null ? {} : { ldSignatureVerified }), - ...(featureRequestActor == null ? {} : { featureRequestActor }), + ...(featureRequestActor == null ? {} : { + featureRequestActor, + featureRequestInstrument, + }), identifier: recipient, attempt: 0, started: new Date().toISOString(), diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 05c386ab5..9a4e3a14a 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -2566,11 +2566,17 @@ export class FederationImpl ); // TODO: Replace inferred actor restoration with shared principal replay. // https://github.com/fedify-dev/fedify/issues/1290 - if ( - activity instanceof FeatureRequest && - activity.actorIds.length === 0 && - message.featureRequestActor != null - ) { + if (message.featureRequestActor != null) { + if ( + !(activity instanceof FeatureRequest) || + activity.actorIds.length !== 0 || + activity.instrumentIds.length !== 1 || + activity.instrumentId?.href !== message.featureRequestInstrument + ) { + throw new TypeError( + "FeatureRequest instrument changed after authentication.", + ); + } activity = activity.clone({ actor: new URL(message.featureRequestActor), }); diff --git a/packages/fedify/src/federation/queue.ts b/packages/fedify/src/federation/queue.ts index 8228a9bd2..c9849052a 100644 --- a/packages/fedify/src/federation/queue.ts +++ b/packages/fedify/src/federation/queue.ts @@ -180,6 +180,14 @@ export interface InboxMessage { * @internal */ readonly featureRequestActor?: string; + /** + * The instrument authenticated with the inferred FeatureRequest owner. + * Workers must match it before restoring that owner after parsing. + * @internal + */ + // TODO: Remove this binding with shared principal replay: + // https://github.com/fedify-dev/fedify/issues/1290 + readonly featureRequestInstrument?: string; readonly started: string; readonly attempt: number; readonly identifier: string | null; From ebb78d5f5950b9f51a507e1a2ca105fc43587337 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 17:14:59 +0900 Subject: [PATCH 05/24] Authenticate before resolving collection owners A parsed actorless FeatureRequest with no proofs is not authenticated. Require a verified document signature before resolving its owner, or fall back to HTTP authentication first. Keep explicit verification bypass behavior and cover unsigned requests with a no-lookup test. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4236971480 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 18 +++++++++++++++--- packages/fedify/src/federation/handler.ts | 19 ++++++++++++++++++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index 29a2302d4..8f6ba895f 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -200,6 +200,7 @@ async function deliver( skip?: boolean; reparsedInstrument?: URL; replayedInstrument?: URL; + onCollectionLookup?: () => void; } = {}, ) { const collection = options.collection ?? new FeaturedCollection({ @@ -212,10 +213,11 @@ async function deliver( instrument: collectionId, }); let collectionAvailable = true; - let collectionLookedUp = false; + let contextLoads = 0; const contextUrl = "https://example.com/changing-context"; const contextLoader = async (url: string) => { if (url === contextUrl) { + contextLoads++; return { document: { "@context": { @@ -223,7 +225,7 @@ async function deliver( ".", !collectionAvailable && options.replayedInstrument != null ? options.replayedInstrument - : collectionLookedUp && options.reparsedInstrument != null + : contextLoads > 1 && options.reparsedInstrument != null ? options.reparsedInstrument : collectionId, ).href, @@ -247,7 +249,7 @@ async function deliver( }; } if (url === collectionId.href) { - collectionLookedUp = true; + options.onCollectionLookup?.(); if (!collectionAvailable) throw new Error("Collection unavailable"); return { document: await collection.toJsonLd(), @@ -478,6 +480,16 @@ for (const authentication of ["http", "ld", "proof", "mixed"] as const) { }); } +test("actorless FeatureRequest does not fetch a collection before authentication", async () => { + let lookups = 0; + const result = await deliver("unsigned", { + onCollectionLookup: () => lookups++, + }); + assertEquals(result.response.status, 401); + assertEquals(lookups, 0); + assertEquals(result.received, []); +}); + test("actorless FeatureRequest requires an actual signature, not an empty proof set", async () => { const result = await deliver("unsigned"); assertEquals(result.response.status, 401); diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 5e2f07c5e..227d02bec 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -2254,7 +2254,16 @@ async function handleInboxInternal( // TODO: Replace this type-specific authentication branch with shared inbox // principals: https://github.com/fedify-dev/fedify/issues/1290 let featureRequestActor: URL | undefined; - if (activity instanceof FeatureRequest && activity.actorIds.length === 0) { + if ( + activity instanceof FeatureRequest && activity.actorIds.length === 0 && + (skipSignatureVerification || observation.verification.attempts.some( + (attempt) => + attempt.subject.pointer === "" && attempt.status === "verified" && + (attempt.mechanism === "linkedData" || + attempt.mechanism === "objectIntegrity") && + attempt.signatures.length > 0, + )) + ) { featureRequestActor = await resolveFeatureRequestActor(ctx, activity) ?? undefined; if (featureRequestActor == null) { @@ -2306,6 +2315,14 @@ async function handleInboxInternal( // successful parse is not authentication: fall back to HTTP signatures. activity = null; } + } else if ( + activity instanceof FeatureRequest && activity.actorIds.length === 0 + ) { + // An empty proof set only parses the document. Authenticate the HTTP + // request before dereferencing its instrument. + activity = null; + activityVerified = false; + proofVerified = false; } let httpSigKey: CryptographicKey | null = null; // Nonce verification is deferred until after actor/key ownership is checked From 8730d0ee6337e045566f999562401f509a6617c0 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 17:18:57 +0900 Subject: [PATCH 06/24] Dereference collections without account discovery Resolve web collection IDs through the document loader directly. WebFinger's best-effort account discovery hides failures and should not decide collection authorization. Classify the authoritative fetch failure instead, while retaining verified lookup for portable IDs. Replace account-fallback tests with checks that a collection 404 does not trigger WebFinger even when that service is temporarily unavailable. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4236971483 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 117 +++++------------- .../fedify/src/federation/feature-request.ts | 42 +++++-- 2 files changed, 68 insertions(+), 91 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index 8f6ba895f..edb0a5bde 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -101,94 +101,43 @@ test("actorless FeatureRequest preserves collection DNS failures for retry", asy ); }); -async function lookupFallbackCollection( - candidate: FeaturedCollection | Person | Error, - outage: Error, -): Promise { - const fallbackUrl = "https://example.com/fallback-collection"; - const context = createRequestContext({ - federation: createFederation({ kv: new MemoryKvStore() }), - url: target, - data: undefined, - documentLoader: async (url) => { - if (url === collectionId.href) throw outage; - assertEquals(url, fallbackUrl); - if (candidate instanceof Error) throw candidate; - return { - document: await candidate.toJsonLd(), - documentUrl: url, - contextUrl: null, - }; - }, - contextLoader: mockDocumentLoader, - }); - const originalFetch = globalThis.fetch; - globalThis.fetch = () => - Promise.resolve(Response.json({ - subject: collectionId.href, - links: [{ - rel: "self", - type: "application/activity+json", - href: fallbackUrl, - }], - })); - try { - return await resolveFeatureRequestActor( - context, - new FeatureRequest({ instrument: collectionId }), - ); - } finally { - globalThis.fetch = originalFetch; - } -} - -for ( - const [label, candidate] of [ - [ - "permanent HTTP failure", - new FetchError( - collectionId, - "HTTP 404", - new Response(null, { status: 404 }), - ), - ], - ["wrong type", new Person({ id: collectionId })], - [ - "wrong ID", - new FeaturedCollection({ - id: new URL("https://example.com/wrong"), - attribution: owner, - }), - ], - ["missing owner", new FeaturedCollection({ id: collectionId })], - [ - "multiple owners", - new FeaturedCollection({ - id: collectionId, - attributions: [owner, target], - }), - ], - ] as const -) { - test(`actorless FeatureRequest preserves an outage after fallback ${label}`, async () => { - const outage = new Error("Collection temporarily unavailable"); - assertEquals( - await assertRejects(() => lookupFallbackCollection(candidate, outage)), - outage, +for (const failure of ["503", "network"] as const) { + test(`actorless FeatureRequest does not use WebFinger after a collection 404 (${failure})`, async () => { + const error = new FetchError( + collectionId, + "HTTP 404", + new Response(null, { status: 404 }), ); + const context = createRequestContext({ + federation: createFederation({ kv: new MemoryKvStore() }), + url: target, + data: undefined, + documentLoader: () => Promise.reject(error), + contextLoader: mockDocumentLoader, + }); + let fetches = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = () => { + fetches++; + return failure === "503" + ? Promise.resolve(new Response(null, { status: 503 })) + : Promise.reject(new Error("WebFinger unavailable")); + }; + try { + assertEquals( + await resolveFeatureRequestActor( + context, + new FeatureRequest({ instrument: collectionId }), + ), + null, + ); + assertEquals(fetches, 0); + } finally { + globalThis.fetch = originalFetch; + } }); } -test("actorless FeatureRequest accepts a valid fallback despite an earlier outage", async () => { - assertEquals( - await lookupFallbackCollection( - new FeaturedCollection({ id: collectionId, attribution: owner }), - new Error("Collection temporarily unavailable"), - ), - owner, - ); -}); - type Authentication = "http" | "ld" | "proof" | "mixed" | "unsigned"; async function deliver( diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index 75d78cb9f..777899f9b 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -1,5 +1,9 @@ import type { Context } from "./context.ts"; -import { FeaturedCollection, type FeatureRequest } from "@fedify/vocab"; +import { + FeaturedCollection, + type FeatureRequest, + Object as ActivityObject, +} from "@fedify/vocab"; import { type DocumentLoader, FetchError, @@ -21,8 +25,9 @@ export async function resolveFeatureRequestActor( if (request.instrumentIds.length !== 1) return null; const id = request.instrumentIds[0]; // Never infer ownership from an instrument embedded by the sender. - // lookupObject() may hide loader failures while trying other locations. - // Preserve retryable failures until a valid collection has been found. + // Collection authorization uses the referenced resource, not account + // discovery through WebFinger. Portable IDs retain their verified lookup. + // Preserve loader failures that parsing or portable lookup may hide. let retryableFailure: { error: unknown } | undefined; const captureFailure = (loader: DocumentLoader): DocumentLoader => async (...args) => { @@ -40,10 +45,33 @@ export async function resolveFeatureRequestActor( throw error; } }; - const collection = await context.lookupObject(id, { - documentLoader: captureFailure(context.documentLoader), - contextLoader: captureFailure(context.contextLoader), - }); + let collection: ActivityObject | null; + try { + if (isPortableId(id)) { + collection = await context.lookupObject(id, { + documentLoader: captureFailure(context.documentLoader), + contextLoader: captureFailure(context.contextLoader), + }); + } else { + const document = await captureFailure(context.documentLoader)(id.href); + collection = await ActivityObject.fromJsonLd(document.document, { + ...context, + documentLoader: captureFailure(context.documentLoader), + contextLoader: captureFailure(context.contextLoader), + baseUrl: new URL(document.documentUrl), + }); + } + } catch (error) { + if (retryableFailure != null) throw retryableFailure.error; + const status = error instanceof FetchError ? error.response?.status : null; + if ( + error instanceof TypeError || + error instanceof UrlError && error.reason === "disallowed" || + status != null && status >= 400 && status < 500 && + status !== 408 && status !== 429 + ) return null; + throw error; + } if ( !(collection instanceof FeaturedCollection) || collection.id == null || !isSameObjectId(collection.id, id) || collection.attributionIds.length !== 1 From 09f7f2928b59bea816f792dcbc612de7c7ad36f2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 19:06:11 +0900 Subject: [PATCH 07/24] Check collection origin after redirects Reject collection documents whose final URL has a different origin from the referenced web ID, before trusting their attribution. Direct dereferencing must preserve the authority check from lookupObject(). Cover cross-origin rejection and same-origin redirects. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237041628 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 18 +++++++++++++++++- .../fedify/src/federation/feature-request.ts | 6 +++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index edb0a5bde..53c89d350 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -144,6 +144,7 @@ async function deliver( authentication: Authentication, options: { collection?: FeaturedCollection | Person; + collectionDocumentUrl?: string; request?: Activity; queued?: boolean; skip?: boolean; @@ -202,7 +203,7 @@ async function deliver( if (!collectionAvailable) throw new Error("Collection unavailable"); return { document: await collection.toJsonLd(), - documentUrl: url, + documentUrl: options.collectionDocumentUrl ?? url, contextUrl: null, }; } @@ -362,6 +363,21 @@ test("actorless FeatureRequest binds HTTP signer to the reparsed collection owne assertEquals(result.received, []); }); +test("actorless FeatureRequest rejects cross-origin collection redirects", async () => { + const result = await deliver("http", { + collectionDocumentUrl: "https://attacker.example/forged-collection", + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); +}); + +test("actorless FeatureRequest accepts same-origin collection redirects", async () => { + const result = await deliver("http", { + collectionDocumentUrl: "https://example.com/redirected-collection", + }); + assertEquals(result.response.status, 202); +}); + test("actorless FeatureRequest rejects an instrument changed by queue contexts", async () => { const result = await deliver("http", { queued: true, diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index 777899f9b..cd2c110d4 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -54,11 +54,15 @@ export async function resolveFeatureRequestActor( }); } else { const document = await captureFailure(context.documentLoader)(id.href); + const documentUrl = new URL(document.documentUrl); + // A web resource cannot vouch for an ID on another origin, even when + // reached through a redirect from the requested collection URL. + if (documentUrl.origin !== id.origin) return null; collection = await ActivityObject.fromJsonLd(document.document, { ...context, documentLoader: captureFailure(context.documentLoader), contextLoader: captureFailure(context.contextLoader), - baseUrl: new URL(document.documentUrl), + baseUrl: documentUrl, }); } } catch (error) { From be7774b60b7eef31c4097b32700e6e24be68a02f Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 19:18:17 +0900 Subject: [PATCH 08/24] Freeze authenticated FeatureRequest queue views Replay the producer's parsed view under built-in contexts so remote context changes cannot alter fields after authentication. Keep the inferred owner separate and preserve the original signed payload. Replace the instrument-only queue binding with this snapshot. Cover HTTP and proof authentication with changing object and instrument contexts, retries, preserved proofs, and no custom-context refetches. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237041633 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 85 ++++++++++++++++--- packages/fedify/src/federation/handler.ts | 18 +++- packages/fedify/src/federation/inbox.ts | 8 +- packages/fedify/src/federation/middleware.ts | 50 +++++------ packages/fedify/src/federation/queue.ts | 13 ++- 5 files changed, 117 insertions(+), 57 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index 53c89d350..7c31e2fae 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -145,6 +145,7 @@ async function deliver( options: { collection?: FeaturedCollection | Person; collectionDocumentUrl?: string; + replayedObject?: URL; request?: Activity; queued?: boolean; skip?: boolean; @@ -164,13 +165,21 @@ async function deliver( }); let collectionAvailable = true; let contextLoads = 0; + let replayContextLoads = 0; const contextUrl = "https://example.com/changing-context"; const contextLoader = async (url: string) => { if (url === contextUrl) { contextLoads++; + if (!collectionAvailable) replayContextLoads++; return { document: { "@context": { + receiver: new URL( + ".", + !collectionAvailable && options.replayedObject != null + ? options.replayedObject + : target, + ).href, featured: new URL( ".", !collectionAvailable && options.replayedInstrument != null @@ -245,12 +254,35 @@ async function deliver( ed25519PrivateKey, ed25519Multikey.id!, { - contextLoader: mockDocumentLoader, + contextLoader, + context: + options.replayedInstrument != null || options.replayedObject != null + ? [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", + "https://w3id.org/fep/7aa9", + contextUrl, + ] + : undefined, }, - )).toJsonLd() + )).toJsonLd({ + format: "compact", + contextLoader, + context: + options.replayedInstrument != null || options.replayedObject != null + ? [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", + "https://w3id.org/fep/7aa9", + contextUrl, + ] + : undefined, + }) : await activity.toJsonLd(); if ( - options.reparsedInstrument != null || options.replayedInstrument != null + authentication !== "proof" && + (options.reparsedInstrument != null || options.replayedInstrument != null || + options.replayedObject != null) ) { const document = body as Record; document["@context"] = [ @@ -260,6 +292,7 @@ async function deliver( contextUrl, ]; document.instrument = "featured:1"; + if (options.replayedObject != null) document.object = "receiver:bob"; } let request = new Request("https://receiver.example/inbox", { method: "POST", @@ -343,6 +376,9 @@ async function deliver( originals, body, messages, + get replayContextLoads() { + return replayContextLoads; + }, async replay(retry = false) { collectionAvailable = false; failOnce = retry; @@ -378,16 +414,39 @@ test("actorless FeatureRequest accepts same-origin collection redirects", async assertEquals(result.response.status, 202); }); -test("actorless FeatureRequest rejects an instrument changed by queue contexts", async () => { - const result = await deliver("http", { - queued: true, - replayedInstrument: new URL("https://example.com/other-featured/1"), - }); - assertEquals(result.response.status, 202); - await result.replay(); - assertEquals(result.received, []); - assertEquals(result.messages[0].activity, result.body); -}); +for (const authentication of ["http", "proof"] as const) { + for (const field of ["instrument", "object"] as const) { + test(`actorless FeatureRequest freezes ${authentication} ${field} across changing queue contexts`, async () => { + const result = await deliver(authentication, { + queued: true, + ...(field === "instrument" + ? { + replayedInstrument: new URL("https://example.com/other-featured/1"), + } + : { replayedObject: new URL("https://attacker.example/users/bob") }), + }); + assertEquals(result.response.status, 202); + await result.replay(true); + assertEquals(result.received.map((activity) => activity.instrumentId), [ + collectionId, + collectionId, + ]); + assertEquals(result.received.map((activity) => activity.objectId), [ + target, + target, + ]); + assertEquals(result.received.map((activity) => activity.actorId), [ + owner, + owner, + ]); + assertEquals(result.messages[1].activity, result.body); + assertEquals(result.replayContextLoads, 0); + if (authentication === "proof") { + assert(await result.received[0].getProof() != null); + } + }); + } +} for (const authentication of ["http", "ld", "proof", "mixed"] as const) { test(`actorless FeatureRequest accepts ${authentication} authentication`, async () => { diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 227d02bec..e4b5a56d6 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -2799,6 +2799,17 @@ async function handleInboxInternal( ), }; observation.stage = "dispatch"; + // Freeze the parsed view authenticated above, rather than resolving remote + // contexts again in the worker. The inferred actor is not part of this + // replay cache; the original signed document is kept separately. + // TODO: Replace this snapshot with shared principal replay: + // https://github.com/fedify-dev/fedify/issues/1290 + const featureRequestSnapshot = featureRequestActor == null || queue == null + ? undefined + : await compactJsonLd( + await activity.clone({ actors: [] }).toJsonLd({ format: "compact" }), + undefined, + ); const routeResult = await routeActivity({ context: ctx, // Direct handleInbox() consumers may later forward the payload from the @@ -2818,14 +2829,13 @@ async function handleInboxInternal( // backlog messages from older producers can still depend on it, while the // raw payload stays preserved separately for forwarding and low-level // hooks. - normalizedActivity: hasLdSignature && compactedJson !== json + normalizedActivity: featureRequestSnapshot != null + ? featureRequestSnapshot + : hasLdSignature && compactedJson !== json ? compactedJson : undefined, ldSignatureVerified: hasLdSignature ? ldSigVerified : undefined, featureRequestActor: featureRequestActor?.href, - featureRequestInstrument: featureRequestActor == null - ? undefined - : activity.instrumentId?.href, activity, recipient, inboxListeners, diff --git a/packages/fedify/src/federation/inbox.ts b/packages/fedify/src/federation/inbox.ts index 4faef2dc1..58d072247 100644 --- a/packages/fedify/src/federation/inbox.ts +++ b/packages/fedify/src/federation/inbox.ts @@ -63,8 +63,6 @@ export interface RouteActivityParameters { // https://github.com/fedify-dev/fedify/issues/1290 /** The actor authenticated for an actorless FeatureRequest. @internal */ featureRequestActor?: string; - /** The instrument authenticated with the inferred actor. @internal */ - featureRequestInstrument?: string; activity: Activity; recipient: string | null; inboxListeners?: ActivityListenerSet>; @@ -117,7 +115,6 @@ export async function routeActivity( normalizedActivity, ldSignatureVerified, featureRequestActor, - featureRequestInstrument, activity, recipient, inboxListeners, @@ -226,10 +223,7 @@ export async function routeActivity( // while preserving the original payload for forwarding. ...(normalizedActivity == null ? {} : { normalizedActivity }), ...(ldSignatureVerified == null ? {} : { ldSignatureVerified }), - ...(featureRequestActor == null ? {} : { - featureRequestActor, - featureRequestInstrument, - }), + ...(featureRequestActor == null ? {} : { featureRequestActor }), identifier: recipient, attempt: 0, started: new Date().toISOString(), diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 9a4e3a14a..71ef9c882 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -2522,28 +2522,29 @@ export class FederationImpl message.ldSignatureVerified === true || message.normalizedActivity != null || (message.ldSignatureVerified == null && hasSignatureField); - const parseContext = hasSignatureField - ? { - ...context, - // Verified LDS replay, fallback-authenticated queue items with a - // producer-side normalized cache, and legacy queued LDS - // messages may still reference Fedify's built-in signature - // contexts at the root after we detach the signature object, so - // keep the normalization loader shortcut available whenever a - // signature block remains. Authentication provenance lives in - // ldSignatureVerified; normalizedActivity is a separate parse - // cache that rolling upgrades and stricter worker loaders may - // still depend on. - contextLoader: getNormalizationContextLoader( - context.contextLoader, - ), - } - : { - ...context, - contextLoader: wrapContextLoaderForJsonLd( - context.contextLoader, - ), - }; + const parseContext = + hasSignatureField || message.featureRequestActor != null + ? { + ...context, + // Verified LDS replay, fallback-authenticated queue items with a + // producer-side normalized cache, and legacy queued LDS + // messages may still reference Fedify's built-in signature + // contexts at the root after we detach the signature object, so + // use the fixed normalization contexts for signature blocks and + // producer-frozen FeatureRequest snapshots. Authentication provenance + // lives in ldSignatureVerified; normalizedActivity is a separate parse + // cache that rolling upgrades and stricter worker loaders may + // still depend on. + contextLoader: getNormalizationContextLoader( + context.contextLoader, + ), + } + : { + ...context, + contextLoader: wrapContextLoaderForJsonLd( + context.contextLoader, + ), + }; parseContextLoader = parseContext.contextLoader; let normalizedActivity: unknown | undefined; if (shouldParseFromNormalizedSignedPayload) { @@ -2570,11 +2571,10 @@ export class FederationImpl if ( !(activity instanceof FeatureRequest) || activity.actorIds.length !== 0 || - activity.instrumentIds.length !== 1 || - activity.instrumentId?.href !== message.featureRequestInstrument + message.normalizedActivity == null ) { throw new TypeError( - "FeatureRequest instrument changed after authentication.", + "FeatureRequest is missing its authenticated replay snapshot.", ); } activity = activity.clone({ diff --git a/packages/fedify/src/federation/queue.ts b/packages/fedify/src/federation/queue.ts index c9849052a..36821f488 100644 --- a/packages/fedify/src/federation/queue.ts +++ b/packages/fedify/src/federation/queue.ts @@ -155,6 +155,11 @@ export interface InboxMessage { * sidecar because generic queue backends do not provide reliable lifecycle * guarantees for auxiliary storage across retries and redeliveries. * + * For an actorless FeatureRequest, this is the producer's self-contained + * parsed view without the inferred actor. It freezes the fields + * used for authorization, while the original signed payload stays in + * {@link activity}. + * * @internal */ readonly normalizedActivity?: unknown; @@ -180,14 +185,6 @@ export interface InboxMessage { * @internal */ readonly featureRequestActor?: string; - /** - * The instrument authenticated with the inferred FeatureRequest owner. - * Workers must match it before restoring that owner after parsing. - * @internal - */ - // TODO: Remove this binding with shared principal replay: - // https://github.com/fedify-dev/fedify/issues/1290 - readonly featureRequestInstrument?: string; readonly started: string; readonly attempt: number; readonly identifier: string | null; From 2eb01c2943df278d9dcc76409eafb28b34d8b345 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 19:39:51 +0900 Subject: [PATCH 09/24] Reject malformed FeatureRequest collections Treat known parsing and validation failures from collection loaders as permanent invalid input, preserving that classification when JSON-LD wraps the original error. Malformed collection responses must return 400 rather than escape as retryable server errors. Keep network and unknown loader TypeErrors retryable, along with DNS failures, HTTP 408/429, and server failures. Cover malformed JSON, context definitions, and context URLs with standard loaders through HTTP and proof-authenticated inbox requests. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237320962 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 140 +++++++++++++++++- .../fedify/src/federation/feature-request.ts | 50 +++++-- 2 files changed, 176 insertions(+), 14 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index 7c31e2fae..7b4f9017a 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -6,7 +6,12 @@ import { FeatureRequest, Person, } from "@fedify/vocab"; -import { FetchError, UrlError } from "@fedify/vocab-runtime"; +import { + type DocumentLoader, + FetchError, + getDocumentLoader, + UrlError, +} from "@fedify/vocab-runtime"; import { assert, assertEquals, assertRejects } from "@std/assert"; import { signRequest } from "../sig/http.ts"; import { signJsonLd, verifyJsonLd } from "../sig/ld.ts"; @@ -145,6 +150,7 @@ async function deliver( options: { collection?: FeaturedCollection | Person; collectionDocumentUrl?: string; + collectionLoader?: DocumentLoader; replayedObject?: URL; request?: Activity; queued?: boolean; @@ -194,6 +200,13 @@ async function deliver( contextUrl: null, }; } + if ( + (url === "https://example.com/collection-context" || + url === "https://[invalid") && + options.collectionLoader != null + ) { + return await options.collectionLoader(url); + } return await mockDocumentLoader(url); }; const documentLoader = async (url: string) => { @@ -209,6 +222,9 @@ async function deliver( } if (url === collectionId.href) { options.onCollectionLookup?.(); + if (options.collectionLoader != null) { + return await options.collectionLoader(url); + } if (!collectionAvailable) throw new Error("Collection unavailable"); return { document: await collection.toJsonLd(), @@ -399,6 +415,128 @@ test("actorless FeatureRequest binds HTTP signer to the reparsed collection owne assertEquals(result.received, []); }); +for (const authentication of ["http", "proof"] as const) { + for ( + const malformed of [ + "collection JSON", + "context JSON", + "context definition", + "context URL", + ] as const + ) { + test(`actorless FeatureRequest rejects malformed ${malformed} with ${authentication} authentication`, async () => { + const document = await new FeaturedCollection({ + id: collectionId, + attribution: owner, + }).toJsonLd() as Record; + const contexts = Array.isArray(document["@context"]) + ? document["@context"] + : [document["@context"]]; + if (malformed === "context JSON") { + document["@context"] = [ + ...contexts, + "https://example.com/collection-context", + ]; + } else if (malformed === "context definition") { + document["@context"] = [...contexts, { broken: 42 }]; + } else if (malformed === "context URL") { + document["@context"] = [...contexts, "https://[invalid"]; + } + const originalFetch = globalThis.fetch; + globalThis.fetch = (input) => { + const url = input instanceof Request ? input.url : String(input); + return Promise.resolve( + new Response( + malformed === "collection JSON" || + url === "https://example.com/collection-context" + ? "{" + : JSON.stringify(document), + { headers: { "Content-Type": "application/activity+json" } }, + ), + ); + }; + try { + const result = await deliver(authentication, { + collectionLoader: getDocumentLoader({ allowPrivateAddress: true }), + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + } finally { + globalThis.fetch = originalFetch; + } + }); + } +} + +for ( + const error of [new TypeError("fetch failed"), new TypeError("loader bug")] +) { + test(`actorless FeatureRequest preserves collection TypeError for retry: ${error.message}`, async () => { + assertEquals( + await assertRejects(() => lookupFailingCollection(error)), + error, + ); + }); +} + +for ( + const error of [new TypeError("fetch failed"), new TypeError("loader bug")] +) { + test(`actorless FeatureRequest preserves context TypeError for retry: ${error.message}`, async () => { + const document = await new FeaturedCollection({ + id: collectionId, + attribution: owner, + }).toJsonLd() as Record; + const contexts = Array.isArray(document["@context"]) + ? document["@context"] + : [document["@context"]]; + document["@context"] = [ + ...contexts, + "https://example.com/collection-context", + ]; + assertEquals( + await assertRejects(() => + deliver("http", { + collectionLoader: (url) => + url === collectionId.href + ? Promise.resolve({ + document, + documentUrl: url, + contextUrl: null, + }) + : Promise.reject(error), + }) + ), + error, + ); + }); +} + +for ( + const error of [ + new TypeError("Invalid JSON-LD: null."), + new TypeError("Invalid URL"), + ] +) { + test(`actorless FeatureRequest rejects collection validation errors: ${error.message}`, async () => { + assertEquals(await lookupFailingCollection(error), null); + }); +} + +test("actorless FeatureRequest preserves wrapped remote context failures for retry", async () => { + const error = Object.assign( + new Error("Remote context temporarily unavailable"), + { + name: "jsonld.SyntaxError", + details: { code: "loading remote context failed" }, + }, + ); + assertEquals( + await assertRejects(() => lookupFailingCollection(error)), + error, + ); +}); + test("actorless FeatureRequest rejects cross-origin collection redirects", async () => { const result = await deliver("http", { collectionDocumentUrl: "https://attacker.example/forged-collection", diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index cd2c110d4..261e07f2a 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -9,6 +9,7 @@ import { FetchError, UrlError, } from "@fedify/vocab-runtime"; +import { isInvalidUrlTypeError } from "../sig/ld.ts"; import type { InboxVerificationAttempt } from "../sig/verification.ts"; import { getPortableDid, @@ -16,6 +17,35 @@ import { isSameObjectId, } from "../sig/portable-key-id.ts"; +// A loader may report bad remote data as a parser/validation error. Raw +// TypeErrors also represent fetch failures, so do not classify them by type +// alone. Keep this consistent with the inbox's known validation errors. +// TODO: Fold this classification into shared principal resolution: +// https://github.com/fedify-dev/fedify/issues/1290 +function isPermanentCollectionError(error: unknown): boolean { + if (error instanceof SyntaxError || isInvalidUrlTypeError(error)) return true; + if ( + error instanceof TypeError && + /^(Invalid JSON-LD:|Invalid type:|Unexpected type:|Invalid @id:|Invalid FEP-ef61 gateway:)/ + .test(error.message) + ) return true; + if (error instanceof Error) { + const details = (error as Error & { details?: { code?: unknown } }).details; + if ( + error.name === "jsonld.SyntaxError" && + details?.code !== "loading remote context failed" + ) return true; + if ( + error.name === "jsonld.InvalidUrl" && + details?.code === "invalid remote context" + ) return true; + } + const status = error instanceof FetchError ? error.response?.status : null; + return error instanceof UrlError && error.reason === "disallowed" || + status != null && status >= 400 && status < 500 && status !== 408 && + status !== 429; +} + // FIXME: Replace this FeatureRequest-specific authentication bridge with the // general inbox principal handling: https://github.com/fedify-dev/fedify/issues/1290 export async function resolveFeatureRequestActor( @@ -29,19 +59,14 @@ export async function resolveFeatureRequestActor( // discovery through WebFinger. Portable IDs retain their verified lookup. // Preserve loader failures that parsing or portable lookup may hide. let retryableFailure: { error: unknown } | undefined; + let permanentFailure = false; const captureFailure = (loader: DocumentLoader): DocumentLoader => async (...args) => { try { return await loader(...args); } catch (error) { - const status = error instanceof FetchError - ? error.response?.status - : null; - const permanent = - error instanceof UrlError && error.reason === "disallowed" || - status != null && status >= 400 && status < 500 && - status !== 408 && status !== 429; - if (!permanent) retryableFailure ??= { error }; + if (isPermanentCollectionError(error)) permanentFailure = true; + else retryableFailure ??= { error }; throw error; } }; @@ -67,12 +92,11 @@ export async function resolveFeatureRequestActor( } } catch (error) { if (retryableFailure != null) throw retryableFailure.error; - const status = error instanceof FetchError ? error.response?.status : null; + // JSON-LD may wrap loader errors. Preserve the original classification + // rather than treating a malformed remote context as a transient outage. if ( - error instanceof TypeError || - error instanceof UrlError && error.reason === "disallowed" || - status != null && status >= 400 && status < 500 && - status !== 408 && status !== 429 + permanentFailure || isPermanentCollectionError(error) || + error instanceof TypeError ) return null; throw error; } From 929e6f7d148bf64691d5b5bd1b8e69c39f44a85b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 20:10:58 +0900 Subject: [PATCH 10/24] Use the owner's proof-verified activity view A relay signature may authenticate a different context interpretation from the collection owner's proof. Keep the activity returned by owner proof verification and resolve its collection owner again before checking the signer. Memoize contexts within that verification attempt. Do not authorize this new view with the earlier relay signature. Cover changing object and instrument interpretations in direct delivery and queued retries while preserving the original signed payload. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237369631 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 100 ++++++++++++++++-- packages/fedify/src/federation/handler.ts | 48 +++++++-- 2 files changed, 130 insertions(+), 18 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index 7b4f9017a..3c2afc3e1 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -151,6 +151,8 @@ async function deliver( collection?: FeaturedCollection | Person; collectionDocumentUrl?: string; collectionLoader?: DocumentLoader; + relayObject?: URL; + relayInstrument?: URL; replayedObject?: URL; request?: Activity; queued?: boolean; @@ -182,13 +184,17 @@ async function deliver( "@context": { receiver: new URL( ".", - !collectionAvailable && options.replayedObject != null + contextLoads % 2 === 1 && options.relayObject != null + ? options.relayObject + : !collectionAvailable && options.replayedObject != null ? options.replayedObject : target, ).href, featured: new URL( ".", - !collectionAvailable && options.replayedInstrument != null + contextLoads % 2 === 1 && options.relayInstrument != null + ? options.relayInstrument + : !collectionAvailable && options.replayedInstrument != null ? options.replayedInstrument : contextLoads > 1 && options.reparsedInstrument != null ? options.reparsedInstrument @@ -210,10 +216,13 @@ async function deliver( return await mockDocumentLoader(url); }; const documentLoader = async (url: string) => { - if (url === options.reparsedInstrument?.href) { + if ( + url === options.reparsedInstrument?.href || + url === options.relayInstrument?.href + ) { return { document: await new FeaturedCollection({ - id: options.reparsedInstrument, + id: new URL(url), attribution: new URL("https://example.com/person3"), }).toJsonLd(), documentUrl: url, @@ -243,17 +252,63 @@ async function deliver( } return await mockDocumentLoader(url); }; + const relayContext = [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", + "https://w3id.org/fep/7aa9", + contextUrl, + ]; + const ownerContextLoader: DocumentLoader = (url) => + url === contextUrl + ? Promise.resolve({ + document: { + "@context": { + receiver: new URL(".", target).href, + featured: new URL(".", collectionId).href, + }, + }, + documentUrl: url, + contextUrl: null, + }) + : mockDocumentLoader(url); + const relayContextLoader: DocumentLoader = (url) => + url === contextUrl + ? Promise.resolve({ + document: { + "@context": { + receiver: new URL(".", options.relayObject ?? target).href, + featured: + new URL(".", options.relayInstrument ?? collectionId).href, + }, + }, + documentUrl: url, + contextUrl: null, + }) + : mockDocumentLoader(url); + const changingRelay = options.relayObject != null || + options.relayInstrument != null; const body = authentication === "mixed" ? await signJsonLd( await (await signObject( activity, ed25519PrivateKey, ed25519Multikey.id!, - { contextLoader: mockDocumentLoader }, - )).toJsonLd(), + { + contextLoader: changingRelay + ? ownerContextLoader + : mockDocumentLoader, + context: changingRelay ? relayContext : undefined, + }, + )).toJsonLd({ + format: "compact", + contextLoader: changingRelay ? ownerContextLoader : mockDocumentLoader, + context: changingRelay ? relayContext : undefined, + }), rsaPrivateKey2, rsaPublicKey2.id!, - { contextLoader: mockDocumentLoader }, + { + contextLoader: changingRelay ? relayContextLoader : mockDocumentLoader, + }, ) : authentication === "ld" ? await signJsonLd( @@ -537,6 +592,37 @@ test("actorless FeatureRequest preserves wrapped remote context failures for ret ); }); +for (const queued of [false, true]) { + for (const field of ["object", "instrument"] as const) { + test(`actorless FeatureRequest uses the owner's proof view after relay ${field} changes (${queued ? "queued" : "direct"})`, async () => { + const result = await deliver("mixed", { + queued, + ...(field === "object" + ? { relayObject: new URL("https://attacker.example/users/bob") } + : { + relayInstrument: new URL("https://example.com/other-featured/1"), + }), + }); + assertEquals(result.response.status, 202); + if (queued) await result.replay(true); + assertEquals( + result.received.map((activity) => activity.objectId), + queued ? [target, target] : [target], + ); + assertEquals( + result.received.map((activity) => activity.instrumentId), + queued ? [collectionId, collectionId] : [collectionId], + ); + assertEquals( + result.received.map((activity) => activity.actorId), + queued ? [owner, owner] : [owner], + ); + if (queued) assertEquals(result.messages[1].activity, result.body); + assertEquals(result.originals[0], result.body); + }); + } +} + test("actorless FeatureRequest rejects cross-origin collection redirects", async () => { const result = await deliver("http", { collectionDocumentUrl: "https://attacker.example/forged-collection", diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index e4b5a56d6..a712b6b7e 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -2273,28 +2273,53 @@ async function handleInboxInternal( ), ); } - if ( - ldSigVerified && !skipSignatureVerification && + const requiresOwnerProof = ldSigVerified && !skipSignatureVerification && (isPortableActivity(activity.clone({ actor: featureRequestActor })) || !hasFeatureRequestSignature( observation.verification.attempts, featureRequestActor, "linkedData", - )) - ) { + )); + if (requiresOwnerProof) { // A relay's LDS must not hide the owner's proof, and the missing actor // must not bypass the portable proof policy. Verify the original // document, never the inferred clone. try { - await verifyObject(Activity, jsonWithoutSig, { + const verifiedActivity = await verifyObject(Activity, jsonWithoutSig, { ...signatureObservation, - contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), + // Keep parsing and proof verification on the same context snapshot. + contextLoader: getNormalizationContextLoader(ctx.contextLoader), documentLoader: ctx.documentLoader, keyCache, meterProvider, tracerProvider, verifyPortableObject: ctx.verifyPortableObject, }); + if (verifiedActivity != null) { + if ( + !(verifiedActivity instanceof FeatureRequest) || + verifiedActivity.actorIds.length !== 0 + ) { + return await respondInvalidActivity( + new TypeError( + "Owner proof must verify an actorless FeatureRequest.", + ), + ); + } + // TODO: Bind shared principals to their verified activity view: + // https://github.com/fedify-dev/fedify/issues/1290 + activity = verifiedActivity; + featureRequestActor = + await resolveFeatureRequestActor(ctx, verifiedActivity) ?? + undefined; + if (featureRequestActor == null) { + return await respondInvalidActivity( + new TypeError( + "FeatureRequest must reference a collection with one owner.", + ), + ); + } + } } catch (error) { if (!isPermanentActivityParseError(error)) throw error; return await respondInvalidActivity(error); @@ -2305,11 +2330,12 @@ async function handleInboxInternal( featureRequestActor, "objectIntegrity", ); - activityVerified = proofVerified || hasFeatureRequestSignature( - observation.verification.attempts, - featureRequestActor, - "linkedData", - ); + activityVerified = proofVerified || + (!requiresOwnerProof && hasFeatureRequestSignature( + observation.verification.attempts, + featureRequestActor, + "linkedData", + )); if (!activityVerified && !skipSignatureVerification) { // verifyObject() can return an actorless object with zero proofs. A // successful parse is not authentication: fall back to HTTP signatures. From d0ed260edf2d183421da2254e27c2e309a882209 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 20:11:17 +0900 Subject: [PATCH 11/24] Reject malformed collection dates and contexts Reuse the inbox's temporal-literal validation at the web collection parse boundary. Reject malformed dates and durations as invalid input without changing public parser exceptions or hiding loader RangeErrors. Also reject JSON-LD context cycles and context overflow by their known error codes. Cover both authentication paths and preserve retryable loader failures even when the document has malformed temporal fields. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237369636 https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237374028 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 97 ++++++++++++++++++- .../fedify/src/federation/feature-request.ts | 17 ++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index 3c2afc3e1..fbf86314e 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -207,7 +207,7 @@ async function deliver( }; } if ( - (url === "https://example.com/collection-context" || + (url.startsWith("https://example.com/collection-context") || url === "https://[invalid") && options.collectionLoader != null ) { @@ -623,6 +623,101 @@ for (const queued of [false, true]) { } } +for (const authentication of ["http", "proof"] as const) { + for (const field of ["published", "duration"] as const) { + test(`actorless FeatureRequest rejects malformed temporal collection ${field} with ${authentication} authentication`, async () => { + const document = await new FeaturedCollection({ + id: collectionId, + attribution: owner, + }).toJsonLd() as Record; + document[field] = { "@value": "not-a-temporal-value" }; + const result = await deliver(authentication, { + collectionLoader: (url) => + Promise.resolve({ document, documentUrl: url, contextUrl: null }), + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + }); + } +} + +test("actorless FeatureRequest preserves collection loader RangeError for retry", async () => { + const error = new RangeError("Loader temporarily failed"); + assertEquals( + await assertRejects(() => lookupFailingCollection(error)), + error, + ); +}); + +test("actorless FeatureRequest preserves context loader RangeError despite malformed temporal fields", async () => { + const error = new RangeError("Context loader temporarily failed"); + const document = await new FeaturedCollection({ + id: collectionId, + attribution: owner, + }).toJsonLd() as Record; + const contexts = Array.isArray(document["@context"]) + ? document["@context"] + : [document["@context"]]; + document["@context"] = [ + ...contexts, + "https://example.com/collection-context", + ]; + document.published = { "@value": "not-a-date" }; + assertEquals( + await assertRejects(() => + deliver("http", { + collectionLoader: (url) => + url === collectionId.href + ? Promise.resolve({ document, documentUrl: url, contextUrl: null }) + : Promise.reject(error), + }) + ), + error, + ); +}); + +for (const authentication of ["http", "proof"] as const) { + for ( + const kind of ["recursive context inclusion", "context overflow"] as const + ) { + test(`actorless FeatureRequest rejects ${kind} with ${authentication} authentication`, async () => { + const document = await new FeaturedCollection({ + id: collectionId, + attribution: owner, + }).toJsonLd() as Record; + const contexts = Array.isArray(document["@context"]) + ? document["@context"] + : [document["@context"]]; + const firstContext = "https://example.com/collection-context/0"; + document["@context"] = [...contexts, firstContext]; + let loads = 0; + const result = await deliver(authentication, { + collectionLoader: (url) => { + if (url === collectionId.href) { + return Promise.resolve({ + document, + documentUrl: url, + contextUrl: null, + }); + } + loads++; + assert(loads <= 20); + const nextContext = kind === "recursive context inclusion" + ? firstContext + : `https://example.com/collection-context/${loads}`; + return Promise.resolve({ + document: { "@context": nextContext }, + documentUrl: url, + contextUrl: null, + }); + }, + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + }); + } +} + test("actorless FeatureRequest rejects cross-origin collection redirects", async () => { const result = await deliver("http", { collectionDocumentUrl: "https://attacker.example/forged-collection", diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index 261e07f2a..8b3a8b4de 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -1,4 +1,5 @@ import type { Context } from "./context.ts"; +import { hasMalformedKnownTemporalLiteral } from "./temporal.ts"; import { FeaturedCollection, type FeatureRequest, @@ -39,6 +40,11 @@ function isPermanentCollectionError(error: unknown): boolean { error.name === "jsonld.InvalidUrl" && details?.code === "invalid remote context" ) return true; + if ( + error.name === "jsonld.ContextUrlError" && + (details?.code === "recursive context inclusion" || + details?.code === "context overflow") + ) return true; } const status = error instanceof FetchError ? error.response?.status : null; return error instanceof UrlError && error.reason === "disallowed" || @@ -71,6 +77,7 @@ export async function resolveFeatureRequestActor( } }; let collection: ActivityObject | null; + let collectionDocument: unknown; try { if (isPortableId(id)) { collection = await context.lookupObject(id, { @@ -79,6 +86,7 @@ export async function resolveFeatureRequestActor( }); } else { const document = await captureFailure(context.documentLoader)(id.href); + collectionDocument = document.document; const documentUrl = new URL(document.documentUrl); // A web resource cannot vouch for an ID on another origin, even when // reached through a redirect from the requested collection URL. @@ -92,6 +100,15 @@ export async function resolveFeatureRequestActor( } } catch (error) { if (retryableFailure != null) throw retryableFailure.error; + // Only a positively identified malformed temporal literal is permanent. + // Loader RangeErrors were captured above and must remain retryable. + if ( + error instanceof RangeError && collectionDocument != null && + await hasMalformedKnownTemporalLiteral( + collectionDocument, + context.contextLoader, + ) + ) return null; // JSON-LD may wrap loader errors. Preserve the original classification // rather than treating a malformed remote context as a transient outage. if ( From 4765d02be4ab490c0fb649083f12098e6304551d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 20:43:36 +0900 Subject: [PATCH 12/24] Scope owner proofs to their parsed activity Do not let a discarded portable verification attempt authenticate an actorless FeatureRequest when its authoritative owner-proof retry fails. Match only observations from the attempt that returned the activity being dispatched, retaining HTTP fallback where permitted. Add direct and queued regressions with a changing context that makes the discarded attempt succeed and the authoritative retry fail. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237441183 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 81 ++++++++++++++++++- packages/fedify/src/federation/handler.ts | 13 ++- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index fbf86314e..88f096fb0 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -8,6 +8,7 @@ import { } from "@fedify/vocab"; import { type DocumentLoader, + exportDidKey, FetchError, getDocumentLoader, UrlError, @@ -152,6 +153,9 @@ async function deliver( collectionDocumentUrl?: string; collectionLoader?: DocumentLoader; relayObject?: URL; + relayContextLoads?: number[]; + proofKeyId?: URL; + uncoveredAttributionAt?: number; relayInstrument?: URL; replayedObject?: URL; request?: Activity; @@ -182,9 +186,29 @@ async function deliver( return { document: { "@context": { + ...(options.proofKeyId != null + ? { + request: { + "@id": `ap+ef61://${ + options.proofKeyId.href.split("#")[0] + }/requests/`, + "@prefix": true, + }, + } + : {}), + ...(options.proofKeyId != null && + contextLoads === options.uncoveredAttributionAt + ? { + object: { + "@id": "https://www.w3.org/ns/activitystreams#attributedTo", + "@type": "@id", + }, + } + : {}), receiver: new URL( ".", - contextLoads % 2 === 1 && options.relayObject != null + (options.relayContextLoads?.includes(contextLoads) ?? + contextLoads % 2 === 1) && options.relayObject != null ? options.relayObject : !collectionAvailable && options.replayedObject != null ? options.replayedObject @@ -192,7 +216,8 @@ async function deliver( ).href, featured: new URL( ".", - contextLoads % 2 === 1 && options.relayInstrument != null + (options.relayContextLoads?.includes(contextLoads) ?? + contextLoads % 2 === 1) && options.relayInstrument != null ? options.relayInstrument : !collectionAvailable && options.replayedInstrument != null ? options.replayedInstrument @@ -263,6 +288,16 @@ async function deliver( ? Promise.resolve({ document: { "@context": { + ...(options.proofKeyId != null + ? { + request: { + "@id": `ap+ef61://${ + options.proofKeyId.href.split("#")[0] + }/requests/`, + "@prefix": true, + }, + } + : {}), receiver: new URL(".", target).href, featured: new URL(".", collectionId).href, }, @@ -276,6 +311,16 @@ async function deliver( ? Promise.resolve({ document: { "@context": { + ...(options.proofKeyId != null + ? { + request: { + "@id": `ap+ef61://${ + options.proofKeyId.href.split("#")[0] + }/requests/`, + "@prefix": true, + }, + } + : {}), receiver: new URL(".", options.relayObject ?? target).href, featured: new URL(".", options.relayInstrument ?? collectionId).href, @@ -292,7 +337,7 @@ async function deliver( await (await signObject( activity, ed25519PrivateKey, - ed25519Multikey.id!, + options.proofKeyId ?? ed25519Multikey.id!, { contextLoader: changingRelay ? ownerContextLoader @@ -970,3 +1015,33 @@ test("actorless FeatureRequest portable authentication requires the owner's DID ), ); }); + +for (const queued of [false, true]) { + test(`actorless FeatureRequest rejects stale portable proof evidence (${queued ? "queued" : "direct"})`, async () => { + const did = await exportDidKey(ed25519Multikey.publicKey!); + const result = await deliver("mixed", { + queued, + proofKeyId: new URL(`${did}#${did.substring("did:key:".length)}`), + request: new FeatureRequest({ + id: new URL(`ap://${encodeURIComponent(did)}/requests/1`), + object: target, + instrument: collectionId, + }), + relayObject: new URL("https://attacker.example/users/bob"), + // The discarded portable attempt verifies the owner's view; the + // authoritative retry adds an uncovered attribution and returns null. + // LDS normalization loads once; the discarded portable verification + // loads 12 times. The memoized owner-proof retry starts at load 14. + relayContextLoads: [1, 14], + uncoveredAttributionAt: 14, + collection: new FeaturedCollection({ + id: collectionId, + attribution: new URL(`ap://${encodeURIComponent(did)}/actor`), + }), + }); + assertEquals(result.response.status, 401); + assertEquals(result.received, []); + assertEquals(result.messages, []); + }); +} + diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index a712b6b7e..153d50c34 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -2273,6 +2273,13 @@ async function handleInboxInternal( ), ); } + // Only the proof attempt that produced the current activity can + // authenticate it. The earlier portable attempt discarded its parse. + // TODO: Carry this binding in shared inbox principals: + // https://github.com/fedify-dev/fedify/issues/1290 + let featureRequestProofAttempts = ldSigVerified + ? [] + : observation.verification.attempts; const requiresOwnerProof = ldSigVerified && !skipSignatureVerification && (isPortableActivity(activity.clone({ actor: featureRequestActor })) || !hasFeatureRequestSignature( @@ -2285,6 +2292,7 @@ async function handleInboxInternal( // must not bypass the portable proof policy. Verify the original // document, never the inferred clone. try { + const attemptStart = observation.verification.attempts.length; const verifiedActivity = await verifyObject(Activity, jsonWithoutSig, { ...signatureObservation, // Keep parsing and proof verification on the same context snapshot. @@ -2309,6 +2317,9 @@ async function handleInboxInternal( // TODO: Bind shared principals to their verified activity view: // https://github.com/fedify-dev/fedify/issues/1290 activity = verifiedActivity; + featureRequestProofAttempts = observation.verification.attempts.slice( + attemptStart, + ); featureRequestActor = await resolveFeatureRequestActor(ctx, verifiedActivity) ?? undefined; @@ -2326,7 +2337,7 @@ async function handleInboxInternal( } } proofVerified = hasFeatureRequestSignature( - observation.verification.attempts, + featureRequestProofAttempts, featureRequestActor, "objectIntegrity", ); From 3d5cff1530c7c05d577c84b216119a1bd4482b90 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 20:43:49 +0900 Subject: [PATCH 13/24] Reject oversized FeatureRequest collections Treat the standard loader's BodyTooLargeError as a permanent invalid collection response. Keep ordinary FetchErrors without an HTTP response retryable, and avoid adding a public error export in a patch release. Exercise the bounded standard loader through HTTP and proof-authenticated inbox requests, and retain a regression for transport failures. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237441184 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 31 +++++++++++++++++++ .../fedify/src/federation/feature-request.ts | 5 +++ 2 files changed, 36 insertions(+) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index 88f096fb0..04b57a68a 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -1045,3 +1045,34 @@ for (const queued of [false, true]) { }); } +for (const authentication of ["http", "proof"] as const) { + test(`actorless FeatureRequest rejects oversized collection responses with ${authentication} authentication`, async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = () => + Promise.resolve( + new Response(null, { + headers: { + "Content-Type": "application/activity+json", + "Content-Length": String(16 * 1024 * 1024 + 1), + }, + }), + ); + try { + const result = await deliver(authentication, { + collectionLoader: getDocumentLoader({ allowPrivateAddress: true }), + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + } finally { + globalThis.fetch = originalFetch; + } + }); +} + +test("actorless FeatureRequest preserves collection FetchError without a response for retry", async () => { + const error = new FetchError(collectionId, "Connection lost"); + assertEquals( + await assertRejects(() => lookupFailingCollection(error)), + error, + ); +}); diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index 8b3a8b4de..42c52271c 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -46,6 +46,11 @@ function isPermanentCollectionError(error: unknown): boolean { details?.code === "context overflow") ) return true; } + // This private loader error is identified by its stable name rather than + // adding a vocab-runtime export in a patch release. + if (error instanceof FetchError && error.name === "BodyTooLargeError") { + return true; + } const status = error instanceof FetchError ? error.response?.status : null; return error instanceof UrlError && error.reason === "disallowed" || status != null && status >= 400 && status < 500 && status !== 408 && From 3040f524aaa62af9d663a6301b9b5ca9b248b574 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 21:31:01 +0900 Subject: [PATCH 14/24] Reject unusable successful collection responses The standard loader can report invalid HTML and oversized HTML as a FetchError carrying an otherwise successful HTTP response. Treat these as invalid collections instead of retryable transport failures, including when the invalid response comes from a referenced remote context. Exercise both HTML failure paths through HTTP and proof authentication. Keep transient HTTP statuses and failures without responses retryable. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237536827 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 53 ++++++++++++++++++- .../fedify/src/federation/feature-request.ts | 8 ++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index 04b57a68a..425569836 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -74,7 +74,7 @@ test("actorless FeatureRequest preserves collection lookup failures for retry", ); }); -for (const status of [400, 401, 403, 404, 410, 408, 429, 503]) { +for (const status of [200, 204, 206, 400, 401, 403, 404, 410, 408, 429, 503]) { test(`actorless FeatureRequest classifies collection HTTP ${status}`, async () => { const error = new FetchError( collectionId, @@ -1076,3 +1076,54 @@ test("actorless FeatureRequest preserves collection FetchError without a respons error, ); }); + +for (const authentication of ["http", "proof"] as const) { + for (const resource of ["collection", "context"] as const) { + for (const oversized of [false, true]) { + test(`actorless FeatureRequest rejects ${oversized ? "oversized" : "invalid"} HTML ${resource} responses with ${authentication} authentication`, async () => { + const document = await new FeaturedCollection({ + id: collectionId, + attribution: owner, + }).toJsonLd() as Record; + const contextUrl = "https://example.com/collection-context/html"; + if (resource === "context") { + const contexts = Array.isArray(document["@context"]) + ? document["@context"] + : [document["@context"]]; + document["@context"] = [...contexts, contextUrl]; + } + const originalFetch = globalThis.fetch; + globalThis.fetch = (input) => { + const url = input instanceof Request ? input.url : String(input); + const html = resource === "collection" || url === contextUrl; + return Promise.resolve( + new Response( + html + ? "No ActivityPub document" + : JSON.stringify(document), + { + headers: { + "Content-Type": html + ? "text/html" + : "application/activity+json", + ...(html && oversized + ? { "Content-Length": String(16 * 1024 * 1024 + 1) } + : {}), + }, + }, + ), + ); + }; + try { + const result = await deliver(authentication, { + collectionLoader: getDocumentLoader({ allowPrivateAddress: true }), + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + } finally { + globalThis.fetch = originalFetch; + } + }); + } + } +} diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index 42c52271c..8458b9d21 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -53,8 +53,12 @@ function isPermanentCollectionError(error: unknown): boolean { } const status = error instanceof FetchError ? error.response?.status : null; return error instanceof UrlError && error.reason === "disallowed" || - status != null && status >= 400 && status < 500 && status !== 408 && - status !== 429; + status != null && ( + // Successful responses can still be unusable documents (e.g., HTML + // without an ActivityPub alternate), rather than transport failures. + status >= 200 && status < 300 || + status >= 400 && status < 500 && status !== 408 && status !== 429 + ); } // FIXME: Replace this FeatureRequest-specific authentication bridge with the From ff38ae4de7038bbbb63db9ef79265b3b611ad582 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 21:50:56 +0900 Subject: [PATCH 15/24] Require one complete FeatureRequest instrument ID accessors omit anonymous embedded values, so one instrument ID did not imply one instrument. Count the parsed property's full values before resolving ownership, without dereferencing embedded instruments or reparsing the original context. Cover anonymous values before and after the collection reference with HTTP, Linked Data Signature, and Object Integrity Proof authentication. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237646067 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 25 +++++++++++++++++++ .../fedify/src/federation/feature-request.ts | 10 ++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index 425569836..a48e378aa 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -4,6 +4,7 @@ import { Create, FeaturedCollection, FeatureRequest, + Object as ActivityObject, Person, } from "@fedify/vocab"; import { @@ -1127,3 +1128,27 @@ for (const authentication of ["http", "proof"] as const) { } } } + +for (const authentication of ["http", "ld", "proof"] as const) { + for (const anonymousFirst of [false, true]) { + test(`actorless FeatureRequest rejects an anonymous instrument ${anonymousFirst ? "before" : "after"} the collection with ${authentication} authentication`, async () => { + let lookups = 0; + const anonymous = new ActivityObject({ name: "No collection ID" }); + const result = await deliver(authentication, { + request: new FeatureRequest({ + id: new URL("https://example.com/requests/ambiguous"), + object: target, + instruments: anonymousFirst + ? [anonymous, collectionId] + : [collectionId, anonymous], + }), + onCollectionLookup: () => { + lookups++; + }, + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + assertEquals(lookups, 0); + }); + } +} diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index 8458b9d21..013c3c661 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -69,6 +69,16 @@ export async function resolveFeatureRequestActor( ): Promise { if (request.instrumentIds.length !== 1) return null; const id = request.instrumentIds[0]; + // ID accessors omit anonymous embedded values. Count the parsed values, + // without dereferencing them or reinterpreting the original context. + const expanded = await request.toJsonLd({ format: "expand" }) as Record< + string, + unknown + >[]; + const instruments = expanded[0]?.[ + "https://www.w3.org/ns/activitystreams#instrument" + ]; + if (!Array.isArray(instruments) || instruments.length !== 1) return null; // Never infer ownership from an instrument embedded by the sender. // Collection authorization uses the referenced resource, not account // discovery through WebFinger. Portable IDs retain their verified lookup. From 2adad42f3fa52f18a2af21212a93e30a7a6b3bc3 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 21:51:12 +0900 Subject: [PATCH 16/24] Reject credentials in FeatureRequest instruments Reject usernames and passwords in a collection URL before calling its loader. Node.js rejects these URLs during Request construction, so they must not become retryable loader failures at this authentication boundary. Cover username-only, password-only, and combined credentials, and verify that the standard loader never reaches fetch for such an instrument. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237646069 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 65 +++++++++++++++++++ .../fedify/src/federation/feature-request.ts | 1 + 2 files changed, 66 insertions(+) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index a48e378aa..f9f3313bf 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -1152,3 +1152,68 @@ for (const authentication of ["http", "ld", "proof"] as const) { }); } } + +for (const credentials of ["user@", ":pass@", "user:pass@"] as const) { + test(`actorless FeatureRequest rejects credential-bearing collection URL ${credentials} before loading`, async () => { + let loads = 0; + const context = createRequestContext({ + federation: createFederation({ kv: new MemoryKvStore() }), + url: target, + data: undefined, + documentLoader: () => { + loads++; + return Promise.reject( + new TypeError( + "Request cannot be constructed from a URL that includes credentials", + ), + ); + }, + contextLoader: mockDocumentLoader, + }); + assertEquals( + await resolveFeatureRequestActor( + context, + new FeatureRequest({ + instrument: new URL(`https://${credentials}example.com/featured/1`), + }), + ), + null, + ); + assertEquals(loads, 0); + }); +} + +test("actorless FeatureRequest rejects credentials before invoking the standard document loader", async () => { + const id = new URL("https://user:pass@example.com/featured/1"); + let fetches = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = () => { + fetches++; + return Promise.resolve( + new Response("{}", { + headers: { "Content-Type": "application/activity+json" }, + }), + ); + }; + try { + const context = createRequestContext({ + federation: createFederation({ kv: new MemoryKvStore() }), + url: target, + data: undefined, + documentLoader: getDocumentLoader({ allowPrivateAddress: true }), + contextLoader: mockDocumentLoader, + }); + assertEquals( + await resolveFeatureRequestActor( + context, + new FeatureRequest({ + instrument: id, + }), + ), + null, + ); + assertEquals(fetches, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index 013c3c661..712814d8e 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -69,6 +69,7 @@ export async function resolveFeatureRequestActor( ): Promise { if (request.instrumentIds.length !== 1) return null; const id = request.instrumentIds[0]; + if (id.username !== "" || id.password !== "") return null; // ID accessors omit anonymous embedded values. Count the parsed values, // without dereferencing them or reinterpreting the original context. const expanded = await request.toJsonLd({ format: "expand" }) as Record< From 675c6d2b382c53e962a90bfdc3b20447a2eb22c0 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 10 Oct 2026 22:12:26 +0900 Subject: [PATCH 17/24] Test credential rejection through the inbox Exercise credential-bearing FeatureRequest instruments after HTTP, Linked Data Signature, and Object Integrity Proof authentication. Check that the inbox returns 400 without notifying listeners or loading the collection, rather than relying only on direct resolver tests. The fixture routes the credential-bearing URL to a failing loader so these tests fail if the pre-load credential guard is removed. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237700035 Changelog: none Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index f9f3313bf..55c165a0b 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -152,6 +152,7 @@ async function deliver( options: { collection?: FeaturedCollection | Person; collectionDocumentUrl?: string; + collectionLookupId?: URL; collectionLoader?: DocumentLoader; relayObject?: URL; relayContextLoads?: number[]; @@ -255,7 +256,7 @@ async function deliver( contextUrl: null, }; } - if (url === collectionId.href) { + if (url === (options.collectionLookupId ?? collectionId).href) { options.onCollectionLookup?.(); if (options.collectionLoader != null) { return await options.collectionLoader(url); @@ -1217,3 +1218,30 @@ test("actorless FeatureRequest rejects credentials before invoking the standard globalThis.fetch = originalFetch; } }); + +for (const authentication of ["http", "ld", "proof"] as const) { + test(`actorless FeatureRequest rejects credentials through inbox with ${authentication} authentication`, async () => { + const id = new URL("https://user:pass@example.com/featured/1"); + let lookups = 0; + const result = await deliver(authentication, { + request: new FeatureRequest({ + id: new URL("https://example.com/requests/credentials"), + object: target, + instrument: id, + }), + collectionLookupId: id, + onCollectionLookup: () => { + lookups++; + }, + collectionLoader: () => + Promise.reject( + new TypeError( + "Request cannot be constructed from a URL that includes credentials", + ), + ), + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + assertEquals(lookups, 0); + }); +} From ad64ad5a31858c4a1edc32c885e88c5e38a2d637 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 11 Oct 2026 01:09:55 +0900 Subject: [PATCH 18/24] Validate every collection attribution ID accessors and typed parsing omit anonymous attribution values. Require one full value before inferring the FeatureRequest owner. Expand web collections once and parse that same view so validation counts values that the typed parser would otherwise discard. Regressions cover both orders of anonymous values and all authentication mechanisms, including untyped values from the remote collection. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237762682 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 41 +++++++++++++++++++ .../fedify/src/federation/feature-request.ts | 18 +++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index 55c165a0b..388f018fc 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -1245,3 +1245,44 @@ for (const authentication of ["http", "ld", "proof"] as const) { assertEquals(lookups, 0); }); } + +for (const authentication of ["http", "ld", "proof"] as const) { + for (const anonymousFirst of [false, true]) { + test(`actorless FeatureRequest rejects an anonymous attribution ${anonymousFirst ? "before" : "after"} the owner with ${authentication} authentication`, async () => { + const anonymous = new Person({ name: "No owner ID" }); + const result = await deliver(authentication, { + collection: new FeaturedCollection({ + id: collectionId, + attributions: anonymousFirst + ? [anonymous, owner] + : [owner, anonymous], + }), + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + }); + } +} + +for (const anonymousFirst of [false, true]) { + test(`actorless FeatureRequest rejects untyped anonymous attribution ${anonymousFirst ? "before" : "after"} the owner`, async () => { + const document = await new FeaturedCollection({ + id: collectionId, + attribution: owner, + }).toJsonLd() as Record; + const anonymous = { name: "No owner ID" }; + document.attributedTo = anonymousFirst + ? [anonymous, owner.href] + : [owner.href, anonymous]; + const result = await deliver("http", { + collectionLoader: (url) => + Promise.resolve({ + document, + documentUrl: url, + contextUrl: null, + }), + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + }); +} diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index 712814d8e..255612c8a 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -10,6 +10,7 @@ import { FetchError, UrlError, } from "@fedify/vocab-runtime"; +import jsonld from "@fedify/vocab-runtime/jsonld"; import { isInvalidUrlTypeError } from "../sig/ld.ts"; import type { InboxVerificationAttempt } from "../sig/verification.ts"; import { @@ -98,6 +99,7 @@ export async function resolveFeatureRequestActor( }; let collection: ActivityObject | null; let collectionDocument: unknown; + let expandedCollection: Record[] | undefined; try { if (isPortableId(id)) { collection = await context.lookupObject(id, { @@ -111,7 +113,13 @@ export async function resolveFeatureRequestActor( // A web resource cannot vouch for an ID on another origin, even when // reached through a redirect from the requested collection URL. if (documentUrl.origin !== id.origin) return null; - collection = await ActivityObject.fromJsonLd(document.document, { + // Preserve values the typed attribution parser may omit. Parse the + // same expanded view used for cardinality, without loading contexts twice. + expandedCollection = await jsonld.expand(document.document, { + documentLoader: captureFailure(context.contextLoader), + keepFreeFloatingNodes: true, + }) as Record[]; + collection = await ActivityObject.fromJsonLd(expandedCollection, { ...context, documentLoader: captureFailure(context.documentLoader), contextLoader: captureFailure(context.contextLoader), @@ -144,6 +152,14 @@ export async function resolveFeatureRequestActor( if (retryableFailure != null) throw retryableFailure.error; return null; } + // As with instrument, ID accessors alone do not establish cardinality. + expandedCollection ??= await collection.toJsonLd({ + format: "expand", + }) as Record[]; + const attributions = expandedCollection[0]?.[ + "https://www.w3.org/ns/activitystreams#attributedTo" + ]; + if (!Array.isArray(attributions) || attributions.length !== 1) return null; return collection.attributionIds[0]; } From f9ec7d4990054c804f5af55664b2861b5d8d964e Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 11 Oct 2026 01:10:44 +0900 Subject: [PATCH 19/24] Reject unusable collection redirects Treat returned 3xx FetchErrors and Node's credential-URL construction error as permanent collection failures rather than retrying malformed remote data. Check returned document URLs too, since Deno and Bun can construct credential-bearing Requests. Real-loader inbox regressions cover missing Location and credentials under HTTP, LDS, and proof authentication. Transport failures, 408/429, and 5xx responses retain their retry behavior. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237762676 https://github.com/fedify-dev/fedify/pull/1291#discussion_r4237762684 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 55 ++++++++++++++++++- .../fedify/src/federation/feature-request.ts | 20 +++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index 388f018fc..5dd24cc92 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -75,7 +75,27 @@ test("actorless FeatureRequest preserves collection lookup failures for retry", ); }); -for (const status of [200, 204, 206, 400, 401, 403, 404, 410, 408, 429, 503]) { +for ( + const status of [ + 200, + 204, + 206, + 300, + 301, + 302, + 304, + 307, + 308, + 400, + 401, + 403, + 404, + 410, + 408, + 429, + 503, + ] +) { test(`actorless FeatureRequest classifies collection HTTP ${status}`, async () => { const error = new FetchError( collectionId, @@ -1262,6 +1282,39 @@ for (const authentication of ["http", "ld", "proof"] as const) { assertEquals(result.received, []); }); } + for (const redirect of ["missing location", "credentials"] as const) { + test(`actorless FeatureRequest rejects collection redirect with ${redirect} with ${authentication} authentication`, async () => { + const document = await new FeaturedCollection({ + id: collectionId, + attribution: owner, + }).toJsonLd(); + const originalFetch = globalThis.fetch; + globalThis.fetch = (input) => { + const url = input instanceof Request ? input.url : String(input); + return Promise.resolve( + url === collectionId.href + ? new Response(null, { + status: 302, + headers: redirect === "credentials" + ? { Location: "https://user:pass@example.com/redirected" } + : {}, + }) + : new Response(JSON.stringify(document), { + headers: { "Content-Type": "application/activity+json" }, + }), + ); + }; + try { + const result = await deliver(authentication, { + collectionLoader: getDocumentLoader({ allowPrivateAddress: true }), + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + } finally { + globalThis.fetch = originalFetch; + } + }); + } } for (const anonymousFirst of [false, true]) { diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index 255612c8a..8c7a7599d 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -31,6 +31,13 @@ function isPermanentCollectionError(error: unknown): boolean { /^(Invalid JSON-LD:|Invalid type:|Unexpected type:|Invalid @id:|Invalid FEP-ef61 gateway:)/ .test(error.message) ) return true; + // Node rejects credential-bearing redirect targets before returning a + // remote document. Other runtimes may return one; validate that URL below. + if ( + error instanceof TypeError && + /^Request cannot be constructed from a URL that includes credentials(?::|$)/ + .test(error.message) + ) return true; if (error instanceof Error) { const details = (error as Error & { details?: { code?: unknown } }).details; if ( @@ -55,9 +62,9 @@ function isPermanentCollectionError(error: unknown): boolean { const status = error instanceof FetchError ? error.response?.status : null; return error instanceof UrlError && error.reason === "disallowed" || status != null && ( - // Successful responses can still be unusable documents (e.g., HTML - // without an ActivityPub alternate), rather than transport failures. - status >= 200 && status < 300 || + // A returned 2xx/3xx error means the loader could not use the + // document or follow the redirect, rather than a transport failure. + status >= 200 && status < 400 || status >= 400 && status < 500 && status !== 408 && status !== 429 ); } @@ -90,7 +97,12 @@ export async function resolveFeatureRequestActor( const captureFailure = (loader: DocumentLoader): DocumentLoader => async (...args) => { try { - return await loader(...args); + const document = await loader(...args); + const documentUrl = new URL(document.documentUrl); + if (documentUrl.username !== "" || documentUrl.password !== "") { + throw new UrlError("Credential-bearing document URL"); + } + return document; } catch (error) { if (isPermanentCollectionError(error)) permanentFailure = true; else retryableFailure ??= { error }; From 3f5ca6f95abaa181e312f8f4a33ebe60235b92f3 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 11 Oct 2026 01:30:47 +0900 Subject: [PATCH 20/24] Reject cyclic and excessive collection redirects Both built-in document loaders report redirect loops and exhausted redirect limits as FetchErrors without responses. Recognize those known failures so actorless FeatureRequests reject unusable collection lookups instead of retrying them. Other response-less failures remain retryable. Regression tests exercise HTTP and alternate-document chains through both loaders with HTTP, LDS, and proof authentication. Additional cases preserve timeout retries and cover Node's credential-URL error. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4238325801 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 76 +++++++++++++++++++ .../fedify/src/federation/feature-request.ts | 12 +++ 2 files changed, 88 insertions(+) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index 5dd24cc92..cb66d7698 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -15,6 +15,7 @@ import { UrlError, } from "@fedify/vocab-runtime"; import { assert, assertEquals, assertRejects } from "@std/assert"; +import { getAuthenticatedDocumentLoader } from "../utils/docloader.ts"; import { signRequest } from "../sig/http.ts"; import { signJsonLd, verifyJsonLd } from "../sig/ld.ts"; import { signObject, verifyObject } from "../sig/proof.ts"; @@ -1339,3 +1340,78 @@ for (const anonymousFirst of [false, true]) { assertEquals(result.received, []); }); } + +for (const authentication of ["http", "ld", "proof"] as const) { + for (const authenticatedLoader of [false, true]) { + for (const alternate of [false, true]) { + for (const loop of [false, true]) { + test(`actorless FeatureRequest rejects ${alternate ? "alternate" : "HTTP"} redirect ${loop ? "loops" : "limits"} using ${authenticatedLoader ? "authenticated" : "standard"} loader with ${authentication} authentication`, async () => { + let fetches = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = () => { + fetches++; + const next = loop + ? alternate && fetches === 1 + ? `${collectionId.href}/alternate` + : collectionId.href + : `${collectionId.href}/${fetches}`; + return Promise.resolve( + alternate + ? new Response("", { + headers: { + "Content-Type": "text/html", + Link: + `<${next}>; rel="alternate"; type="application/activity+json"`, + }, + }) + : new Response(null, { + status: 302, + headers: { Location: next }, + }), + ); + }; + try { + const collectionLoader = authenticatedLoader + ? getAuthenticatedDocumentLoader({ + keyId: new URL("https://example.com/person2#main-key"), + privateKey: rsaPrivateKey2, + }, { + allowPrivateAddress: true, + specDeterminer: { + determineSpec: () => Promise.resolve("rfc9421"), + rememberSpec: () => Promise.resolve(), + }, + }) + : getDocumentLoader({ allowPrivateAddress: true }); + const result = await deliver(authentication, { collectionLoader }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + assertEquals(fetches, loop ? alternate ? 2 : 1 : 21); + } finally { + globalThis.fetch = originalFetch; + } + }); + } + } + } +} + +test("actorless FeatureRequest preserves response-less collection timeouts for retry", async () => { + const error = new FetchError(collectionId, "Document lookup timed out"); + error.cause = new DOMException("Document lookup timed out", "TimeoutError"); + assertEquals( + await assertRejects(() => lookupFailingCollection(error)), + error, + ); +}); + +test("actorless FeatureRequest rejects Node credential URL construction errors", async () => { + assertEquals( + await lookupFailingCollection( + new TypeError( + "Request cannot be constructed from a URL that includes credentials: https://user:pass@example.com/redirected", + ), + ), + null, + ); +}); diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index 8c7a7599d..ef2b65f44 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -59,6 +59,18 @@ function isPermanentCollectionError(error: unknown): boolean { if (error instanceof FetchError && error.name === "BodyTooLargeError") { return true; } + // Both built-in loaders use these messages when no document can be + // reached. Response-less network failures and timeouts remain retryable. + if (error instanceof FetchError && error.response == null) { + const prefix = `${error.url.href}: `; + const message = error.message.startsWith(prefix) + ? error.message.slice(prefix.length) + : ""; + if ( + /^Redirect loop detected: .+/.test(message) || + /^Too many redirections \(\d+\)$/.test(message) + ) return true; + } const status = error instanceof FetchError ? error.response?.status : null; return error instanceof UrlError && error.reason === "disallowed" || status != null && ( From 3e0b5f39eedc98cff3c056039bec1470472b4887 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 11 Oct 2026 03:07:13 +0900 Subject: [PATCH 21/24] Require absent actors before owner inference An empty actorIds list can hide anonymous actors, blank nodes, and untyped values. Reject these explicitly declared actors rather than replacing them with an inferred FeatureRequest owner. Record contexts from the current parsing attempt and check actor presence against that input view without re-fetching mutable contexts. Reset the captured view on HTTP fallback and owner-proof retries. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4238419390 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 46 ++++++++++++++++++- .../fedify/src/federation/feature-request.ts | 30 ++++++++++++ packages/fedify/src/federation/handler.ts | 43 +++++++++++++++-- 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index cb66d7698..de4142ef7 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -182,6 +182,7 @@ async function deliver( relayInstrument?: URL; replayedObject?: URL; request?: Activity; + rawActor?: unknown; queued?: boolean; skip?: boolean; reparsedInstrument?: URL; @@ -355,7 +356,17 @@ async function deliver( : mockDocumentLoader(url); const changingRelay = options.relayObject != null || options.relayInstrument != null; - const body = authentication === "mixed" + const rawActorDocument = options.rawActor === undefined ? null : { + ...await activity.toJsonLd() as Record, + actor: options.rawActor, + }; + const body = rawActorDocument != null + ? authentication === "ld" + ? await signJsonLd(rawActorDocument, rsaPrivateKey3, rsaPublicKey3.id!, { + contextLoader: mockDocumentLoader, + }) + : rawActorDocument + : authentication === "mixed" ? await signJsonLd( await (await signObject( activity, @@ -1415,3 +1426,36 @@ test("actorless FeatureRequest rejects Node credential URL construction errors", null, ); }); + +for (const authentication of ["http", "ld", "proof"] as const) { + for (const queued of [false, true]) { + test(`actorless FeatureRequest rejects an anonymous declared actor with ${authentication} authentication (${queued ? "queued" : "direct"})`, async () => { + const result = await deliver(authentication, { + queued, + request: new FeatureRequest({ + id: new URL("https://example.com/requests/anonymous-actor"), + actor: new Person({ name: "Anonymous actor" }), + object: target, + instrument: collectionId, + }), + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + assertEquals(result.messages, []); + }); + } +} +for (const authentication of ["http", "ld"] as const) { + for ( + const actor of ["_:actor", { name: "Untyped actor" }, { + id: "_:actor", + type: "Person", + }] + ) { + test(`actorless FeatureRequest rejects declared actor ${JSON.stringify(actor)} with ${authentication} authentication`, async () => { + const result = await deliver(authentication, { rawActor: actor }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + }); + } +} diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index ef2b65f44..030981958 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -81,6 +81,36 @@ function isPermanentCollectionError(error: unknown): boolean { ); } +// TODO: Preserve unparsed property presence with the verified view in #1290: +// https://github.com/fedify-dev/fedify/issues/1290 +export function captureFeatureRequestActorPresence(loader: DocumentLoader) { + const contexts = new Map>>(); + return { + contextLoader: (async (...args) => { + const document = await loader(...args); + if (!contexts.has(args[0])) { + contexts.set(args[0], structuredClone(document)); + } + return document; + }) satisfies DocumentLoader, + async hasActor(document: unknown): Promise { + const expanded = await jsonld.expand(document, { + documentLoader: (url: string) => { + const context = contexts.get(url); + if (context == null) { + throw new TypeError("Invalid JSON-LD: unrecorded actor context."); + } + return Promise.resolve(context); + }, + keepFreeFloatingNodes: true, + }); + const actors = expanded[0] + ?.["https://www.w3.org/ns/activitystreams#actor"]; + return Array.isArray(actors) && actors.length > 0; + }, + }; +} + // FIXME: Replace this FeatureRequest-specific authentication bridge with the // general inbox principal handling: https://github.com/fedify-dev/fedify/issues/1290 export async function resolveFeatureRequestActor( diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 153d50c34..87995a92e 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -69,6 +69,7 @@ import { } from "../sig/ld.ts"; import { doesActorOwnKey } from "../sig/owner.ts"; import { + captureFeatureRequestActorPresence, hasFeatureRequestSignature, resolveFeatureRequestActor, } from "./feature-request.ts"; @@ -2123,6 +2124,14 @@ async function handleInboxInternal( } } } + let actorPresence = captureFeatureRequestActorPresence( + ldSigVerified + ? getNormalizationContextLoader(ctx.contextLoader) + : wrapContextLoaderForJsonLd(ctx.contextLoader), + ); + let actorPresenceDocument = ldSigVerified + ? compactedJsonWithoutSig + : jsonWithoutSig; let activity: Activity | null = null; let activityVerified = false; // Whether the activity is authenticated by its Object Integrity Proofs, @@ -2135,7 +2144,7 @@ async function handleInboxInternal( compactedJsonWithoutSig, { ...ctx, - contextLoader: getNormalizationContextLoader(ctx.contextLoader), + contextLoader: actorPresence.contextLoader, }, ); } catch (error) { @@ -2182,7 +2191,7 @@ async function handleInboxInternal( try { activity = await verifyObject(Activity, jsonWithoutSig, { ...signatureObservation, - contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), + contextLoader: actorPresence.contextLoader, documentLoader: ctx.documentLoader, keyCache, meterProvider, @@ -2264,6 +2273,13 @@ async function handleInboxInternal( attempt.signatures.length > 0, )) ) { + if (await actorPresence.hasActor(actorPresenceDocument)) { + return await respondInvalidActivity( + new TypeError( + "FeatureRequest actor must be absent to infer its owner.", + ), + ); + } featureRequestActor = await resolveFeatureRequestActor(ctx, activity) ?? undefined; if (featureRequestActor == null) { @@ -2292,11 +2308,14 @@ async function handleInboxInternal( // must not bypass the portable proof policy. Verify the original // document, never the inferred clone. try { + const ownerActorPresence = captureFeatureRequestActorPresence( + getNormalizationContextLoader(ctx.contextLoader), + ); const attemptStart = observation.verification.attempts.length; const verifiedActivity = await verifyObject(Activity, jsonWithoutSig, { ...signatureObservation, // Keep parsing and proof verification on the same context snapshot. - contextLoader: getNormalizationContextLoader(ctx.contextLoader), + contextLoader: ownerActorPresence.contextLoader, documentLoader: ctx.documentLoader, keyCache, meterProvider, @@ -2306,7 +2325,8 @@ async function handleInboxInternal( if (verifiedActivity != null) { if ( !(verifiedActivity instanceof FeatureRequest) || - verifiedActivity.actorIds.length !== 0 + verifiedActivity.actorIds.length !== 0 || + await ownerActorPresence.hasActor(jsonWithoutSig) ) { return await respondInvalidActivity( new TypeError( @@ -2317,6 +2337,8 @@ async function handleInboxInternal( // TODO: Bind shared principals to their verified activity view: // https://github.com/fedify-dev/fedify/issues/1290 activity = verifiedActivity; + actorPresence = ownerActorPresence; + actorPresenceDocument = jsonWithoutSig; featureRequestProofAttempts = observation.verification.attempts.slice( attemptStart, ); @@ -2523,12 +2545,16 @@ async function handleInboxInternal( } httpSigKey = verification.key; } + actorPresence = captureFeatureRequestActorPresence( + wrapContextLoaderForJsonLd(ctx.contextLoader), + ); + actorPresenceDocument = jsonWithoutSig; try { activity = observation.activity = await Activity.fromJsonLd( jsonWithoutSig, { ...ctx, - contextLoader: wrapContextLoaderForJsonLd(ctx.contextLoader), + contextLoader: actorPresence.contextLoader, }, ); } catch (error) { @@ -2542,6 +2568,13 @@ async function handleInboxInternal( featureRequestActor = undefined; } if (activity instanceof FeatureRequest && activity.actorIds.length === 0) { + if (await actorPresence.hasActor(actorPresenceDocument)) { + return await respondInvalidActivity( + new TypeError( + "FeatureRequest actor must be absent to infer its owner.", + ), + ); + } featureRequestActor ??= await resolveFeatureRequestActor(ctx, activity) ?? undefined; if (featureRequestActor == null) { From 25ebc02b6046dc348728a7814d85373f8797f1d0 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 11 Oct 2026 03:08:03 +0900 Subject: [PATCH 22/24] Keep document scheme checks with private access allowPrivateAddress bypassed protocol validation along with address checks. Reject unsupported schemes in both built-in loaders before fetching initial URLs, redirects, or alternate documents. Restrict FeatureRequest collection references to web or portable identifiers. Keep transport failures retryable without adding runtime-specific unsupported-scheme TypeError matching. Regressions cover both loaders with private access enabled and all three inbox authentication modes. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4238419384 https://github.com/fedify-dev/fedify/issues/1292 Assisted-by: Codex:gpt-6.1-sol --- CHANGES.md | 12 ++++ changes.d/fedify/document-url-schemes.md | 4 ++ .../vocab-runtime/document-url-schemes.md | 4 ++ .../src/federation/feature-request.test.ts | 61 +++++++++++++++++++ .../fedify/src/federation/feature-request.ts | 5 ++ packages/fedify/src/utils/docloader.test.ts | 21 +++++++ packages/fedify/src/utils/docloader.ts | 4 ++ packages/vocab-runtime/src/docloader.test.ts | 20 ++++++ packages/vocab-runtime/src/docloader.ts | 6 +- 9 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 changes.d/fedify/document-url-schemes.md create mode 100644 changes.d/vocab-runtime/document-url-schemes.md diff --git a/CHANGES.md b/CHANGES.md index 43be2ebd7..730855d28 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,10 @@ To be released. ### @fedify/fedify + - Fixed `getAuthenticatedDocumentLoader()` skipping URL scheme validation + when `allowPrivateAddress` was enabled. Unsupported schemes are now + rejected before fetching, including redirect and alternate document + targets. [[#1291], [#1292]] - Fixed the inbox rejecting FEP-7aa9 `FeatureRequest` activities without an `actor`, allowing applications to receive collection inclusion requests from Mastodon. Requests are accepted only when authenticated @@ -17,6 +21,14 @@ To be released. [#1289]: https://github.com/fedify-dev/fedify/issues/1289 [#1291]: https://github.com/fedify-dev/fedify/pull/1291 +[#1292]: https://github.com/fedify-dev/fedify/issues/1292 + +### @fedify/vocab-runtime + + - Fixed `getDocumentLoader()` skipping URL scheme validation when + `allowPrivateAddress` was enabled. Unsupported schemes are now rejected + before fetching, including redirect and alternate document targets. + [[#1291], [#1292]] Version 2.4.2 diff --git a/changes.d/fedify/document-url-schemes.md b/changes.d/fedify/document-url-schemes.md new file mode 100644 index 000000000..705ec0a9c --- /dev/null +++ b/changes.d/fedify/document-url-schemes.md @@ -0,0 +1,4 @@ + - Fixed `getAuthenticatedDocumentLoader()` skipping URL scheme validation + when `allowPrivateAddress` was enabled. Unsupported schemes are now + rejected before fetching, including redirect and alternate document + targets. [[#1291], [#1292]] diff --git a/changes.d/vocab-runtime/document-url-schemes.md b/changes.d/vocab-runtime/document-url-schemes.md new file mode 100644 index 000000000..66cebfed6 --- /dev/null +++ b/changes.d/vocab-runtime/document-url-schemes.md @@ -0,0 +1,4 @@ + - Fixed `getDocumentLoader()` skipping URL scheme validation when + `allowPrivateAddress` was enabled. Unsupported schemes are now rejected + before fetching, including redirect and alternate document targets. + [[#1291], [#1292]] diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index de4142ef7..b7f88a4f3 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -1459,3 +1459,64 @@ for (const authentication of ["http", "ld"] as const) { }); } } + +for (const authentication of ["http", "ld", "proof"] as const) { + for ( + const scheme of [ + "ftp://example.com/collection", + "file:///collection", + ] as const + ) { + test(`actorless FeatureRequest rejects unsupported collection URL ${scheme} with ${authentication} authentication`, async () => { + let lookups = 0; + const instrument = new URL(scheme); + const result = await deliver(authentication, { + request: new FeatureRequest({ + id: new URL("https://example.com/requests/unsupported-scheme"), + instrument, + object: target, + }), + collectionLookupId: instrument, + onCollectionLookup: () => { + lookups++; + }, + collectionLoader: getDocumentLoader({ allowPrivateAddress: true }), + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + assertEquals(lookups, 0); + }); + for (const authenticatedLoader of [false, true]) { + test(`actorless FeatureRequest rejects redirect to ${scheme} using ${authenticatedLoader ? "authenticated" : "standard"} loader with ${authentication} authentication`, async () => { + let fetches = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = () => { + fetches++; + return Promise.resolve( + new Response(null, { status: 302, headers: { Location: scheme } }), + ); + }; + try { + const collectionLoader = authenticatedLoader + ? getAuthenticatedDocumentLoader({ + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, + }, { + allowPrivateAddress: true, + specDeterminer: { + determineSpec: () => Promise.resolve("rfc9421"), + rememberSpec: () => Promise.resolve(), + }, + }) + : getDocumentLoader({ allowPrivateAddress: true }); + const result = await deliver(authentication, { collectionLoader }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + assertEquals(fetches, 1); + } finally { + globalThis.fetch = originalFetch; + } + }); + } + } +} diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index 030981958..156a7ca9a 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -120,6 +120,11 @@ export async function resolveFeatureRequestActor( if (request.instrumentIds.length !== 1) return null; const id = request.instrumentIds[0]; if (id.username !== "" || id.password !== "") return null; + if ( + !isPortableId(id) && id.protocol !== "http:" && id.protocol !== "https:" + ) { + return null; + } // ID accessors omit anonymous embedded values. Count the parsed values, // without dereferencing them or reinterpreting the original context. const expanded = await request.toJsonLd({ format: "expand" }) as Record< diff --git a/packages/fedify/src/utils/docloader.test.ts b/packages/fedify/src/utils/docloader.test.ts index d2b4720cc..0ddc005a4 100644 --- a/packages/fedify/src/utils/docloader.test.ts +++ b/packages/fedify/src/utils/docloader.test.ts @@ -844,3 +844,24 @@ test("getAuthenticatedDocumentLoader() times out", async (t) => { } }); }); + +for (const url of ["ftp://example.com/collection", "file:///collection"]) { + test(`getAuthenticatedDocumentLoader() rejects ${url} with private addresses allowed before fetching`, async () => { + let fetches = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = () => { + fetches++; + return Promise.reject(new Error("Must not fetch")); + }; + try { + const loader = getAuthenticatedDocumentLoader({ + keyId: new URL("https://example.com/key2"), + privateKey: rsaPrivateKey2, + }, { allowPrivateAddress: true }); + await rejects(loader(url), UrlError); + deepStrictEqual(fetches, 0); + } finally { + globalThis.fetch = originalFetch; + } + }); +} diff --git a/packages/fedify/src/utils/docloader.ts b/packages/fedify/src/utils/docloader.ts index 4a6a4e05d..c1d04241a 100644 --- a/packages/fedify/src/utils/docloader.ts +++ b/packages/fedify/src/utils/docloader.ts @@ -132,6 +132,10 @@ export function getAuthenticatedDocumentLoader( url: string, options?: DocumentLoaderOptions, ): Promise { + const protocol = new URL(url).protocol; + if (protocol !== "http:" && protocol !== "https:") { + throw new UrlError(`Unsupported protocol: ${protocol}`); + } if (!allowPrivateAddress) { try { await validatePublicUrl(url); diff --git a/packages/vocab-runtime/src/docloader.test.ts b/packages/vocab-runtime/src/docloader.test.ts index 89d8d49eb..88f5b0360 100644 --- a/packages/vocab-runtime/src/docloader.test.ts +++ b/packages/vocab-runtime/src/docloader.test.ts @@ -1612,3 +1612,23 @@ test("getDocumentLoader() times out a stalled body of such a status", async () = await rejects(loader(url), (e) => isTimeoutFetchError(e, url)); }); }); + +for (const url of ["ftp://example.com/collection", "file:///collection"]) { + test(`getDocumentLoader() rejects ${url} with private addresses allowed before fetching`, async () => { + let fetches = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = () => { + fetches++; + return Promise.reject(new Error("Must not fetch")); + }; + try { + await rejects( + getDocumentLoader({ allowPrivateAddress: true })(url), + UrlError, + ); + deepStrictEqual(fetches, 0); + } finally { + globalThis.fetch = originalFetch; + } + }); +} diff --git a/packages/vocab-runtime/src/docloader.ts b/packages/vocab-runtime/src/docloader.ts index 27b3a2666..329a3384e 100644 --- a/packages/vocab-runtime/src/docloader.ts +++ b/packages/vocab-runtime/src/docloader.ts @@ -557,7 +557,11 @@ export function getDocumentLoader( visited = new Set(), ): Promise { options?.signal?.throwIfAborted(); - const currentUrl = new URL(url).href; + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw new UrlError(`Unsupported protocol: ${parsedUrl.protocol}`); + } + const currentUrl = parsedUrl.href; if (!skipPreloadedContexts && currentUrl in preloadedContexts) { logger.debug("Using preloaded context: {url}.", { url: currentUrl }); return { From dc6c318d824ff6e7417224250adefee8848f1f60 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 11 Oct 2026 03:29:09 +0900 Subject: [PATCH 23/24] Count list-wrapped collection attributions Match the generated decoder's list-container interpretation before counting owner values. Anonymous attributions must not disappear from this check. Keep accepting lists containing only one owner. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4238709093 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 36 +++++++++++++++++++ .../fedify/src/federation/feature-request.ts | 20 ++++++++--- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index b7f88a4f3..cbbfe0938 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -1520,3 +1520,39 @@ for (const authentication of ["http", "ld", "proof"] as const) { } } } + +for (const authentication of ["http", "ld", "proof"] as const) { + for (const anonymousFirst of [false, true]) { + test(`actorless FeatureRequest rejects list-wrapped anonymous attribution ${anonymousFirst ? "before" : "after"} the owner with ${authentication} authentication`, async () => { + const document = await new FeaturedCollection({ + id: collectionId, + attribution: owner, + }).toJsonLd() as Record; + const anonymous = { type: "Person", name: "No owner ID" }; + document.attributedTo = { + "@list": anonymousFirst + ? [anonymous, owner.href] + : [owner.href, anonymous], + }; + const result = await deliver(authentication, { + collectionLoader: (url) => + Promise.resolve({ document, documentUrl: url, contextUrl: null }), + }); + assertEquals(result.response.status, 400); + assertEquals(result.received, []); + }); + } + test(`actorless FeatureRequest accepts a list-wrapped single owner with ${authentication} authentication`, async () => { + const document = await new FeaturedCollection({ + id: collectionId, + attribution: owner, + }).toJsonLd() as Record; + document.attributedTo = { "@list": [owner.href] }; + const result = await deliver(authentication, { + collectionLoader: (url) => + Promise.resolve({ document, documentUrl: url, contextUrl: null }), + }); + assertEquals(result.response.status, 202); + assertEquals(result.received.length, 1); + }); +} diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index 156a7ca9a..77ff2ef48 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -81,6 +81,16 @@ function isPermanentCollectionError(error: unknown): boolean { ); } +// Match the generated decoder's single-list-container interpretation. +function expandedPropertyValues(value: unknown): unknown[] { + if (!Array.isArray(value)) return []; + const first = value[0]; + return value.length === 1 && first != null && typeof first === "object" && + "@list" in first && Array.isArray(first["@list"]) + ? first["@list"] + : value; +} + // TODO: Preserve unparsed property presence with the verified view in #1290: // https://github.com/fedify-dev/fedify/issues/1290 export function captureFeatureRequestActorPresence(loader: DocumentLoader) { @@ -215,10 +225,12 @@ export async function resolveFeatureRequestActor( expandedCollection ??= await collection.toJsonLd({ format: "expand", }) as Record[]; - const attributions = expandedCollection[0]?.[ - "https://www.w3.org/ns/activitystreams#attributedTo" - ]; - if (!Array.isArray(attributions) || attributions.length !== 1) return null; + const attributions = expandedPropertyValues( + expandedCollection[0]?.[ + "https://www.w3.org/ns/activitystreams#attributedTo" + ], + ); + if (attributions.length !== 1) return null; return collection.attributionIds[0]; } From 356d03f5e79fc1a5995b0f957609252365b14905 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 11 Oct 2026 03:29:27 +0900 Subject: [PATCH 24/24] Reject malformed collection language tags Identify malformed hreflang values in the already-expanded document using the generated decoder's language-tag normalization. Treat these parser failures as permanent while preserving captured loader failures for retry. Avoid changing public parser exception types in this patch. https://github.com/fedify-dev/fedify/pull/1291#discussion_r4238709098 Assisted-by: Codex:gpt-6.1-sol --- .../src/federation/feature-request.test.ts | 19 ++++++++++ .../fedify/src/federation/feature-request.ts | 38 ++++++++++++++++--- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/fedify/src/federation/feature-request.test.ts b/packages/fedify/src/federation/feature-request.test.ts index cbbfe0938..a2189e353 100644 --- a/packages/fedify/src/federation/feature-request.test.ts +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -1555,4 +1555,23 @@ for (const authentication of ["http", "ld", "proof"] as const) { assertEquals(result.response.status, 202); assertEquals(result.received.length, 1); }); + for (const language of ["not_a_locale", "en-US"]) { + test(`actorless FeatureRequest handles collection language ${language} with ${authentication} authentication`, async () => { + const document = await new FeaturedCollection({ + id: collectionId, + attribution: owner, + }).toJsonLd() as Record; + document.attachment = { + type: "Link", + href: "https://example.com/link", + hreflang: language, + }; + const result = await deliver(authentication, { + collectionLoader: (url) => + Promise.resolve({ document, documentUrl: url, contextUrl: null }), + }); + assertEquals(result.response.status, language === "en-US" ? 202 : 400); + assertEquals(result.received.length, language === "en-US" ? 1 : 0); + }); + } } diff --git a/packages/fedify/src/federation/feature-request.ts b/packages/fedify/src/federation/feature-request.ts index 77ff2ef48..74d34e4c7 100644 --- a/packages/fedify/src/federation/feature-request.ts +++ b/packages/fedify/src/federation/feature-request.ts @@ -8,6 +8,7 @@ import { import { type DocumentLoader, FetchError, + normalizeLanguageTag, UrlError, } from "@fedify/vocab-runtime"; import jsonld from "@fedify/vocab-runtime/jsonld"; @@ -91,6 +92,32 @@ function expandedPropertyValues(value: unknown): unknown[] { : value; } +// TODO: Replace these parser-specific checks with structured failures in #1290: +// https://github.com/fedify-dev/fedify/issues/1290 +function hasMalformedCollectionLanguage(value: unknown): boolean { + if (Array.isArray(value)) return value.some(hasMalformedCollectionLanguage); + if (value == null || typeof value !== "object") return false; + const node = value as Record; + if ("@value" in node) return false; // Raw @json extension data is opaque. + for (const [key, child] of Object.entries(node)) { + if (key === "https://www.w3.org/ns/activitystreams#hreflang") { + for (const item of expandedPropertyValues(child)) { + if ( + item == null || typeof item !== "object" || !("@value" in item) || + typeof item["@value"] !== "string" || "@language" in item + ) continue; + try { + new Intl.Locale(normalizeLanguageTag(item["@value"])); + } catch (error) { + if (error instanceof RangeError) return true; + throw error; + } + } + } else if (hasMalformedCollectionLanguage(child)) return true; + } + return false; +} + // TODO: Preserve unparsed property presence with the verified view in #1290: // https://github.com/fedify-dev/fedify/issues/1290 export function captureFeatureRequestActorPresence(loader: DocumentLoader) { @@ -197,14 +224,15 @@ export async function resolveFeatureRequestActor( } } catch (error) { if (retryableFailure != null) throw retryableFailure.error; - // Only a positively identified malformed temporal literal is permanent. + // Only positively identified malformed parser literals are permanent. // Loader RangeErrors were captured above and must remain retryable. if ( error instanceof RangeError && collectionDocument != null && - await hasMalformedKnownTemporalLiteral( - collectionDocument, - context.contextLoader, - ) + (hasMalformedCollectionLanguage(expandedCollection) || + await hasMalformedKnownTemporalLiteral( + collectionDocument, + context.contextLoader, + )) ) return null; // JSON-LD may wrap loader errors. Preserve the original classification // rather than treating a malformed remote context as a transient outage.