Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ 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]

[#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
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/src/inbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand All @@ -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,
Expand Down Expand Up @@ -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<Response> {
return async function fetch(request: Request): Promise<Response> {
const timestamp = Temporal.Now.instant();
Expand All @@ -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),
Expand Down
7 changes: 7 additions & 0 deletions packages/fedify/src/federation/federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1234,6 +1234,13 @@ export interface FederationFetchOptions<TContextData> {
* @since 0.7.0
*/
onUnauthorized?: (request: Request) => Response | Promise<Response>;

/**
* Whether to require HTTP Signatures for all incoming activities.
* By default, this is `false`
* @since 2.0.0
*/
requireHttpSignature?: boolean;
Copy link
Member

Choose a reason for hiding this comment

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

The current implementation adds a new requireHttpSignature field to FederationFetchOptions and InboxHandlerParameters, which duplicates functionality that already exists in Fedify's .authorize() callback API.

According to our access control documentation, the standard way to enable authorized fetch in Fedify is through the .authorize() method:

federation
  .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
    // ...
  })
  .authorize(async (ctx, identifier) => {
    const signedKeyOwner = await ctx.getSignedKeyOwner();
    if (signedKeyOwner == null) return false;
    return !await isBlocked(identifier, signedKeyOwner);
  });

The .authorize() callback provides a flexible, dispatcher-level mechanism for access control that:

  • Follows Fedify's established patterns
  • Allows fine-grained control (per-actor, per-collection, etc.)
  • Is already documented and understood by users
  • Supports complex authorization logic beyond simple HTTP signature checks

Instead of adding a new field that bypasses the existing API, I suggest implementing the -A/--authorized-fetch option by registering an .authorize() callback in the CLI code:

// In packages/cli/src/inbox.tsx
federation
  .setInboxListeners("/users/{identifier}/inbox", "/inbox")
  .authorize(async (ctx, identifier) => {
    if (!command.authorizedFetch) return true;  // Allow all if -A not set
    const signedKeyOwner = await ctx.getSignedKeyOwner();
    return signedKeyOwner != null;  // Only allow signed requests
  });

}

/**
Expand Down
10 changes: 9 additions & 1 deletion packages/fedify/src/federation/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ export interface InboxHandlerParameters<TContextData> {
onNotFound(request: Request): Response | Promise<Response>;
signatureTimeWindow: Temporal.Duration | Temporal.DurationLike | false;
skipSignatureVerification: boolean;
requireHttpSignature?: boolean;
idempotencyStrategy?:
| IdempotencyStrategy
| IdempotencyKeyCallback<TContextData>;
Expand Down Expand Up @@ -601,6 +602,7 @@ async function handleInboxInternal<TContextData>(
onNotFound,
signatureTimeWindow,
skipSignatureVerification,
requireHttpSignature,
tracerProvider,
} = parameters;
const logger = getLogger(["fedify", "federation", "inbox"]);
Expand Down Expand Up @@ -737,7 +739,10 @@ async function handleInboxInternal<TContextData>(
}
}
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,
Expand Down Expand Up @@ -768,6 +773,9 @@ async function handleInboxInternal<TContextData>(
}
httpSigKey = key;
}
}
// Parse activity if not already parsed
if (activity == null) {
activity = await Activity.fromJsonLd(jsonWithoutSig, ctx);
}
if (activity.id != null) {
Expand Down
137 changes: 137 additions & 0 deletions packages/fedify/src/federation/handler_requirehttpsig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The tests for requireHttpSignature are good, but they don't seem to cover a specific edge case. The current tests use an unsigned activity body, so activity is null inside handleInbox before the HTTP signature check. This means the tests only validate the activity == null part of the condition activity == null || (requireHttpSignature ?? false).

It would be beneficial to add a test case for when an activity is successfully parsed from a Linked Data Signature (so activity != null), but requireHttpSignature: true is also set. This would ensure that HTTP signature verification is correctly enforced even when a valid LD-Signature is present.

A suggested test flow would be:

  1. Create a request with a body containing a valid LD-Signed activity.
  2. Call handleInbox with this request and requireHttpSignature: true. It should fail with a 401 because there's no HTTP signature.
  3. Sign the same request with an HTTP signature.
  4. Call handleInbox again. It should now succeed with a 202.

This would provide more complete coverage for this new security feature.

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<void>({ kv: new MemoryKvStore() });
const unsignedContext = createRequestContext({
federation,
request: unsignedRequest,
url: new URL(unsignedRequest.url),
data: undefined,
});

const actorDispatcher: ActorDispatcher<void> = (_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 });
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The clone: undefined property is unnecessary and potentially confusing. The options parameter of createInboxContext is of type RequestContextOptions, which does not have a clone property. The unsignedContext object also does not have a clone property, so there is no need to explicitly set it to undefined. This should be removed for clarity. This applies to all similar occurrences in this file.

Suggested change
return createInboxContext({ ...unsignedContext, clone: undefined });
return createInboxContext({ ...unsignedContext });

},
...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",
);
});
2 changes: 2 additions & 0 deletions packages/fedify/src/federation/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,7 @@ export class FederationImpl<TContextData>
onNotAcceptable,
onUnauthorized,
contextData,
requireHttpSignature,
span,
tracer,
}: FederationFetchOptions<TContextData> & { span: Span; tracer: Tracer },
Expand Down Expand Up @@ -1363,6 +1364,7 @@ export class FederationImpl<TContextData>
onNotFound,
signatureTimeWindow: this.signatureTimeWindow,
skipSignatureVerification: this.skipSignatureVerification,
requireHttpSignature,
tracerProvider: this.tracerProvider,
idempotencyStrategy: this.idempotencyStrategy,
});
Expand Down
Loading