diff --git a/CHANGES.md b/CHANGES.md index dc4947b62..730855d28 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,28 @@ Version 2.4.3 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 + 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 +[#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/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/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 new file mode 100644 index 000000000..a2189e353 --- /dev/null +++ b/packages/fedify/src/federation/feature-request.test.ts @@ -0,0 +1,1577 @@ +import { mockDocumentLoader, test } from "@fedify/fixture"; +import { + Activity, + Create, + FeaturedCollection, + FeatureRequest, + Object as ActivityObject, + Person, +} from "@fedify/vocab"; +import { + type DocumentLoader, + exportDidKey, + FetchError, + getDocumentLoader, + 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"; +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 [ + 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, + `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, + ); +}); + +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; + } + }); +} + +type Authentication = "http" | "ld" | "proof" | "mixed" | "unsigned"; + +async function deliver( + authentication: Authentication, + options: { + collection?: FeaturedCollection | Person; + collectionDocumentUrl?: string; + collectionLookupId?: URL; + collectionLoader?: DocumentLoader; + relayObject?: URL; + relayContextLoads?: number[]; + proofKeyId?: URL; + uncoveredAttributionAt?: number; + relayInstrument?: URL; + replayedObject?: URL; + request?: Activity; + rawActor?: unknown; + queued?: boolean; + skip?: boolean; + reparsedInstrument?: URL; + replayedInstrument?: URL; + onCollectionLookup?: () => void; + } = {}, +) { + 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; + 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": { + ...(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( + ".", + (options.relayContextLoads?.includes(contextLoads) ?? + contextLoads % 2 === 1) && options.relayObject != null + ? options.relayObject + : !collectionAvailable && options.replayedObject != null + ? options.replayedObject + : target, + ).href, + featured: new URL( + ".", + (options.relayContextLoads?.includes(contextLoads) ?? + contextLoads % 2 === 1) && options.relayInstrument != null + ? options.relayInstrument + : !collectionAvailable && options.replayedInstrument != null + ? options.replayedInstrument + : contextLoads > 1 && options.reparsedInstrument != null + ? options.reparsedInstrument + : collectionId, + ).href, + }, + }, + documentUrl: url, + contextUrl: null, + }; + } + if ( + (url.startsWith("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) => { + if ( + url === options.reparsedInstrument?.href || + url === options.relayInstrument?.href + ) { + return { + document: await new FeaturedCollection({ + id: new URL(url), + attribution: new URL("https://example.com/person3"), + }).toJsonLd(), + documentUrl: url, + contextUrl: null, + }; + } + if (url === (options.collectionLookupId ?? 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(), + documentUrl: options.collectionDocumentUrl ?? 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 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": { + ...(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, + }, + }, + documentUrl: url, + contextUrl: null, + }) + : mockDocumentLoader(url); + const relayContextLoader: DocumentLoader = (url) => + url === contextUrl + ? 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, + }, + }, + documentUrl: url, + contextUrl: null, + }) + : mockDocumentLoader(url); + const changingRelay = options.relayObject != null || + options.relayInstrument != null; + 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, + ed25519PrivateKey, + options.proofKeyId ?? ed25519Multikey.id!, + { + contextLoader: changingRelay + ? ownerContextLoader + : mockDocumentLoader, + context: changingRelay ? relayContext : undefined, + }, + )).toJsonLd({ + format: "compact", + contextLoader: changingRelay ? ownerContextLoader : mockDocumentLoader, + context: changingRelay ? relayContext : undefined, + }), + rsaPrivateKey2, + rsaPublicKey2.id!, + { + contextLoader: changingRelay ? relayContextLoader : mockDocumentLoader, + }, + ) + : authentication === "ld" + ? await signJsonLd( + await activity.toJsonLd(), + rsaPrivateKey3, + rsaPublicKey3.id!, + { + contextLoader: mockDocumentLoader, + }, + ) + : authentication === "proof" + ? await (await signObject( + activity, + ed25519PrivateKey, + ed25519Multikey.id!, + { + 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({ + 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 ( + authentication !== "proof" && + (options.reparsedInstrument != null || options.replayedInstrument != null || + options.replayedObject != null) + ) { + const document = body as Record; + document["@context"] = [ + ...(Array.isArray(document["@context"]) + ? document["@context"] + : [document["@context"]]), + contextUrl, + ]; + document.instrument = "featured:1"; + if (options.replayedObject != null) document.object = "receiver:bob"; + } + 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: () => contextLoader, + }); + 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, + }); + 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, + get replayContextLoads() { + return replayContextLoads; + }, + 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]); + } + }, + }; +} + +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", "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, + ); +}); + +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); + }); + } +} + +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", + }); + 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); +}); + +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 () => { + 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 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); + 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", + ), + ); +}); + +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, []); + }); +} + +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, + ); +}); + +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; + } + }); + } + } +} + +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); + }); + } +} + +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; + } +}); + +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); + }); +} + +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 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]) { + 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, []); + }); +} + +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, + ); +}); + +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, []); + }); + } +} + +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; + } + }); + } + } +} + +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); + }); + 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 new file mode 100644 index 000000000..74d34e4c7 --- /dev/null +++ b/packages/fedify/src/federation/feature-request.ts @@ -0,0 +1,290 @@ +import type { Context } from "./context.ts"; +import { hasMalformedKnownTemporalLiteral } from "./temporal.ts"; +import { + FeaturedCollection, + type FeatureRequest, + Object as ActivityObject, +} from "@fedify/vocab"; +import { + type DocumentLoader, + FetchError, + normalizeLanguageTag, + 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 { + getPortableDid, + isPortableId, + 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; + // 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 ( + error.name === "jsonld.SyntaxError" && + details?.code !== "loading remote context failed" + ) return true; + if ( + 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; + } + // 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; + } + // 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 && ( + // 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 + ); +} + +// 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: 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) { + 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( + context: Context, + request: FeatureRequest, +): Promise { + 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< + 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. + // 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 { + 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 }; + throw error; + } + }; + let collection: ActivityObject | null; + let collectionDocument: unknown; + let expandedCollection: Record[] | undefined; + 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); + 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. + if (documentUrl.origin !== id.origin) return null; + // 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), + baseUrl: documentUrl, + }); + } + } catch (error) { + if (retryableFailure != null) throw retryableFailure.error; + // Only positively identified malformed parser literals are permanent. + // Loader RangeErrors were captured above and must remain retryable. + if ( + error instanceof RangeError && collectionDocument != null && + (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. + if ( + permanentFailure || isPermanentCollectionError(error) || + error instanceof TypeError + ) return null; + throw error; + } + if ( + !(collection instanceof FeaturedCollection) || collection.id == null || + !isSameObjectId(collection.id, id) || collection.attributionIds.length !== 1 + ) { + 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 = expandedPropertyValues( + expandedCollection[0]?.[ + "https://www.w3.org/ns/activitystreams#attributedTo" + ], + ); + if (attributions.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..87995a92e 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,11 @@ import { wrapContextLoaderForJsonLd, } from "../sig/ld.ts"; import { doesActorOwnKey } from "../sig/owner.ts"; +import { + captureFeatureRequestActorPresence, + hasFeatureRequestSignature, + resolveFeatureRequestActor, +} from "./feature-request.ts"; import { getCanonicalPortableId, getPortableDid, @@ -2118,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, @@ -2130,7 +2144,7 @@ async function handleInboxInternal( compactedJsonWithoutSig, { ...ctx, - contextLoader: getNormalizationContextLoader(ctx.contextLoader), + contextLoader: actorPresence.contextLoader, }, ); } catch (error) { @@ -2177,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, @@ -2246,6 +2260,129 @@ 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 && + (skipSignatureVerification || observation.verification.attempts.some( + (attempt) => + attempt.subject.pointer === "" && attempt.status === "verified" && + (attempt.mechanism === "linkedData" || + attempt.mechanism === "objectIntegrity") && + 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) { + return await respondInvalidActivity( + new TypeError( + "FeatureRequest must reference a collection with one owner.", + ), + ); + } + // 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( + 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 { + 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: ownerActorPresence.contextLoader, + documentLoader: ctx.documentLoader, + keyCache, + meterProvider, + tracerProvider, + verifyPortableObject: ctx.verifyPortableObject, + }); + if (verifiedActivity != null) { + if ( + !(verifiedActivity instanceof FeatureRequest) || + verifiedActivity.actorIds.length !== 0 || + await ownerActorPresence.hasActor(jsonWithoutSig) + ) { + 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; + actorPresence = ownerActorPresence; + actorPresenceDocument = jsonWithoutSig; + featureRequestProofAttempts = observation.verification.attempts.slice( + attemptStart, + ); + 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); + } + } + proofVerified = hasFeatureRequestSignature( + featureRequestProofAttempts, + featureRequestActor, + "objectIntegrity", + ); + 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. + 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 // to avoid consuming nonces on requests that will be rejected anyway. @@ -2408,18 +2545,50 @@ 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) { 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) { + 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) { + 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"; @@ -2700,6 +2869,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 @@ -2719,10 +2899,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, 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..71ef9c882 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, @@ -2521,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) { @@ -2563,6 +2565,22 @@ export class FederationImpl parseInput, parseContext, ); + // TODO: Replace inferred actor restoration with shared principal replay. + // https://github.com/fedify-dev/fedify/issues/1290 + if (message.featureRequestActor != null) { + if ( + !(activity instanceof FeatureRequest) || + activity.actorIds.length !== 0 || + message.normalizedActivity == null + ) { + throw new TypeError( + "FeatureRequest is missing its authenticated replay snapshot.", + ); + } + 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..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; @@ -171,6 +176,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; 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 {