mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(federation): ✨ Add bridge tokens, federation request debugging
This commit is contained in:
parent
673b7d0bae
commit
eab61b38f1
|
|
@ -294,9 +294,12 @@ avatars = []
|
||||||
enabled = false
|
enabled = false
|
||||||
# Only lysand-ap exists for now
|
# Only lysand-ap exists for now
|
||||||
software = "lysand-ap"
|
software = "lysand-ap"
|
||||||
# WARNING: These IPs will have signature checks disabled.
|
# If this is empty, any bridge with the correct token
|
||||||
# Only use the bridge software if you trust it.
|
# will be able to send data to your instance
|
||||||
allowed_ips = ["192.168.1.0/24"]
|
allowed_ips = ["192.168.1.0/24"]
|
||||||
|
# Token for the bridge software
|
||||||
|
# Bridge must have the same token!
|
||||||
|
token = "mycooltoken"
|
||||||
|
|
||||||
[instance]
|
[instance]
|
||||||
name = "Lysand"
|
name = "Lysand"
|
||||||
|
|
|
||||||
|
|
@ -527,6 +527,13 @@ export const configValidator = z.object({
|
||||||
)
|
)
|
||||||
.default({}),
|
.default({}),
|
||||||
}),
|
}),
|
||||||
|
debug: z
|
||||||
|
.object({
|
||||||
|
federation: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.default({
|
||||||
|
federation: false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = z.infer<typeof configValidator>;
|
export type Config = z.infer<typeof configValidator>;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { applyConfig, handleZodError } from "@api";
|
import { applyConfig, debugRequest, handleZodError } from "@api";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { dualLogger } from "@loggers";
|
import { dualLogger } from "@loggers";
|
||||||
import {
|
import {
|
||||||
|
|
@ -43,6 +43,8 @@ export const schemas = {
|
||||||
header: z.object({
|
header: z.object({
|
||||||
signature: z.string(),
|
signature: z.string(),
|
||||||
date: z.string(),
|
date: z.string(),
|
||||||
|
authorization: z.string().optional(),
|
||||||
|
origin: z.string(),
|
||||||
}),
|
}),
|
||||||
body: z.any(),
|
body: z.any(),
|
||||||
};
|
};
|
||||||
|
|
@ -56,10 +58,34 @@ export default (app: Hono) =>
|
||||||
zValidator("json", schemas.body, handleZodError),
|
zValidator("json", schemas.body, handleZodError),
|
||||||
async (context) => {
|
async (context) => {
|
||||||
const { uuid } = context.req.valid("param");
|
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 =
|
const body: typeof EntityValidator.$Entity =
|
||||||
await context.req.valid("json");
|
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);
|
const user = await User.fromId(uuid);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|
@ -74,27 +100,38 @@ export default (app: Hono) =>
|
||||||
|
|
||||||
let checkSignature = true;
|
let checkSignature = true;
|
||||||
|
|
||||||
if (request_ip?.address && config.federation.bridge.enabled) {
|
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) {
|
for (const ip of config.federation.bridge.allowed_ips) {
|
||||||
if (matches(ip, request_ip?.address)) {
|
if (matches(ip, request_ip?.address)) {
|
||||||
checkSignature = false;
|
checkSignature = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return errorResponse(
|
||||||
|
"Request IP address is not available",
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify request signature
|
// Verify request signature
|
||||||
// TODO: Check if instance is defederated
|
|
||||||
// TODO: Reverse DNS lookup with Origin header
|
|
||||||
if (checkSignature) {
|
if (checkSignature) {
|
||||||
if (!signature) {
|
|
||||||
return errorResponse("Missing Signature header", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!date) {
|
|
||||||
return errorResponse("Missing Date header", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyId = signature
|
const keyId = signature
|
||||||
.split("keyId=")[1]
|
.split("keyId=")[1]
|
||||||
.split(",")[0]
|
.split(",")[0]
|
||||||
|
|
@ -177,6 +214,17 @@ export default (app: Hono) =>
|
||||||
case "Follow": {
|
case "Follow": {
|
||||||
const follow = await validator.Follow(body);
|
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);
|
const account = await User.resolve(follow.author);
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
|
|
@ -220,7 +268,16 @@ export default (app: Hono) =>
|
||||||
case "FollowAccept": {
|
case "FollowAccept": {
|
||||||
const followAccept = await validator.FollowAccept(body);
|
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);
|
const account = await User.resolve(followAccept.author);
|
||||||
|
|
||||||
|
|
@ -255,6 +312,17 @@ export default (app: Hono) =>
|
||||||
case "FollowReject": {
|
case "FollowReject": {
|
||||||
const followReject = await validator.FollowReject(body);
|
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);
|
const account = await User.resolve(followReject.author);
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ describe("API Tests", () => {
|
||||||
expect(data.error).toContain("https://stackoverflow.com");
|
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:") {
|
if (new URL(config.http.base_url).protocol === "http:") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -59,5 +60,5 @@ describe("API Tests", () => {
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.error).toContain("does not match base URL");
|
expect(data.error).toContain("does not match base URL");
|
||||||
});
|
}); */
|
||||||
});
|
});
|
||||||
|
|
|
||||||
21
utils/api.ts
21
utils/api.ts
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { consoleLogger } from "@loggers";
|
||||||
import { errorResponse } from "@response";
|
import { errorResponse } from "@response";
|
||||||
|
import chalk from "chalk";
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { createMiddleware } from "hono/factory";
|
import { createMiddleware } from "hono/factory";
|
||||||
|
|
@ -22,6 +24,7 @@ import { fromZodError } from "zod-validation-error";
|
||||||
import type { Application } from "~database/entities/Application";
|
import type { Application } from "~database/entities/Application";
|
||||||
import { getFromHeader } from "~database/entities/User";
|
import { getFromHeader } from "~database/entities/User";
|
||||||
import type { User } from "~packages/database-interface/user";
|
import type { User } from "~packages/database-interface/user";
|
||||||
|
import { LogLevel } from "~packages/log-manager";
|
||||||
import type { APIRouteMetadata, HttpVerb } from "~types/api";
|
import type { APIRouteMetadata, HttpVerb } from "~types/api";
|
||||||
|
|
||||||
export const applyConfig = (routeMeta: APIRouteMetadata) => {
|
export const applyConfig = (routeMeta: APIRouteMetadata) => {
|
||||||
|
|
@ -298,3 +301,21 @@ export const jsonOrForm = () => {
|
||||||
await next();
|
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)}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue