diff --git a/CHANGES.md b/CHANGES.md index eebe2c34..d8afbc8d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -81,6 +81,8 @@ To be released. `[string | URL | URLPattern, Temporal.Duration | Temporal.DurationLike][]` (was `[string | URL | URLPattern, Temporal.Duration][]`). + - Adds the `-A`/`--authorized-fetch` flag to the `fedify inbox` command. [[#229], [#472] By Lee ByeongJun] + - The `@fedify/fedify/x/*` modules are removed. Also, there are no Fresh integration for now. [[#391] by Chanhaeng Lee] @@ -90,6 +92,7 @@ To be released. - Removed `@fedify/fedify/x/sveltekit` in favor of `@fedify/sveltekit`. - Removed `@fedify/fedify/x/fresh` (Fresh integration). [[#466]] +[#229]: https://github.com/fedify-dev/fedify/issues/229 [#280]: https://github.com/fedify-dev/fedify/issues/280 [#366]: https://github.com/fedify-dev/fedify/issues/366 [#376]: https://github.com/fedify-dev/fedify/issues/376 diff --git a/packages/cli/src/inbox.tsx b/packages/cli/src/inbox.tsx index d088ff14..2815be92 100644 --- a/packages/cli/src/inbox.tsx +++ b/packages/cli/src/inbox.tsx @@ -103,6 +103,14 @@ export const inboxCommand = command( }), "An ephemeral ActivityPub inbox for testing purposes.", ), + authorizedFetch: option( + "-A", + "--authorized-fetch", + { + description: + message`Require HTTP Signatures for all incoming requests. Returns 401 for unsigned requests.`, + }, + ), }), debugOption, ), @@ -119,6 +127,7 @@ export async function runInbox( const fetch = createFetchHandler({ actorName: command.actorName, actorSummary: command.actorSummary, + requireHttpSignature: command.authorizedFetch, }); const sendDeleteToPeers = createSendDeleteToPeers({ actorName: command.actorName, @@ -499,7 +508,11 @@ app.get("/r/:idx{[0-9]+}", (c) => { }); function createFetchHandler( - actorOptions: { actorName: string; actorSummary: string }, + actorOptions: { + actorName: string; + actorSummary: string; + requireHttpSignature?: boolean; + }, ): (request: Request) => Promise { return async function fetch(request: Request): Promise { const timestamp = Temporal.Now.instant(); @@ -521,6 +534,7 @@ function createFetchHandler( actorName: actorOptions.actorName, actorSummary: actorOptions.actorSummary, }, + requireHttpSignature: actorOptions.requireHttpSignature, onNotAcceptable: app.fetch.bind(app), onNotFound: app.fetch.bind(app), onUnauthorized: app.fetch.bind(app), diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index d3a2b7a9..afefb344 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -1234,6 +1234,13 @@ export interface FederationFetchOptions { * @since 0.7.0 */ onUnauthorized?: (request: Request) => Response | Promise; + + /** + * Whether to require HTTP Signatures for all incoming activities. + * By default, this is `false` + * @since 2.0.0 + */ + requireHttpSignature?: boolean; } /** diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index d2bd3748..53321e78 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -534,6 +534,7 @@ export interface InboxHandlerParameters { onNotFound(request: Request): Response | Promise; signatureTimeWindow: Temporal.Duration | Temporal.DurationLike | false; skipSignatureVerification: boolean; + requireHttpSignature?: boolean; idempotencyStrategy?: | IdempotencyStrategy | IdempotencyKeyCallback; @@ -601,6 +602,7 @@ async function handleInboxInternal( onNotFound, signatureTimeWindow, skipSignatureVerification, + requireHttpSignature, tracerProvider, } = parameters; const logger = getLogger(["fedify", "federation", "inbox"]); @@ -737,7 +739,10 @@ async function handleInboxInternal( } } let httpSigKey: CryptographicKey | null = null; - if (activity == null) { + // Check if HTTP Signature verification is needed + const needsHttpSigVerification = activity == null || + (requireHttpSignature ?? false); + if (needsHttpSigVerification) { if (!skipSignatureVerification) { const key = await verifyRequest(request, { contextLoader: ctx.contextLoader, @@ -768,6 +773,9 @@ async function handleInboxInternal( } httpSigKey = key; } + } + // Parse activity if not already parsed + if (activity == null) { activity = await Activity.fromJsonLd(jsonWithoutSig, ctx); } if (activity.id != null) { diff --git a/packages/fedify/src/federation/handler_requirehttpsig.test.ts b/packages/fedify/src/federation/handler_requirehttpsig.test.ts new file mode 100644 index 00000000..2e1a5551 --- /dev/null +++ b/packages/fedify/src/federation/handler_requirehttpsig.test.ts @@ -0,0 +1,134 @@ +import { assertEquals } from "@std/assert"; +import { signRequest } from "../sig/http.ts"; +import { + createInboxContext, + createRequestContext, +} from "../testing/context.ts"; +import { mockDocumentLoader } from "../testing/docloader.ts"; +import { rsaPrivateKey3, rsaPublicKey3 } from "../testing/keys.ts"; +import { test } from "../testing/mod.ts"; +import { Create, Note, Person } from "../vocab/vocab.ts"; +import type { ActorDispatcher } from "./callback.ts"; +import { handleInbox } from "./handler.ts"; +import { MemoryKvStore } from "./kv.ts"; +import { createFederation } from "./middleware.ts"; + +test("handleInbox() with requireHttpSignature option", async () => { + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + actor: new URL("https://example.com/person2"), + object: new Note({ + id: new URL("https://example.com/notes/1"), + attribution: new URL("https://example.com/person2"), + content: "Hello, world!", + }), + }); + + const unsignedRequest = new Request("https://example.com/inbox", { + method: "POST", + headers: { "Content-Type": "application/activity+json" }, + body: JSON.stringify(await activity.toJsonLd()), + }); + + const federation = createFederation({ kv: new MemoryKvStore() }); + const unsignedContext = createRequestContext({ + federation, + request: unsignedRequest, + url: new URL(unsignedRequest.url), + data: undefined, + }); + + const actorDispatcher: ActorDispatcher = (_ctx, identifier) => { + if (identifier !== "testuser") return null; + return new Person({ name: "Test User" }); + }; + + const onNotFound = () => new Response("Not found", { status: 404 }); + + const baseInboxOptions = { + kv: new MemoryKvStore(), + kvPrefixes: { + activityIdempotence: ["_fedify", "activityIdempotence"] as const, + publicKey: ["_fedify", "publicKey"] as const, + }, + actorDispatcher, + onNotFound, + signatureTimeWindow: { minutes: 5 } as const, + skipSignatureVerification: false, + }; + + let response = await handleInbox(unsignedRequest, { + recipient: null, + context: unsignedContext, + inboxContextFactory(_activity) { + return createInboxContext({ ...unsignedContext, clone: undefined }); + }, + ...baseInboxOptions, + requireHttpSignature: false, + }); + assertEquals( + response.status, + 401, + "Without HTTP Sig and no LD Sig/OIP, should return 401", + ); + + response = await handleInbox(unsignedRequest.clone() as Request, { + recipient: null, + context: unsignedContext, + inboxContextFactory(_activity) { + return createInboxContext({ ...unsignedContext, clone: undefined }); + }, + ...baseInboxOptions, + requireHttpSignature: true, + }); + assertEquals( + response.status, + 401, + "With requireHttpSignature: true and no HTTP Sig, should return 401", + ); + + const signedRequest = await signRequest( + unsignedRequest.clone() as Request, + rsaPrivateKey3, + rsaPublicKey3.id!, + ); + const signedContext = createRequestContext({ + federation, + request: signedRequest, + url: new URL(signedRequest.url), + data: undefined, + documentLoader: mockDocumentLoader, + }); + + response = await handleInbox(signedRequest, { + recipient: null, + context: signedContext, + inboxContextFactory(_activity) { + return createInboxContext({ ...signedContext, clone: undefined }); + }, + ...baseInboxOptions, + requireHttpSignature: true, + }); + assertEquals( + response.status, + 202, + "With requireHttpSignature: true and valid HTTP Sig, should succeed", + ); + + // `skipSignatureVerification` takes precedence over `requireHttpSignature` + response = await handleInbox(unsignedRequest.clone() as Request, { + recipient: null, + context: unsignedContext, + inboxContextFactory(_activity) { + return createInboxContext({ ...unsignedContext, clone: undefined }); + }, + ...baseInboxOptions, + skipSignatureVerification: true, + requireHttpSignature: true, + }); + assertEquals( + response.status, + 202, + "With skipSignatureVerification: true, should succeed even if requireHttpSignature: true", + ); +}); diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index a9453942..48ecc6d9 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -1235,6 +1235,7 @@ export class FederationImpl onNotAcceptable, onUnauthorized, contextData, + requireHttpSignature, span, tracer, }: FederationFetchOptions & { span: Span; tracer: Tracer }, @@ -1363,6 +1364,7 @@ export class FederationImpl onNotFound, signatureTimeWindow: this.signatureTimeWindow, skipSignatureVerification: this.skipSignatureVerification, + requireHttpSignature, tracerProvider: this.tracerProvider, idempotencyStrategy: this.idempotencyStrategy, });