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
+100 -3
View File
@@ -2,6 +2,7 @@ import { sValidator } from '@hono/standard-validator';
import { compare } from '@node-rs/bcrypt';
import { UserSecurityAuditLogType } from '@prisma/client';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { DateTime } from 'luxon';
import { z } from 'zod';
@@ -14,6 +15,15 @@ import { isTwoFactorAuthenticationEnabled } from '@documenso/lib/server-only/2fa
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import {
forgotPasswordRateLimit,
loginRateLimit,
resendVerifyEmailRateLimit,
resetPasswordRateLimit,
signupRateLimit,
verifyEmailRateLimit,
} from '@documenso/lib/server-only/rate-limit/rate-limits';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
@@ -51,6 +61,19 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const { email, password, totpCode, backupCode, csrfToken } = c.req.valid('json');
const loginLimitResult = await loginRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const loginLimited = rateLimitResponse(c, loginLimitResult);
if (loginLimited) {
throw new HTTPException(429, {
res: loginLimited,
});
}
const csrfCookieToken = await getCsrfCookie(c);
// Todo: (RR7) Add logging here.
@@ -152,6 +175,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Signup endpoint.
*/
.post('/signup', sValidator('json', ZSignUpSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
throw new AppError('SIGNUP_DISABLED', {
message: 'Signups are disabled.',
@@ -160,6 +185,18 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const { name, email, password, signature } = c.req.valid('json');
const signupLimitResult = await signupRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
});
const signupLimited = rateLimitResponse(c, signupLimitResult);
if (signupLimited) {
throw new HTTPException(429, {
res: signupLimited,
});
}
const user = await createUser({ name, email, password, signature }).catch((err) => {
console.error(err);
throw err;
@@ -219,7 +256,24 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Verify email endpoint.
*/
.post('/verify-email', sValidator('json', ZVerifyEmailSchema), async (c) => {
const { state, userId } = await verifyEmail({ token: c.req.valid('json').token });
const requestMetadata = c.get('requestMetadata');
const { token } = c.req.valid('json');
const verifyLimitResult = await verifyEmailRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
const verifyLimited = rateLimitResponse(c, verifyLimitResult);
if (verifyLimited) {
throw new HTTPException(429, {
res: verifyLimited,
});
}
const { state, userId } = await verifyEmail({ token });
// If email is verified, automatically authenticate user.
if (state === EMAIL_VERIFICATION_STATE.VERIFIED && userId !== null) {
@@ -234,8 +288,23 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Resend verification email endpoint.
*/
.post('/resend-verify-email', sValidator('json', ZResendVerifyEmailSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email } = c.req.valid('json');
const resendLimitResult = await resendVerifyEmailRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const resendLimited = rateLimitResponse(c, resendLimitResult);
if (resendLimited) {
throw new HTTPException(429, {
res: resendLimited,
});
}
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
@@ -249,8 +318,23 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Forgot password endpoint.
*/
.post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email } = c.req.valid('json');
const forgotLimitResult = await forgotPasswordRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const forgotLimited = rateLimitResponse(c, forgotLimitResult);
if (forgotLimited) {
throw new HTTPException(429, {
res: forgotLimited,
});
}
if (
email.toLowerCase() === legacyServiceAccountEmail() ||
email.toLowerCase() === deletedServiceAccountEmail()
@@ -268,8 +352,23 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Reset password endpoint.
*/
.post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { token, password } = c.req.valid('json');
const resetLimitResult = await resetPasswordRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
const resetLimited = rateLimitResponse(c, resetLimitResult);
if (resetLimited) {
throw new HTTPException(429, {
res: resetLimited,
});
}
const user = await getUserByResetToken({ token });
if (
@@ -279,8 +378,6 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
return c.text('FORBIDDEN', 403);
}
const requestMetadata = c.get('requestMetadata');
const { userId } = await resetPassword({
token,
password,
+15
View File
@@ -3,8 +3,11 @@ import { UserSecurityAuditLogType } from '@prisma/client';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { passkeyRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
import type { TAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
@@ -23,6 +26,18 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
.post('/authorize', sValidator('json', ZPasskeyAuthorizeSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const passkeyLimitResult = await passkeyRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
});
const passkeyLimited = rateLimitResponse(c, passkeyLimitResult);
if (passkeyLimited) {
throw new HTTPException(429, {
res: passkeyLimited,
});
}
const { csrfToken, credential } = c.req.valid('json');
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {