From 3d3e64edab861aacbe04d5466d067e43a966684a Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Thu, 27 Mar 2025 20:12:00 +0100 Subject: [PATCH] feat(api): :sparkles: Implement rate limiting --- .../v1/accounts/familiar_followers/index.ts | 2 + api/api/v1/accounts/index.ts | 2 + api/api/v1/accounts/lookup/index.ts | 2 + api/api/v1/accounts/relationships/index.ts | 2 + api/api/v1/accounts/search/index.ts | 2 + .../v1/accounts/update_credentials/index.ts | 2 + api/api/v1/apps/index.ts | 3 +- api/api/v1/emojis/index.ts | 1 + app.ts | 9 +++++ bun.lock | 3 ++ classes/errors/api-error.ts | 5 ++- middlewares/rate-limit.ts | 40 +++++++++++++++++++ package.json | 1 + tests/utils.ts | 3 ++ utils/api.ts | 2 +- 15 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 middlewares/rate-limit.ts diff --git a/api/api/v1/accounts/familiar_followers/index.ts b/api/api/v1/accounts/familiar_followers/index.ts index b615e0b4..1b19e29c 100644 --- a/api/api/v1/accounts/familiar_followers/index.ts +++ b/api/api/v1/accounts/familiar_followers/index.ts @@ -9,6 +9,7 @@ import { User, db } from "@versia/kit/db"; import type { Users } from "@versia/kit/tables"; import { type InferSelectModel, sql } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; +import { rateLimit } from "~/middlewares/rate-limit"; const route = createRoute({ method: "get", @@ -26,6 +27,7 @@ const route = createRoute({ scopes: ["read:follows"], permissions: [RolePermission.ManageOwnFollows], }), + rateLimit(5), qsQuery(), ] as const, request: { diff --git a/api/api/v1/accounts/index.ts b/api/api/v1/accounts/index.ts index e7f5ab17..781070fd 100644 --- a/api/api/v1/accounts/index.ts +++ b/api/api/v1/accounts/index.ts @@ -8,6 +8,7 @@ import { and, eq, isNull } from "drizzle-orm"; import ISO6391 from "iso-639-1"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/config.ts"; +import { rateLimit } from "~/middlewares/rate-limit"; const schema = z.object({ username: z.string().openapi({ @@ -55,6 +56,7 @@ const route = createRoute({ scopes: ["write:accounts"], challenge: true, }), + rateLimit(5), jsonOrForm(), ] as const, request: { diff --git a/api/api/v1/accounts/lookup/index.ts b/api/api/v1/accounts/lookup/index.ts index d5c8919f..c422bcf5 100644 --- a/api/api/v1/accounts/lookup/index.ts +++ b/api/api/v1/accounts/lookup/index.ts @@ -7,6 +7,7 @@ import { Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/config.ts"; +import { rateLimit } from "~/middlewares/rate-limit"; const route = createRoute({ method: "get", @@ -19,6 +20,7 @@ const route = createRoute({ auth: false, permissions: [RolePermission.Search], }), + rateLimit(5), ] as const, request: { query: z.object({ diff --git a/api/api/v1/accounts/relationships/index.ts b/api/api/v1/accounts/relationships/index.ts index 312d3146..a3933647 100644 --- a/api/api/v1/accounts/relationships/index.ts +++ b/api/api/v1/accounts/relationships/index.ts @@ -8,6 +8,7 @@ import { import { RolePermission } from "@versia/client/schemas"; import { Relationship } from "@versia/kit/db"; import { ApiError } from "~/classes/errors/api-error"; +import { rateLimit } from "~/middlewares/rate-limit"; const route = createRoute({ method: "get", @@ -20,6 +21,7 @@ const route = createRoute({ }, tags: ["Accounts"], middleware: [ + rateLimit(10), auth({ auth: true, scopes: ["read:follows"], diff --git a/api/api/v1/accounts/search/index.ts b/api/api/v1/accounts/search/index.ts index 87c70aa9..c58d5b43 100644 --- a/api/api/v1/accounts/search/index.ts +++ b/api/api/v1/accounts/search/index.ts @@ -7,6 +7,7 @@ import { Users } from "@versia/kit/tables"; import { eq, ilike, not, or, sql } from "drizzle-orm"; import stringComparison from "string-comparison"; import { ApiError } from "~/classes/errors/api-error"; +import { rateLimit } from "~/middlewares/rate-limit"; export const route = createRoute({ method: "get", @@ -18,6 +19,7 @@ export const route = createRoute({ }, tags: ["Accounts"], middleware: [ + rateLimit(5), auth({ auth: false, permissions: [RolePermission.Search, RolePermission.ViewAccounts], diff --git a/api/api/v1/accounts/update_credentials/index.ts b/api/api/v1/accounts/update_credentials/index.ts index 606e2807..41a23bd7 100644 --- a/api/api/v1/accounts/update_credentials/index.ts +++ b/api/api/v1/accounts/update_credentials/index.ts @@ -10,6 +10,7 @@ import { and, eq, isNull } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; import { contentToHtml } from "~/classes/functions/status"; import { config } from "~/config.ts"; +import { rateLimit } from "~/middlewares/rate-limit"; const route = createRoute({ method: "patch", @@ -21,6 +22,7 @@ const route = createRoute({ }, tags: ["Accounts"], middleware: [ + rateLimit(5), auth({ auth: true, permissions: [RolePermission.ManageOwnAccount], diff --git a/api/api/v1/apps/index.ts b/api/api/v1/apps/index.ts index 20ed7d96..6cc767cf 100644 --- a/api/api/v1/apps/index.ts +++ b/api/api/v1/apps/index.ts @@ -7,6 +7,7 @@ import { } from "@versia/client/schemas"; import { Application } from "@versia/kit/db"; import { ApiError } from "~/classes/errors/api-error"; +import { rateLimit } from "~/middlewares/rate-limit"; const route = createRoute({ method: "post", @@ -17,7 +18,7 @@ const route = createRoute({ url: "https://docs.joinmastodon.org/methods/apps/#create", }, tags: ["Apps"], - middleware: [jsonOrForm()], + middleware: [jsonOrForm(), rateLimit(4)], request: { body: { content: { diff --git a/api/api/v1/emojis/index.ts b/api/api/v1/emojis/index.ts index 7643cd5c..2a5a39ef 100644 --- a/api/api/v1/emojis/index.ts +++ b/api/api/v1/emojis/index.ts @@ -8,6 +8,7 @@ import { Emojis } from "@versia/kit/tables"; import { and, eq, isNull, or } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/config.ts"; + const schema = z.object({ shortcode: CustomEmojiSchema.shape.shortcode, element: z diff --git a/app.ts b/app.ts index 9d70baf4..01ae0450 100644 --- a/app.ts +++ b/app.ts @@ -22,6 +22,7 @@ import { agentBans } from "./middlewares/agent-bans.ts"; import { boundaryCheck } from "./middlewares/boundary-check.ts"; import { ipBans } from "./middlewares/ip-bans.ts"; import { logger } from "./middlewares/logger.ts"; +import { rateLimit } from "./middlewares/rate-limit.ts"; import { routes } from "./routes.ts"; import type { ApiRouteExports, HonoEnv } from "./types/api.ts"; @@ -87,6 +88,14 @@ export const appFactory = async (): Promise> => { }), ); + // 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.get("/metrics", printMetrics); */ // Disabled as federation now checks for this diff --git a/bun.lock b/bun.lock index a1c65cac..679216d7 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "confbox": "^0.2.1", "drizzle-orm": "^0.41.0", "hono": "^4.7.5", + "hono-rate-limiter": "^0.4.2", "html-to-text": "^9.0.5", "ioredis": "^5.6.0", "ip-matching": "^2.1.2", @@ -854,6 +855,8 @@ "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=="], "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=="], diff --git a/classes/errors/api-error.ts b/classes/errors/api-error.ts index 772e2fb3..1df8c4f3 100644 --- a/classes/errors/api-error.ts +++ b/classes/errors/api-error.ts @@ -27,7 +27,10 @@ export class ApiError extends Error { public static zodSchema = z.object({ 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 { diff --git a/middlewares/rate-limit.ts b/middlewares/rate-limit.ts new file mode 100644 index 00000000..7d211193 --- /dev/null +++ b/middlewares/rate-limit.ts @@ -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 => + env.DISABLE_RATE_LIMIT === "true" + ? (_, next): Promise => next() + : rateLimiter({ + keyGenerator: (c): string => c.req.path, + message: (c): z.infer => ({ + 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, + }); diff --git a/package.json b/package.json index 0bd8aab0..0e1e1150 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "confbox": "^0.2.1", "drizzle-orm": "^0.41.0", "hono": "^4.7.5", + "hono-rate-limiter": "^0.4.2", "html-to-text": "^9.0.5", "ioredis": "^5.6.0", "ip-matching": "^2.1.2", diff --git a/tests/utils.ts b/tests/utils.ts index be396411..5b1d76e7 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -5,11 +5,14 @@ import { Client as VersiaClient } from "@versia/client"; import { Note, Token, User, db } from "@versia/kit/db"; import { Notes, Users } from "@versia/kit/tables"; import { solveChallenge } from "altcha-lib"; +import { env } from "bun"; import { type InferSelectModel, asc, inArray, like } from "drizzle-orm"; import { appFactory } from "~/app"; import { searchManager } from "~/classes/search/search-manager"; import { config } from "~/config.ts"; import { setupDatabase } from "~/drizzle/db"; + +env.DISABLE_RATE_LIMIT = "true"; await setupDatabase(); if (config.search.enabled) { diff --git a/utils/api.ts b/utils/api.ts index 29f97aee..26efdb23 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -245,7 +245,7 @@ export const checkRouteNeedsChallenge = async ( .where(eq(Challenges.id, challenge_id)); }; -type HonoEnvWithAuth = HonoEnv & { +export type HonoEnvWithAuth = HonoEnv & { Variables: { auth: AuthData & { user: NonNullable;