From eab61b38f17f11a6c2071625757efc4b05912f91 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 21 May 2024 14:59:03 -1000 Subject: [PATCH] feat(federation): :sparkles: Add bridge tokens, federation request debugging --- config/config.example.toml | 7 +- packages/config-manager/config.type.ts | 7 ++ server/api/users/:uuid/inbox/index.ts | 104 ++++++++++++++++++++----- tests/api.test.ts | 5 +- utils/api.ts | 21 +++++ 5 files changed, 122 insertions(+), 22 deletions(-) diff --git a/config/config.example.toml b/config/config.example.toml index 802573b4..1a38dd7c 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -294,9 +294,12 @@ avatars = [] enabled = false # Only lysand-ap exists for now software = "lysand-ap" -# WARNING: These IPs will have signature checks disabled. -# Only use the bridge software if you trust it. +# If this is empty, any bridge with the correct token +# will be able to send data to your instance allowed_ips = ["192.168.1.0/24"] +# Token for the bridge software +# Bridge must have the same token! +token = "mycooltoken" [instance] name = "Lysand" diff --git a/packages/config-manager/config.type.ts b/packages/config-manager/config.type.ts index cef7da8e..a59e19d9 100644 --- a/packages/config-manager/config.type.ts +++ b/packages/config-manager/config.type.ts @@ -527,6 +527,13 @@ export const configValidator = z.object({ ) .default({}), }), + debug: z + .object({ + federation: z.boolean().default(false), + }) + .default({ + federation: false, + }), }); export type Config = z.infer; diff --git a/server/api/users/:uuid/inbox/index.ts b/server/api/users/:uuid/inbox/index.ts index a1128e2b..1cc1195a 100644 --- a/server/api/users/:uuid/inbox/index.ts +++ b/server/api/users/:uuid/inbox/index.ts @@ -1,4 +1,4 @@ -import { applyConfig, handleZodError } from "@api"; +import { applyConfig, debugRequest, handleZodError } from "@api"; import { zValidator } from "@hono/zod-validator"; import { dualLogger } from "@loggers"; import { @@ -43,6 +43,8 @@ export const schemas = { header: z.object({ signature: z.string(), date: z.string(), + authorization: z.string().optional(), + origin: z.string(), }), body: z.any(), }; @@ -56,10 +58,34 @@ export default (app: Hono) => zValidator("json", schemas.body, handleZodError), async (context) => { const { uuid } = context.req.valid("param"); - const { signature, date } = context.req.valid("header"); + const { signature, date, authorization, origin } = + context.req.valid("header"); + + // Check if Origin is defederated + if ( + config.federation.blocked.find( + (blocked) => + blocked.includes(origin) || origin.includes(blocked), + ) + ) { + // Pretend to accept request + return response(null, 201); + } + const body: typeof EntityValidator.$Entity = await context.req.valid("json"); + if (config.debug.federation) { + // Debug request + await debugRequest( + new Request(context.req.url, { + method: context.req.method, + headers: context.req.raw.headers, + body: await context.req.text(), + }), + ); + } + const user = await User.fromId(uuid); if (!user) { @@ -74,27 +100,38 @@ export default (app: Hono) => let checkSignature = true; - if (request_ip?.address && config.federation.bridge.enabled) { - for (const ip of config.federation.bridge.allowed_ips) { - if (matches(ip, request_ip?.address)) { - checkSignature = false; - break; + if (config.federation.bridge.enabled) { + const token = authorization?.split("Bearer ")[1]; + if (token) { + // Request is bridge request + if (token !== config.federation.bridge.token) { + return errorResponse( + "An invalid token was passed in the Authorization header. Please use the correct token, or remove the Authorization header.", + 401, + ); + } + + if (request_ip?.address) { + if (config.federation.bridge.allowed_ips.length > 0) + checkSignature = false; + + for (const ip of config.federation.bridge.allowed_ips) { + if (matches(ip, request_ip?.address)) { + checkSignature = false; + break; + } + } + } else { + return errorResponse( + "Request IP address is not available", + 500, + ); } } } // Verify request signature - // TODO: Check if instance is defederated - // TODO: Reverse DNS lookup with Origin header if (checkSignature) { - if (!signature) { - return errorResponse("Missing Signature header", 400); - } - - if (!date) { - return errorResponse("Missing Date header", 400); - } - const keyId = signature .split("keyId=")[1] .split(",")[0] @@ -177,6 +214,17 @@ export default (app: Hono) => case "Follow": { const follow = await validator.Follow(body); + if ( + config.federation.discard.follows.find( + (blocked) => + blocked.includes(origin) || + origin.includes(blocked), + ) + ) { + // Pretend to accept request + return response("Follow request sent", 200); + } + const account = await User.resolve(follow.author); if (!account) { @@ -220,7 +268,16 @@ export default (app: Hono) => case "FollowAccept": { const followAccept = await validator.FollowAccept(body); - console.log(followAccept); + if ( + config.federation.discard.follows.find( + (blocked) => + blocked.includes(origin) || + origin.includes(blocked), + ) + ) { + // Pretend to accept request + return response("Follow request accepted", 200); + } const account = await User.resolve(followAccept.author); @@ -255,6 +312,17 @@ export default (app: Hono) => case "FollowReject": { const followReject = await validator.FollowReject(body); + if ( + config.federation.discard.follows.find( + (blocked) => + blocked.includes(origin) || + origin.includes(blocked), + ) + ) { + // Pretend to accept request + return response("Follow request rejected", 200); + } + const account = await User.resolve(followReject.author); if (!account) { diff --git a/tests/api.test.ts b/tests/api.test.ts index 09a11815..1335b502 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -36,7 +36,8 @@ describe("API Tests", () => { expect(data.error).toContain("https://stackoverflow.com"); }); - test("try sending a request with a different origin", async () => { + // Now automatically mitigated by the server + /* test("try sending a request with a different origin", async () => { if (new URL(config.http.base_url).protocol === "http:") { return; } @@ -59,5 +60,5 @@ describe("API Tests", () => { expect(response.status).toBe(400); const data = await response.json(); expect(data.error).toContain("does not match base URL"); - }); + }); */ }); diff --git a/utils/api.ts b/utils/api.ts index ef5d0bc2..4c9de52d 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -1,4 +1,6 @@ +import { consoleLogger } from "@loggers"; import { errorResponse } from "@response"; +import chalk from "chalk"; import { config } from "config-manager"; import type { Context } from "hono"; import { createMiddleware } from "hono/factory"; @@ -22,6 +24,7 @@ import { fromZodError } from "zod-validation-error"; import type { Application } from "~database/entities/Application"; import { getFromHeader } from "~database/entities/User"; import type { User } from "~packages/database-interface/user"; +import { LogLevel } from "~packages/log-manager"; import type { APIRouteMetadata, HttpVerb } from "~types/api"; export const applyConfig = (routeMeta: APIRouteMetadata) => { @@ -298,3 +301,21 @@ export const jsonOrForm = () => { await next(); }); }; + +export const debugRequest = async (req: Request, logger = consoleLogger) => { + const body = await req.clone().text(); + await logger.log( + LogLevel.DEBUG, + "RequestDebugger", + `\n${chalk.green(req.method)} ${chalk.blue(req.url)}\n${chalk.bold( + "Hash", + )}: ${chalk.yellow( + new Bun.SHA256().update(body).digest("hex"), + )}\n${chalk.bold("Headers")}:\n${Array.from(req.headers.entries()) + .map( + ([key, value]) => + ` - ${chalk.cyan(key)}: ${chalk.white(value)}`, + ) + .join("\n")}\n${chalk.bold("Body")}: ${chalk.gray(body)}`, + ); +};