Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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,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]

Expand All @@ -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
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;
}

/**
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
134 changes: 134 additions & 0 deletions packages/fedify/src/federation/handler_requirehttpsig.test.ts
Original file line number Diff line number Diff line change
@@ -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<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 });
},
...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