feat(api): Implement rate limiting
Some checks failed
CodeQL Scan / Analyze (javascript-typescript) (push) Failing after 42s
Build Docker Images / lint (push) Successful in 31s
Build Docker Images / check (push) Successful in 1m3s
Build Docker Images / tests (push) Failing after 6s
Build Docker Images / build (server, Dockerfile, ${{ github.repository_owner }}/server) (push) Has been skipped
Build Docker Images / build (worker, Worker.Dockerfile, ${{ github.repository_owner }}/worker) (push) Has been skipped
Deploy Docs to GitHub Pages / build (push) Failing after 13s
Mirror to Codeberg / Mirror (push) Failing after 0s
Deploy Docs to GitHub Pages / Deploy (push) Has been skipped
Nix Build / check (push) Failing after 33m18s

This commit is contained in:
Jesse Wierzbinski 2025-03-27 20:12:00 +01:00
parent 1993231663
commit 3d3e64edab
No known key found for this signature in database
15 changed files with 76 additions and 3 deletions

View file

@ -9,6 +9,7 @@ import { User, db } from "@versia/kit/db";
import type { Users } from "@versia/kit/tables"; import type { Users } from "@versia/kit/tables";
import { type InferSelectModel, sql } from "drizzle-orm"; import { type InferSelectModel, sql } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { rateLimit } from "~/middlewares/rate-limit";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
@ -26,6 +27,7 @@ const route = createRoute({
scopes: ["read:follows"], scopes: ["read:follows"],
permissions: [RolePermission.ManageOwnFollows], permissions: [RolePermission.ManageOwnFollows],
}), }),
rateLimit(5),
qsQuery(), qsQuery(),
] as const, ] as const,
request: { request: {

View file

@ -8,6 +8,7 @@ import { and, eq, isNull } from "drizzle-orm";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
import { rateLimit } from "~/middlewares/rate-limit";
const schema = z.object({ const schema = z.object({
username: z.string().openapi({ username: z.string().openapi({
@ -55,6 +56,7 @@ const route = createRoute({
scopes: ["write:accounts"], scopes: ["write:accounts"],
challenge: true, challenge: true,
}), }),
rateLimit(5),
jsonOrForm(), jsonOrForm(),
] as const, ] as const,
request: { request: {

View file

@ -7,6 +7,7 @@ import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
import { rateLimit } from "~/middlewares/rate-limit";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
@ -19,6 +20,7 @@ const route = createRoute({
auth: false, auth: false,
permissions: [RolePermission.Search], permissions: [RolePermission.Search],
}), }),
rateLimit(5),
] as const, ] as const,
request: { request: {
query: z.object({ query: z.object({

View file

@ -8,6 +8,7 @@ import {
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db"; import { Relationship } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { rateLimit } from "~/middlewares/rate-limit";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
@ -20,6 +21,7 @@ const route = createRoute({
}, },
tags: ["Accounts"], tags: ["Accounts"],
middleware: [ middleware: [
rateLimit(10),
auth({ auth({
auth: true, auth: true,
scopes: ["read:follows"], scopes: ["read:follows"],

View file

@ -7,6 +7,7 @@ import { Users } from "@versia/kit/tables";
import { eq, ilike, not, or, sql } from "drizzle-orm"; import { eq, ilike, not, or, sql } from "drizzle-orm";
import stringComparison from "string-comparison"; import stringComparison from "string-comparison";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { rateLimit } from "~/middlewares/rate-limit";
export const route = createRoute({ export const route = createRoute({
method: "get", method: "get",
@ -18,6 +19,7 @@ export const route = createRoute({
}, },
tags: ["Accounts"], tags: ["Accounts"],
middleware: [ middleware: [
rateLimit(5),
auth({ auth({
auth: false, auth: false,
permissions: [RolePermission.Search, RolePermission.ViewAccounts], permissions: [RolePermission.Search, RolePermission.ViewAccounts],

View file

@ -10,6 +10,7 @@ import { and, eq, isNull } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { contentToHtml } from "~/classes/functions/status"; import { contentToHtml } from "~/classes/functions/status";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
import { rateLimit } from "~/middlewares/rate-limit";
const route = createRoute({ const route = createRoute({
method: "patch", method: "patch",
@ -21,6 +22,7 @@ const route = createRoute({
}, },
tags: ["Accounts"], tags: ["Accounts"],
middleware: [ middleware: [
rateLimit(5),
auth({ auth({
auth: true, auth: true,
permissions: [RolePermission.ManageOwnAccount], permissions: [RolePermission.ManageOwnAccount],

View file

@ -7,6 +7,7 @@ import {
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { Application } from "@versia/kit/db"; import { Application } from "@versia/kit/db";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { rateLimit } from "~/middlewares/rate-limit";
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
@ -17,7 +18,7 @@ const route = createRoute({
url: "https://docs.joinmastodon.org/methods/apps/#create", url: "https://docs.joinmastodon.org/methods/apps/#create",
}, },
tags: ["Apps"], tags: ["Apps"],
middleware: [jsonOrForm()], middleware: [jsonOrForm(), rateLimit(4)],
request: { request: {
body: { body: {
content: { content: {

View file

@ -8,6 +8,7 @@ import { Emojis } from "@versia/kit/tables";
import { and, eq, isNull, or } from "drizzle-orm"; import { and, eq, isNull, or } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
const schema = z.object({ const schema = z.object({
shortcode: CustomEmojiSchema.shape.shortcode, shortcode: CustomEmojiSchema.shape.shortcode,
element: z element: z

9
app.ts
View file

@ -22,6 +22,7 @@ import { agentBans } from "./middlewares/agent-bans.ts";
import { boundaryCheck } from "./middlewares/boundary-check.ts"; import { boundaryCheck } from "./middlewares/boundary-check.ts";
import { ipBans } from "./middlewares/ip-bans.ts"; import { ipBans } from "./middlewares/ip-bans.ts";
import { logger } from "./middlewares/logger.ts"; import { logger } from "./middlewares/logger.ts";
import { rateLimit } from "./middlewares/rate-limit.ts";
import { routes } from "./routes.ts"; import { routes } from "./routes.ts";
import type { ApiRouteExports, HonoEnv } from "./types/api.ts"; import type { ApiRouteExports, HonoEnv } from "./types/api.ts";
@ -87,6 +88,14 @@ export const appFactory = async (): Promise<OpenAPIHono<HonoEnv>> => {
}), }),
); );
// Set default ratelimits to 100 requests per minute
app.use("/api/*", rateLimit(100));
app.use("/api/v1/media", rateLimit(40));
app.use("/api/v1/media/*", rateLimit(40));
app.use("/api/v2/media", rateLimit(40));
app.use("/api/v1/timelines/*", rateLimit(40));
app.use("/api/v1/push/*", rateLimit(10));
/* app.use("*", registerMetrics); /* app.use("*", registerMetrics);
app.get("/metrics", printMetrics); */ app.get("/metrics", printMetrics); */
// Disabled as federation now checks for this // Disabled as federation now checks for this

View file

@ -32,6 +32,7 @@
"confbox": "^0.2.1", "confbox": "^0.2.1",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"hono": "^4.7.5", "hono": "^4.7.5",
"hono-rate-limiter": "^0.4.2",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"ioredis": "^5.6.0", "ioredis": "^5.6.0",
"ip-matching": "^2.1.2", "ip-matching": "^2.1.2",
@ -854,6 +855,8 @@
"hono": ["hono@4.7.5", "", {}, "sha512-fDOK5W2C1vZACsgLONigdZTRZxuBqFtcKh7bUQ5cVSbwI2RWjloJDcgFOVzbQrlI6pCmhlTsVYZ7zpLj4m4qMQ=="], "hono": ["hono@4.7.5", "", {}, "sha512-fDOK5W2C1vZACsgLONigdZTRZxuBqFtcKh7bUQ5cVSbwI2RWjloJDcgFOVzbQrlI6pCmhlTsVYZ7zpLj4m4qMQ=="],
"hono-rate-limiter": ["hono-rate-limiter@0.4.2", "", { "peerDependencies": { "hono": "^4.1.1" } }, "sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],

View file

@ -27,7 +27,10 @@ export class ApiError extends Error {
public static zodSchema = z.object({ public static zodSchema = z.object({
error: z.string(), error: z.string(),
details: z.string().or(z.record(z.string(), z.string())).optional(), details: z
.string()
.or(z.record(z.string(), z.string().or(z.number())))
.optional(),
}); });
public get schema(): ResponseConfig { public get schema(): ResponseConfig {

40
middlewares/rate-limit.ts Normal file
View file

@ -0,0 +1,40 @@
import type { z } from "@hono/zod-openapi";
import { env } from "bun";
import type { MiddlewareHandler } from "hono";
import { rateLimiter } from "hono-rate-limiter";
import type { ApiError } from "~/classes/errors/api-error";
import type { HonoEnv } from "~/types/api";
// Not exported by hono-rate-limiter
// So we define it ourselves
type RateLimitEnv = HonoEnv & {
Variables: {
rateLimit: {
limit: number;
remaining: number;
resetTime: Date;
};
};
};
export const rateLimit = (
limit: number,
windowMs = 60 * 1000,
): MiddlewareHandler<RateLimitEnv> =>
env.DISABLE_RATE_LIMIT === "true"
? (_, next): Promise<void> => next()
: rateLimiter<RateLimitEnv>({
keyGenerator: (c): string => c.req.path,
message: (c): z.infer<typeof ApiError.zodSchema> => ({
error: "Too many requests, please try again later.",
details: {
limit: c.get("rateLimit").limit,
remaining: c.get("rateLimit").remaining,
reset: c.get("rateLimit").resetTime.toISOString(),
resetInMs:
c.get("rateLimit").resetTime.getTime() - Date.now(),
},
}),
windowMs,
limit,
});

View file

@ -104,6 +104,7 @@
"confbox": "^0.2.1", "confbox": "^0.2.1",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"hono": "^4.7.5", "hono": "^4.7.5",
"hono-rate-limiter": "^0.4.2",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"ioredis": "^5.6.0", "ioredis": "^5.6.0",
"ip-matching": "^2.1.2", "ip-matching": "^2.1.2",

View file

@ -5,11 +5,14 @@ import { Client as VersiaClient } from "@versia/client";
import { Note, Token, User, db } from "@versia/kit/db"; import { Note, Token, User, db } from "@versia/kit/db";
import { Notes, Users } from "@versia/kit/tables"; import { Notes, Users } from "@versia/kit/tables";
import { solveChallenge } from "altcha-lib"; import { solveChallenge } from "altcha-lib";
import { env } from "bun";
import { type InferSelectModel, asc, inArray, like } from "drizzle-orm"; import { type InferSelectModel, asc, inArray, like } from "drizzle-orm";
import { appFactory } from "~/app"; import { appFactory } from "~/app";
import { searchManager } from "~/classes/search/search-manager"; import { searchManager } from "~/classes/search/search-manager";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
import { setupDatabase } from "~/drizzle/db"; import { setupDatabase } from "~/drizzle/db";
env.DISABLE_RATE_LIMIT = "true";
await setupDatabase(); await setupDatabase();
if (config.search.enabled) { if (config.search.enabled) {

View file

@ -245,7 +245,7 @@ export const checkRouteNeedsChallenge = async (
.where(eq(Challenges.id, challenge_id)); .where(eq(Challenges.id, challenge_id));
}; };
type HonoEnvWithAuth = HonoEnv & { export type HonoEnvWithAuth = HonoEnv & {
Variables: { Variables: {
auth: AuthData & { auth: AuthData & {
user: NonNullable<AuthData["user"]>; user: NonNullable<AuthData["user"]>;