aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/docs/03-configuration.md1
-rw-r--r--packages/shared/config.ts6
-rw-r--r--packages/trpc/index.ts43
-rw-r--r--packages/trpc/rateLimit.ts72
-rw-r--r--packages/trpc/routers/apiKeys.ts21
-rw-r--r--packages/trpc/routers/invites.ts21
-rw-r--r--packages/trpc/routers/users.ts22
7 files changed, 173 insertions, 13 deletions
diff --git a/docs/docs/03-configuration.md b/docs/docs/03-configuration.md
index 7fa212ed..5f5d79ef 100644
--- a/docs/docs/03-configuration.md
+++ b/docs/docs/03-configuration.md
@@ -13,6 +13,7 @@ The app is mainly configured by environment variables. All the used environment
| MAX_ASSET_SIZE_MB | No | 50 | Sets the maximum allowed asset size (in MB) to be uploaded |
| DISABLE_NEW_RELEASE_CHECK | No | false | If set to true, latest release check will be disabled in the admin panel. |
| PROMETHEUS_AUTH_TOKEN | No | Not set | If set, will enable a prometheus metrics endpoint at `/api/metrics`. This endpoint will require this token being passed in the Authorization header as a Bearer token. If not set, that endpoint will return 404. |
+| RATE_LIMITING_ENABLED | No | false | If set to true, API rate limiting will be enabled. |
## Asset Storage
diff --git a/packages/shared/config.ts b/packages/shared/config.ts
index 5a6a3dad..87914529 100644
--- a/packages/shared/config.ts
+++ b/packages/shared/config.ts
@@ -113,6 +113,9 @@ const allEnv = z.object({
ASSET_STORE_S3_ACCESS_KEY_ID: z.string().optional(),
ASSET_STORE_S3_SECRET_ACCESS_KEY: z.string().optional(),
ASSET_STORE_S3_FORCE_PATH_STYLE: stringBool("false"),
+
+ // Rate limiting configuration
+ RATE_LIMITING_ENABLED: stringBool("false"),
});
const serverConfigSchema = allEnv
@@ -251,6 +254,9 @@ const serverConfigSchema = allEnv
prometheus: {
metricsToken: val.PROMETHEUS_AUTH_TOKEN,
},
+ rateLimiting: {
+ enabled: val.RATE_LIMITING_ENABLED,
+ },
};
})
.refine(
diff --git a/packages/trpc/index.ts b/packages/trpc/index.ts
index 90f37ae4..cc62c534 100644
--- a/packages/trpc/index.ts
+++ b/packages/trpc/index.ts
@@ -5,6 +5,7 @@ import { ZodError } from "zod";
import type { db } from "@karakeep/db";
import serverConfig from "@karakeep/shared/config";
+import { createRateLimitMiddleware } from "./rateLimit";
import {
apiErrorsTotalCounter,
apiRequestDurationSummary,
@@ -86,21 +87,38 @@ export const procedure = t.procedure
end();
return res;
});
-export const publicProcedure = procedure;
-export const authedProcedure = procedure.use(function isAuthed(opts) {
- const user = opts.ctx.user;
+// Default public procedure rate limiting
+export const publicProcedure = procedure.use(
+ createRateLimitMiddleware({
+ name: "globalPublic",
+ windowMs: 60 * 1000,
+ maxRequests: 1000,
+ }),
+);
- if (!user?.id) {
- throw new TRPCError({ code: "UNAUTHORIZED" });
- }
+export const authedProcedure = procedure
+ // Default authed procedure rate limiting
+ .use(
+ createRateLimitMiddleware({
+ name: "globalAuthed",
+ windowMs: 60 * 1000,
+ maxRequests: 3000,
+ }),
+ )
+ .use(function isAuthed(opts) {
+ const user = opts.ctx.user;
+
+ if (!user?.id) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
- return opts.next({
- ctx: {
- user,
- },
+ return opts.next({
+ ctx: {
+ user,
+ },
+ });
});
-});
export const adminProcedure = authedProcedure.use(function isAdmin(opts) {
const user = opts.ctx.user;
@@ -109,3 +127,6 @@ export const adminProcedure = authedProcedure.use(function isAdmin(opts) {
}
return opts.next(opts);
});
+
+// Export the rate limiting utilities for use in routers
+export { createRateLimitMiddleware };
diff --git a/packages/trpc/rateLimit.ts b/packages/trpc/rateLimit.ts
new file mode 100644
index 00000000..b9aa4aa1
--- /dev/null
+++ b/packages/trpc/rateLimit.ts
@@ -0,0 +1,72 @@
+import { TRPCError } from "@trpc/server";
+
+import serverConfig from "@karakeep/shared/config";
+
+import { Context } from ".";
+
+interface RateLimitConfig {
+ name: string;
+ windowMs: number;
+ maxRequests: number;
+}
+
+interface RateLimitEntry {
+ count: number;
+ resetTime: number;
+}
+
+const rateLimitStore = new Map<string, RateLimitEntry>();
+
+function cleanupExpiredEntries() {
+ const now = Date.now();
+ for (const [key, entry] of rateLimitStore.entries()) {
+ if (now > entry.resetTime) {
+ rateLimitStore.delete(key);
+ }
+ }
+}
+
+setInterval(cleanupExpiredEntries, 60000);
+
+export function createRateLimitMiddleware<T>(config: RateLimitConfig) {
+ return function rateLimitMiddleware(opts: {
+ path: string;
+ ctx: Context;
+ next: () => Promise<T>;
+ }) {
+ if (!serverConfig.rateLimiting.enabled) {
+ return opts.next();
+ }
+ const ip = opts.ctx.req.ip;
+
+ if (!ip) {
+ return opts.next();
+ }
+
+ // TODO: Better fingerprinting
+ const key = `${config.name}:${ip}:${opts.path}`;
+ const now = Date.now();
+
+ let entry = rateLimitStore.get(key);
+
+ if (!entry || now > entry.resetTime) {
+ entry = {
+ count: 1,
+ resetTime: now + config.windowMs,
+ };
+ rateLimitStore.set(key, entry);
+ return opts.next();
+ }
+
+ if (entry.count >= config.maxRequests) {
+ const resetInSeconds = Math.ceil((entry.resetTime - now) / 1000);
+ throw new TRPCError({
+ code: "TOO_MANY_REQUESTS",
+ message: `Rate limit exceeded. Try again in ${resetInSeconds} seconds.`,
+ });
+ }
+
+ entry.count++;
+ return opts.next();
+ };
+}
diff --git a/packages/trpc/routers/apiKeys.ts b/packages/trpc/routers/apiKeys.ts
index eb52189b..d4e01aa5 100644
--- a/packages/trpc/routers/apiKeys.ts
+++ b/packages/trpc/routers/apiKeys.ts
@@ -11,7 +11,12 @@ import {
logAuthenticationError,
validatePassword,
} from "../auth";
-import { authedProcedure, publicProcedure, router } from "../index";
+import {
+ authedProcedure,
+ createRateLimitMiddleware,
+ publicProcedure,
+ router,
+} from "../index";
const zApiKeySchema = z.object({
id: z.string(),
@@ -70,6 +75,13 @@ export const apiKeysAppRouter = router({
// Exchange the username and password with an API key.
// Homemade oAuth. This is used by the extension.
exchange: publicProcedure
+ .use(
+ createRateLimitMiddleware({
+ name: "apiKey.exchange",
+ windowMs: 15 * 60 * 1000,
+ maxRequests: 10,
+ }),
+ ) // 10 requests per 15 minutes
.input(
z.object({
keyName: z.string(),
@@ -97,6 +109,13 @@ export const apiKeysAppRouter = router({
return await generateApiKey(input.keyName, user.id);
}),
validate: publicProcedure
+ .use(
+ createRateLimitMiddleware({
+ name: "apiKey.validate",
+ windowMs: 60 * 1000,
+ maxRequests: 30,
+ }),
+ ) // 30 requests per minute
.input(z.object({ apiKey: z.string() }))
.output(z.object({ success: z.boolean() }))
.mutation(async ({ input, ctx }) => {
diff --git a/packages/trpc/routers/invites.ts b/packages/trpc/routers/invites.ts
index 5f7897c5..0a98f36a 100644
--- a/packages/trpc/routers/invites.ts
+++ b/packages/trpc/routers/invites.ts
@@ -7,7 +7,12 @@ import { invites, users } from "@karakeep/db/schema";
import { generatePasswordSalt, hashPassword } from "../auth";
import { sendInviteEmail } from "../email";
-import { adminProcedure, publicProcedure, router } from "../index";
+import {
+ adminProcedure,
+ createRateLimitMiddleware,
+ publicProcedure,
+ router,
+} from "../index";
import { createUserRaw } from "./users";
export const invitesAppRouter = router({
@@ -113,6 +118,13 @@ export const invitesAppRouter = router({
}),
get: publicProcedure
+ .use(
+ createRateLimitMiddleware({
+ name: "invites.get",
+ windowMs: 60 * 1000,
+ maxRequests: 10,
+ }),
+ )
.input(
z.object({
token: z.string(),
@@ -153,6 +165,13 @@ export const invitesAppRouter = router({
}),
accept: publicProcedure
+ .use(
+ createRateLimitMiddleware({
+ name: "invites.accept",
+ windowMs: 60 * 1000,
+ maxRequests: 10,
+ }),
+ )
.input(
z.object({
token: z.string(),
diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts
index 58093b42..ebe7d96f 100644
--- a/packages/trpc/routers/users.ts
+++ b/packages/trpc/routers/users.ts
@@ -31,6 +31,7 @@ import {
adminProcedure,
authedProcedure,
Context,
+ createRateLimitMiddleware,
publicProcedure,
router,
} from "../index";
@@ -124,6 +125,13 @@ export async function createUser(
export const usersAppRouter = router({
create: publicProcedure
+ .use(
+ createRateLimitMiddleware({
+ name: "users.create",
+ windowMs: 60 * 1000,
+ maxRequests: 3,
+ }),
+ )
.input(zSignUpSchema)
.output(
z.object({
@@ -541,6 +549,13 @@ export const usersAppRouter = router({
.where(eq(userSettings.userId, ctx.user.id));
}),
verifyEmail: publicProcedure
+ .use(
+ createRateLimitMiddleware({
+ name: "users.verifyEmail",
+ windowMs: 5 * 60 * 1000,
+ maxRequests: 10,
+ }),
+ ) // 10 requests per 5 minutes
.input(
z.object({
email: z.string().email(),
@@ -572,6 +587,13 @@ export const usersAppRouter = router({
return { success: true };
}),
resendVerificationEmail: publicProcedure
+ .use(
+ createRateLimitMiddleware({
+ name: "users.resendVerificationEmail",
+ windowMs: 5 * 60 * 1000,
+ maxRequests: 3,
+ }),
+ ) // 3 requests per 5 minutes
.input(
z.object({
email: z.string().email(),