feat(federation): Add bridge tokens, federation request debugging

This commit is contained in:
Jesse Wierzbinski 2024-05-21 14:59:03 -10:00
parent 673b7d0bae
commit eab61b38f1
No known key found for this signature in database
5 changed files with 122 additions and 22 deletions

View file

@ -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"

View file

@ -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<typeof configValidator>;

View file

@ -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) {

View file

@ -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");
});
}); */
});

View file

@ -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)}`,
);
};