mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
refactor(federation): ♻️ Correctly handle bridge requests and instance signatures in user inboxes
This commit is contained in:
parent
afc5a74a40
commit
ace6921447
|
|
@ -2,7 +2,7 @@ import { apiRoute, applyConfig } from "@/api";
|
|||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import type { Entity } from "@versia/federation/types";
|
||||
import { User } from "@versia/kit/db";
|
||||
import { Instance, User } from "@versia/kit/db";
|
||||
import { z } from "zod";
|
||||
import { InboxProcessor } from "~/classes/inbox/processor";
|
||||
import { ErrorSchema } from "~/types/api";
|
||||
|
|
@ -23,9 +23,9 @@ export const schemas = {
|
|||
uuid: z.string().uuid(),
|
||||
}),
|
||||
header: z.object({
|
||||
"x-signature": z.string(),
|
||||
"x-nonce": z.string(),
|
||||
"x-signed-by": z.string().url().or(z.literal("instance")),
|
||||
"x-signature": z.string().optional(),
|
||||
"x-nonce": z.string().optional(),
|
||||
"x-signed-by": z.string().url().or(z.string().startsWith("instance ")),
|
||||
authorization: z.string().optional(),
|
||||
}),
|
||||
body: z.any(),
|
||||
|
|
@ -113,24 +113,37 @@ export default apiRoute((app) =>
|
|||
|
||||
const sender = await User.resolve(signedBy);
|
||||
|
||||
if (!sender) {
|
||||
if (!(sender || signedBy.startsWith("instance "))) {
|
||||
return context.json(
|
||||
{ error: `Couldn't resolve sender ${signedBy}` },
|
||||
{ error: `Couldn't resolve sender URI ${signedBy}` },
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
if (sender?.isLocal()) {
|
||||
return context.json(
|
||||
{ error: "Cannot send federation requests to local users" },
|
||||
{
|
||||
error: "Cannot process federation requests from local users",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const remoteInstance = sender
|
||||
? await Instance.fromUser(sender)
|
||||
: await Instance.resolveFromHost(signedBy.split(" ")[1]);
|
||||
|
||||
if (!remoteInstance) {
|
||||
return context.json(
|
||||
{ error: "Could not resolve the remote instance." },
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
const processor = new InboxProcessor(
|
||||
context,
|
||||
body,
|
||||
sender,
|
||||
remoteInstance,
|
||||
{
|
||||
signature,
|
||||
nonce,
|
||||
|
|
|
|||
|
|
@ -319,6 +319,18 @@ export class Instance extends BaseInterface<typeof Instances> {
|
|||
}
|
||||
}
|
||||
|
||||
public static resolveFromHost(host: string): Promise<Instance> {
|
||||
if (host.startsWith("http")) {
|
||||
const url = new URL(host).host;
|
||||
|
||||
return Instance.resolve(url);
|
||||
}
|
||||
|
||||
const url = new URL(`https://${host}`);
|
||||
|
||||
return Instance.resolve(url.origin);
|
||||
}
|
||||
|
||||
public static async resolve(url: string): Promise<Instance> {
|
||||
const logger = getLogger("federation");
|
||||
const host = new URL(url).host;
|
||||
|
|
@ -346,6 +358,7 @@ export class Instance extends BaseInterface<typeof Instances> {
|
|||
version: metadata.software.version,
|
||||
logo: metadata.logo,
|
||||
protocol,
|
||||
publicKey: metadata.public_key,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
|
||||
import { SignatureValidator } from "@versia/federation";
|
||||
import type { Entity, Note as VersiaNote } from "@versia/federation/types";
|
||||
import { Note, Notification, Relationship, User } from "@versia/kit/db";
|
||||
import {
|
||||
type Instance,
|
||||
Note,
|
||||
Notification,
|
||||
Relationship,
|
||||
User,
|
||||
} from "@versia/kit/db";
|
||||
import type { Context } from "hono";
|
||||
import { ValidationError } from "zod-validation-error";
|
||||
import { config } from "~/packages/config-manager/index.ts";
|
||||
|
|
@ -71,7 +77,7 @@ mock.module("~/packages/config-manager/index.ts", () => ({
|
|||
describe("InboxProcessor", () => {
|
||||
let mockContext: Context;
|
||||
let mockBody: Entity;
|
||||
let mockSender: User;
|
||||
let mockSenderInstance: Instance;
|
||||
let mockHeaders: {
|
||||
signature: string;
|
||||
nonce: string;
|
||||
|
|
@ -98,12 +104,14 @@ describe("InboxProcessor", () => {
|
|||
} as unknown as Context;
|
||||
|
||||
// Setup basic mock sender
|
||||
mockSender = {
|
||||
mockSenderInstance = {
|
||||
id: "test-id",
|
||||
data: {
|
||||
publicKey: "test-key",
|
||||
publicKey: {
|
||||
key: "test-key",
|
||||
},
|
||||
},
|
||||
} as unknown as User;
|
||||
} as unknown as Instance;
|
||||
|
||||
// Setup basic mock headers
|
||||
mockHeaders = {
|
||||
|
|
@ -118,7 +126,7 @@ describe("InboxProcessor", () => {
|
|||
processor = new InboxProcessor(
|
||||
mockContext,
|
||||
mockBody,
|
||||
mockSender,
|
||||
mockSenderInstance,
|
||||
mockHeaders,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import type {
|
|||
User as VersiaUser,
|
||||
} from "@versia/federation/types";
|
||||
import {
|
||||
Instance,
|
||||
type Instance,
|
||||
Like,
|
||||
Note,
|
||||
Notification,
|
||||
|
|
@ -70,7 +70,7 @@ export class InboxProcessor {
|
|||
*
|
||||
* @param context Hono request context.
|
||||
* @param body Entity JSON body.
|
||||
* @param sender Sender of the request (from X-Signed-By header).
|
||||
* @param senderInstance Sender of the request's instance (from X-Signed-By header).
|
||||
* @param headers Various request headers.
|
||||
* @param logger LogTape logger instance.
|
||||
* @param requestIp Request IP address. Grabs it from the Hono context if not provided.
|
||||
|
|
@ -78,10 +78,10 @@ export class InboxProcessor {
|
|||
public constructor(
|
||||
private context: Context,
|
||||
private body: Entity,
|
||||
private sender: User,
|
||||
private senderInstance: Instance,
|
||||
private headers: {
|
||||
signature: string;
|
||||
nonce: string;
|
||||
signature?: string;
|
||||
nonce?: string;
|
||||
authorization?: string;
|
||||
},
|
||||
private logger: Logger = getLogger(["federation", "inbox"]),
|
||||
|
|
@ -94,14 +94,25 @@ export class InboxProcessor {
|
|||
* @returns {Promise<boolean>} - Whether the signature is valid.
|
||||
*/
|
||||
private async isSignatureValid(): Promise<boolean> {
|
||||
if (!this.senderInstance.data.publicKey?.key) {
|
||||
throw new Error(
|
||||
`Instance ${this.senderInstance.data.baseUrl} has no public key stored in database`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.debug.federation) {
|
||||
this.logger.debug`Sender public key: ${this.sender.data.publicKey}`;
|
||||
this.logger
|
||||
.debug`Sender public key: ${this.senderInstance.data.publicKey.key}`;
|
||||
}
|
||||
|
||||
const validator = await SignatureValidator.fromStringKey(
|
||||
this.sender.data.publicKey,
|
||||
this.senderInstance.data.publicKey.key,
|
||||
);
|
||||
|
||||
if (!(this.headers.signature && this.headers.nonce)) {
|
||||
throw new Error("Missing signature or nonce");
|
||||
}
|
||||
|
||||
// HACK: Making a fake Request object instead of passing the values directly is necessary because otherwise the validation breaks for some unknown reason
|
||||
const isValid = await validator.validate(
|
||||
new Request(this.context.req.url, {
|
||||
|
|
@ -185,16 +196,7 @@ export class InboxProcessor {
|
|||
public async process(): Promise<
|
||||
(Response & TypedResponse<{ error: string }, 500, "json">) | Response
|
||||
> {
|
||||
const remoteInstance = await Instance.fromUser(this.sender);
|
||||
|
||||
if (!remoteInstance) {
|
||||
return this.context.json(
|
||||
{ error: "Could not resolve the remote instance." },
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefederated(remoteInstance.data.baseUrl)) {
|
||||
if (isDefederated(this.senderInstance.data.baseUrl)) {
|
||||
// Return 201 to avoid
|
||||
// 1. Leaking defederated instance information
|
||||
// 2. Preventing the sender from thinking the message was not delivered and retrying
|
||||
|
|
@ -439,11 +441,15 @@ export class InboxProcessor {
|
|||
const delete_ = this.body as unknown as VersiaDelete;
|
||||
const toDelete = delete_.deleted;
|
||||
|
||||
const author = delete_.author
|
||||
? await User.resolve(delete_.author)
|
||||
: null;
|
||||
|
||||
switch (delete_.deleted_type) {
|
||||
case "Note": {
|
||||
const note = await Note.fromSql(
|
||||
eq(Notes.uri, toDelete),
|
||||
eq(Notes.authorId, this.sender.id),
|
||||
author ? eq(Notes.authorId, author.id) : undefined,
|
||||
);
|
||||
|
||||
if (!note) {
|
||||
|
|
@ -468,8 +474,8 @@ export class InboxProcessor {
|
|||
);
|
||||
}
|
||||
|
||||
if (userToDelete.id === this.sender.id) {
|
||||
await this.sender.delete();
|
||||
if (!author || userToDelete.id === author.id) {
|
||||
await userToDelete.delete();
|
||||
return this.context.text(
|
||||
"Account deleted, goodbye 👋",
|
||||
200,
|
||||
|
|
@ -486,7 +492,7 @@ export class InboxProcessor {
|
|||
case "pub.versia:likes/Like": {
|
||||
const like = await Like.fromSql(
|
||||
eq(Likes.uri, toDelete),
|
||||
eq(Likes.likerId, this.sender.id),
|
||||
author ? eq(Likes.likerId, author.id) : undefined,
|
||||
);
|
||||
|
||||
if (!like) {
|
||||
|
|
|
|||
1
drizzle/migrations/0035_pretty_whiplash.sql
Normal file
1
drizzle/migrations/0035_pretty_whiplash.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "Instances" ADD COLUMN "public_key" jsonb;
|
||||
2225
drizzle/migrations/meta/0035_snapshot.json
Normal file
2225
drizzle/migrations/meta/0035_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -246,6 +246,13 @@
|
|||
"when": 1729789587213,
|
||||
"tag": "0034_jittery_proemial_gods",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "7",
|
||||
"when": 1732398961365,
|
||||
"tag": "0035_pretty_whiplash",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Source as ApiSource } from "@versia/client/types";
|
||||
import type { ContentFormat } from "@versia/federation/types";
|
||||
import type { ContentFormat, InstanceMetadata } from "@versia/federation/types";
|
||||
import type { Challenge } from "altcha-lib/types";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import {
|
||||
|
|
@ -351,6 +351,7 @@ export const Instances = pgTable("Instances", {
|
|||
.notNull()
|
||||
.$type<"versia" | "activitypub">()
|
||||
.default("versia"),
|
||||
publicKey: jsonb("public_key").$type<InstanceMetadata["public_key"]>(),
|
||||
});
|
||||
|
||||
export const OpenIdAccounts = pgTable("OpenIdAccounts", {
|
||||
|
|
|
|||
Loading…
Reference in a new issue