mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38: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 { createRoute } from "@hono/zod-openapi";
|
||||||
import { getLogger } from "@logtape/logtape";
|
import { getLogger } from "@logtape/logtape";
|
||||||
import type { Entity } from "@versia/federation/types";
|
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 { z } from "zod";
|
||||||
import { InboxProcessor } from "~/classes/inbox/processor";
|
import { InboxProcessor } from "~/classes/inbox/processor";
|
||||||
import { ErrorSchema } from "~/types/api";
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
@ -23,9 +23,9 @@ export const schemas = {
|
||||||
uuid: z.string().uuid(),
|
uuid: z.string().uuid(),
|
||||||
}),
|
}),
|
||||||
header: z.object({
|
header: z.object({
|
||||||
"x-signature": z.string(),
|
"x-signature": z.string().optional(),
|
||||||
"x-nonce": z.string(),
|
"x-nonce": z.string().optional(),
|
||||||
"x-signed-by": z.string().url().or(z.literal("instance")),
|
"x-signed-by": z.string().url().or(z.string().startsWith("instance ")),
|
||||||
authorization: z.string().optional(),
|
authorization: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
body: z.any(),
|
body: z.any(),
|
||||||
|
|
@ -113,24 +113,37 @@ export default apiRoute((app) =>
|
||||||
|
|
||||||
const sender = await User.resolve(signedBy);
|
const sender = await User.resolve(signedBy);
|
||||||
|
|
||||||
if (!sender) {
|
if (!(sender || signedBy.startsWith("instance "))) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{ error: `Couldn't resolve sender ${signedBy}` },
|
{ error: `Couldn't resolve sender URI ${signedBy}` },
|
||||||
404,
|
404,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sender?.isLocal()) {
|
if (sender?.isLocal()) {
|
||||||
return context.json(
|
return context.json(
|
||||||
{ error: "Cannot send federation requests to local users" },
|
{
|
||||||
|
error: "Cannot process federation requests from local users",
|
||||||
|
},
|
||||||
400,
|
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(
|
const processor = new InboxProcessor(
|
||||||
context,
|
context,
|
||||||
body,
|
body,
|
||||||
sender,
|
remoteInstance,
|
||||||
{
|
{
|
||||||
signature,
|
signature,
|
||||||
nonce,
|
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> {
|
public static async resolve(url: string): Promise<Instance> {
|
||||||
const logger = getLogger("federation");
|
const logger = getLogger("federation");
|
||||||
const host = new URL(url).host;
|
const host = new URL(url).host;
|
||||||
|
|
@ -346,6 +358,7 @@ export class Instance extends BaseInterface<typeof Instances> {
|
||||||
version: metadata.software.version,
|
version: metadata.software.version,
|
||||||
logo: metadata.logo,
|
logo: metadata.logo,
|
||||||
protocol,
|
protocol,
|
||||||
|
publicKey: metadata.public_key,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
|
import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
|
||||||
import { SignatureValidator } from "@versia/federation";
|
import { SignatureValidator } from "@versia/federation";
|
||||||
import type { Entity, Note as VersiaNote } from "@versia/federation/types";
|
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 type { Context } from "hono";
|
||||||
import { ValidationError } from "zod-validation-error";
|
import { ValidationError } from "zod-validation-error";
|
||||||
import { config } from "~/packages/config-manager/index.ts";
|
import { config } from "~/packages/config-manager/index.ts";
|
||||||
|
|
@ -71,7 +77,7 @@ mock.module("~/packages/config-manager/index.ts", () => ({
|
||||||
describe("InboxProcessor", () => {
|
describe("InboxProcessor", () => {
|
||||||
let mockContext: Context;
|
let mockContext: Context;
|
||||||
let mockBody: Entity;
|
let mockBody: Entity;
|
||||||
let mockSender: User;
|
let mockSenderInstance: Instance;
|
||||||
let mockHeaders: {
|
let mockHeaders: {
|
||||||
signature: string;
|
signature: string;
|
||||||
nonce: string;
|
nonce: string;
|
||||||
|
|
@ -98,12 +104,14 @@ describe("InboxProcessor", () => {
|
||||||
} as unknown as Context;
|
} as unknown as Context;
|
||||||
|
|
||||||
// Setup basic mock sender
|
// Setup basic mock sender
|
||||||
mockSender = {
|
mockSenderInstance = {
|
||||||
id: "test-id",
|
id: "test-id",
|
||||||
data: {
|
data: {
|
||||||
publicKey: "test-key",
|
publicKey: {
|
||||||
|
key: "test-key",
|
||||||
},
|
},
|
||||||
} as unknown as User;
|
},
|
||||||
|
} as unknown as Instance;
|
||||||
|
|
||||||
// Setup basic mock headers
|
// Setup basic mock headers
|
||||||
mockHeaders = {
|
mockHeaders = {
|
||||||
|
|
@ -118,7 +126,7 @@ describe("InboxProcessor", () => {
|
||||||
processor = new InboxProcessor(
|
processor = new InboxProcessor(
|
||||||
mockContext,
|
mockContext,
|
||||||
mockBody,
|
mockBody,
|
||||||
mockSender,
|
mockSenderInstance,
|
||||||
mockHeaders,
|
mockHeaders,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import type {
|
||||||
User as VersiaUser,
|
User as VersiaUser,
|
||||||
} from "@versia/federation/types";
|
} from "@versia/federation/types";
|
||||||
import {
|
import {
|
||||||
Instance,
|
type Instance,
|
||||||
Like,
|
Like,
|
||||||
Note,
|
Note,
|
||||||
Notification,
|
Notification,
|
||||||
|
|
@ -70,7 +70,7 @@ export class InboxProcessor {
|
||||||
*
|
*
|
||||||
* @param context Hono request context.
|
* @param context Hono request context.
|
||||||
* @param body Entity JSON body.
|
* @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 headers Various request headers.
|
||||||
* @param logger LogTape logger instance.
|
* @param logger LogTape logger instance.
|
||||||
* @param requestIp Request IP address. Grabs it from the Hono context if not provided.
|
* @param requestIp Request IP address. Grabs it from the Hono context if not provided.
|
||||||
|
|
@ -78,10 +78,10 @@ export class InboxProcessor {
|
||||||
public constructor(
|
public constructor(
|
||||||
private context: Context,
|
private context: Context,
|
||||||
private body: Entity,
|
private body: Entity,
|
||||||
private sender: User,
|
private senderInstance: Instance,
|
||||||
private headers: {
|
private headers: {
|
||||||
signature: string;
|
signature?: string;
|
||||||
nonce: string;
|
nonce?: string;
|
||||||
authorization?: string;
|
authorization?: string;
|
||||||
},
|
},
|
||||||
private logger: Logger = getLogger(["federation", "inbox"]),
|
private logger: Logger = getLogger(["federation", "inbox"]),
|
||||||
|
|
@ -94,14 +94,25 @@ export class InboxProcessor {
|
||||||
* @returns {Promise<boolean>} - Whether the signature is valid.
|
* @returns {Promise<boolean>} - Whether the signature is valid.
|
||||||
*/
|
*/
|
||||||
private async isSignatureValid(): Promise<boolean> {
|
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) {
|
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(
|
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
|
// 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(
|
const isValid = await validator.validate(
|
||||||
new Request(this.context.req.url, {
|
new Request(this.context.req.url, {
|
||||||
|
|
@ -185,16 +196,7 @@ export class InboxProcessor {
|
||||||
public async process(): Promise<
|
public async process(): Promise<
|
||||||
(Response & TypedResponse<{ error: string }, 500, "json">) | Response
|
(Response & TypedResponse<{ error: string }, 500, "json">) | Response
|
||||||
> {
|
> {
|
||||||
const remoteInstance = await Instance.fromUser(this.sender);
|
if (isDefederated(this.senderInstance.data.baseUrl)) {
|
||||||
|
|
||||||
if (!remoteInstance) {
|
|
||||||
return this.context.json(
|
|
||||||
{ error: "Could not resolve the remote instance." },
|
|
||||||
500,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDefederated(remoteInstance.data.baseUrl)) {
|
|
||||||
// Return 201 to avoid
|
// Return 201 to avoid
|
||||||
// 1. Leaking defederated instance information
|
// 1. Leaking defederated instance information
|
||||||
// 2. Preventing the sender from thinking the message was not delivered and retrying
|
// 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 delete_ = this.body as unknown as VersiaDelete;
|
||||||
const toDelete = delete_.deleted;
|
const toDelete = delete_.deleted;
|
||||||
|
|
||||||
|
const author = delete_.author
|
||||||
|
? await User.resolve(delete_.author)
|
||||||
|
: null;
|
||||||
|
|
||||||
switch (delete_.deleted_type) {
|
switch (delete_.deleted_type) {
|
||||||
case "Note": {
|
case "Note": {
|
||||||
const note = await Note.fromSql(
|
const note = await Note.fromSql(
|
||||||
eq(Notes.uri, toDelete),
|
eq(Notes.uri, toDelete),
|
||||||
eq(Notes.authorId, this.sender.id),
|
author ? eq(Notes.authorId, author.id) : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!note) {
|
if (!note) {
|
||||||
|
|
@ -468,8 +474,8 @@ export class InboxProcessor {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userToDelete.id === this.sender.id) {
|
if (!author || userToDelete.id === author.id) {
|
||||||
await this.sender.delete();
|
await userToDelete.delete();
|
||||||
return this.context.text(
|
return this.context.text(
|
||||||
"Account deleted, goodbye 👋",
|
"Account deleted, goodbye 👋",
|
||||||
200,
|
200,
|
||||||
|
|
@ -486,7 +492,7 @@ export class InboxProcessor {
|
||||||
case "pub.versia:likes/Like": {
|
case "pub.versia:likes/Like": {
|
||||||
const like = await Like.fromSql(
|
const like = await Like.fromSql(
|
||||||
eq(Likes.uri, toDelete),
|
eq(Likes.uri, toDelete),
|
||||||
eq(Likes.likerId, this.sender.id),
|
author ? eq(Likes.likerId, author.id) : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!like) {
|
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,
|
"when": 1729789587213,
|
||||||
"tag": "0034_jittery_proemial_gods",
|
"tag": "0034_jittery_proemial_gods",
|
||||||
"breakpoints": true
|
"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 { 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 type { Challenge } from "altcha-lib/types";
|
||||||
import { relations, sql } from "drizzle-orm";
|
import { relations, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
|
|
@ -351,6 +351,7 @@ export const Instances = pgTable("Instances", {
|
||||||
.notNull()
|
.notNull()
|
||||||
.$type<"versia" | "activitypub">()
|
.$type<"versia" | "activitypub">()
|
||||||
.default("versia"),
|
.default("versia"),
|
||||||
|
publicKey: jsonb("public_key").$type<InstanceMetadata["public_key"]>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const OpenIdAccounts = pgTable("OpenIdAccounts", {
|
export const OpenIdAccounts = pgTable("OpenIdAccounts", {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue