feat: better ratelimiting (#2520)

Replace hono-rate-limiter with a Prisma/PostgreSQL bucketed counter
approach that works correctly across multiple instances without sticky
sessions.

- Add RateLimit model with composite PK (key, action, bucket) and atomic
upsert
- Create rate limit factory with window parsing, bucket computation, and
fail-open
- Define auth-tier and API-tier rate limit instances
- Add Hono middleware, rateLimitResponse helper, and tRPC
assertRateLimit helper
- Wire rate limit headers through AppError constructor (was declared but
never assigned)
- Apply rate limits to auth routes (email-password, passkey), tRPC
routes
  (2FA email, link org account), API routes, and file upload endpoints
- Add cleanup cron job for expired rate limit rows (batched delete every
15 min)
- Remove hono-rate-limiter dependency
This commit is contained in:
Lucas Smith
2026-02-20 12:23:02 +11:00
committed by GitHub
parent 006b1d0a57
commit 653ab3678a
21 changed files with 1218 additions and 62 deletions
@@ -4,6 +4,8 @@ import { DateTime } from 'luxon';
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from '@documenso/lib/server-only/2fa/email/constants';
import { send2FATokenEmail } from '@documenso/lib/server-only/2fa/email/send-2fa-token-email';
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { request2FAEmailRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
@@ -21,6 +23,13 @@ export const accessAuthRequest2FAEmailRoute = procedure
try {
const { token } = input;
const rateLimitResult = await request2FAEmailRateLimit.check({
ip: ctx.metadata.requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
assertRateLimit(rateLimitResult);
const user = ctx.user;
// Get document and recipient by token
@@ -1,4 +1,6 @@
import { linkOrganisationAccount } from '@documenso/ee/server-only/lib/link-organisation-account';
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { linkOrgAccountRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { procedure } from '../trpc';
import {
@@ -15,6 +17,13 @@ export const linkOrganisationAccountRoute = procedure
.mutation(async ({ input, ctx }) => {
const { token } = input;
const rateLimitResult = await linkOrgAccountRateLimit.check({
ip: ctx.metadata.requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
assertRateLimit(rateLimitResult);
await linkOrganisationAccount({
token,
requestMeta: ctx.metadata.requestMetadata,
+7 -1
View File
@@ -37,7 +37,7 @@ const t = initTRPC
.create({
transformer: dataTransformer,
errorFormatter(opts) {
const { shape, error } = opts;
const { shape, error, ctx } = opts;
const originalError = error.cause;
@@ -46,6 +46,12 @@ const t = initTRPC
// Default unknown errors to 400, since if you're throwing an AppError it is expected
// that you already know what you're doing.
if (originalError instanceof AppError) {
if (originalError.headers && ctx) {
for (const [headerKey, headerValue] of Object.entries(originalError.headers)) {
ctx.res.headers.append(headerKey, headerValue);
}
}
data = {
...data,
appError: AppError.toJSON(originalError),