mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 09:54:51 +10:00
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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -64,6 +64,11 @@ type AppErrorOptions = {
|
||||
* Mainly used for API -> Frontend communication and logging filtering.
|
||||
*/
|
||||
statusCode?: number;
|
||||
|
||||
/**
|
||||
* Optional headers to include when this error is returned in an API response.
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export class AppError extends Error {
|
||||
@@ -82,6 +87,8 @@ export class AppError extends Error {
|
||||
*/
|
||||
statusCode?: number;
|
||||
|
||||
headers?: Record<string, string>;
|
||||
|
||||
name = 'AppError';
|
||||
|
||||
/**
|
||||
@@ -97,6 +104,7 @@ export class AppError extends Error {
|
||||
this.code = errorCode;
|
||||
this.userMessage = options?.userMessage;
|
||||
this.statusCode = options?.statusCode;
|
||||
this.headers = options?.headers;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-sig
|
||||
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
||||
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||
import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits';
|
||||
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
|
||||
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
|
||||
import { PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION } from './definitions/internal/process-recipient-expired';
|
||||
@@ -37,6 +38,7 @@ export const jobsClient = new JobClient([
|
||||
EXECUTE_WEBHOOK_JOB_DEFINITION,
|
||||
EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION,
|
||||
PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION,
|
||||
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
||||
] as const);
|
||||
|
||||
export const jobs = jobsClient;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TCleanupRateLimitsJobDefinition } from './cleanup-rate-limits';
|
||||
|
||||
const BATCH_SIZE = 10_000;
|
||||
|
||||
export const run = async ({ io }: { payload: TCleanupRateLimitsJobDefinition; io: JobRunIO }) => {
|
||||
const cutoff = DateTime.now().minus({ hours: 24 }).toJSDate();
|
||||
|
||||
let totalDeleted = 0;
|
||||
let deleted = 0;
|
||||
|
||||
do {
|
||||
// Prisma doesn't support DELETE with LIMIT, so use raw SQL for batching
|
||||
// to avoid long-running transactions that could lock the table.
|
||||
deleted = await prisma.$executeRaw`
|
||||
DELETE FROM "RateLimit"
|
||||
WHERE ctid IN (
|
||||
SELECT ctid FROM "RateLimit"
|
||||
WHERE "createdAt" < ${cutoff}
|
||||
LIMIT ${BATCH_SIZE}
|
||||
)
|
||||
`;
|
||||
|
||||
totalDeleted += deleted;
|
||||
} while (deleted >= BATCH_SIZE);
|
||||
|
||||
if (totalDeleted > 0) {
|
||||
io.logger.info(`Cleaned up ${totalDeleted} expired rate limit entries`);
|
||||
} else {
|
||||
io.logger.info('No expired rate limit entries to clean up');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID = 'internal.cleanup-rate-limits';
|
||||
|
||||
const CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA = z.object({});
|
||||
|
||||
export type TCleanupRateLimitsJobDefinition = z.infer<
|
||||
typeof CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const CLEANUP_RATE_LIMITS_JOB_DEFINITION = {
|
||||
id: CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
|
||||
name: 'Cleanup Rate Limits',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
|
||||
schema: CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA,
|
||||
cron: '*/15 * * * *', // Every 15 minutes.
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./cleanup-rate-limits.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
|
||||
TCleanupRateLimitsJobDefinition
|
||||
>;
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Context } from 'hono';
|
||||
import type { MiddlewareHandler } from 'hono/types';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { getIpAddress } from '../../universal/get-ip-address';
|
||||
import type { RateLimitCheckResult } from './rate-limit';
|
||||
import type { createRateLimit } from './rate-limit';
|
||||
|
||||
/**
|
||||
* Set rate limit response headers on a Hono context.
|
||||
*/
|
||||
const setRateLimitHeaders = (c: Context, result: RateLimitCheckResult) => {
|
||||
c.header('X-RateLimit-Limit', String(result.limit));
|
||||
c.header('X-RateLimit-Remaining', String(result.remaining));
|
||||
c.header('X-RateLimit-Reset', String(Math.ceil(result.reset.getTime() / 1000)));
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Hono middleware that applies rate limiting to a route.
|
||||
*
|
||||
* Uses IP address for identification. Optionally accepts an identifier
|
||||
* function for per-user/per-entity limiting.
|
||||
*/
|
||||
export const createRateLimitMiddleware = (
|
||||
limiter: ReturnType<typeof createRateLimit>,
|
||||
options?: { identifierFn?: (c: Context) => string | undefined },
|
||||
): MiddlewareHandler => {
|
||||
return async (c, next) => {
|
||||
let ip: string;
|
||||
|
||||
try {
|
||||
ip = getIpAddress(c.req.raw);
|
||||
} catch {
|
||||
ip = 'unknown';
|
||||
}
|
||||
|
||||
const identifier = options?.identifierFn?.(c);
|
||||
|
||||
const result = await limiter.check({ ip, identifier });
|
||||
|
||||
setRateLimitHeaders(c, result);
|
||||
|
||||
if (result.isLimited) {
|
||||
c.header(
|
||||
'Retry-After',
|
||||
String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
|
||||
);
|
||||
|
||||
return c.json({ error: 'Too many requests, please try again later.' }, 429);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper for inline rate limit checks in Hono auth routes.
|
||||
*
|
||||
* Returns a 429 Response with rate limit headers if limited, or `null` if allowed.
|
||||
*/
|
||||
export const rateLimitResponse = (c: Context, result: RateLimitCheckResult): Response | null => {
|
||||
setRateLimitHeaders(c, result);
|
||||
|
||||
if (result.isLimited) {
|
||||
c.header(
|
||||
'Retry-After',
|
||||
String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
|
||||
);
|
||||
|
||||
return c.json({ error: 'Too many requests, please try again later.' }, 429);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper for inline rate limit checks in tRPC routes.
|
||||
*
|
||||
* Throws an AppError with TOO_MANY_REQUESTS code if limited.
|
||||
*/
|
||||
export const assertRateLimit = (result: RateLimitCheckResult): void => {
|
||||
if (result.isLimited) {
|
||||
const retryAfter = String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000)));
|
||||
|
||||
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
|
||||
message: 'Too many requests, please try again later.',
|
||||
headers: {
|
||||
'X-RateLimit-Limit': String(result.limit),
|
||||
'X-RateLimit-Remaining': String(result.remaining),
|
||||
'X-RateLimit-Reset': String(Math.ceil(result.reset.getTime() / 1000)),
|
||||
'Retry-After': retryAfter,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
type WindowUnit = 's' | 'm' | 'h' | 'd';
|
||||
type WindowStr = `${number}${WindowUnit}`;
|
||||
|
||||
type RateLimitConfig = {
|
||||
action: string;
|
||||
max: number;
|
||||
globalMax?: number;
|
||||
window: WindowStr;
|
||||
};
|
||||
|
||||
type CheckParams = {
|
||||
ip: string;
|
||||
identifier?: string;
|
||||
};
|
||||
|
||||
export type RateLimitCheckResult = {
|
||||
isLimited: boolean;
|
||||
remaining: number;
|
||||
limit: number;
|
||||
reset: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse window string (e.g., '1h', '15m', '30s') to milliseconds.
|
||||
*/
|
||||
export const parseWindow = (window: WindowStr): number => {
|
||||
const value = parseInt(window.slice(0, -1), 10);
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const unit = window.slice(-1) as WindowUnit;
|
||||
|
||||
const multipliers: Record<WindowUnit, number> = {
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
return value * multipliers[unit];
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the current time bucket for the given window size.
|
||||
*/
|
||||
export const getBucket = (windowMs: number): Date => {
|
||||
const now = Date.now();
|
||||
|
||||
return new Date(now - (now % windowMs));
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a rate limiter with the given configuration.
|
||||
*
|
||||
* Uses bucketed counters in the database for distributed rate limiting
|
||||
* across multiple instances. Each check atomically increments the counter
|
||||
* and returns the new count.
|
||||
*/
|
||||
export const createRateLimit = (config: RateLimitConfig) => {
|
||||
const windowMs = parseWindow(config.window);
|
||||
|
||||
return {
|
||||
async check(params: CheckParams): Promise<RateLimitCheckResult> {
|
||||
const bucket = getBucket(windowMs);
|
||||
const reset = new Date(bucket.getTime() + windowMs);
|
||||
const ipLimit = config.globalMax ?? config.max;
|
||||
|
||||
if (process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true') {
|
||||
return {
|
||||
isLimited: false,
|
||||
remaining: ipLimit,
|
||||
limit: ipLimit,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Always upsert the IP counter.
|
||||
const ipResult = await prisma.rateLimit.upsert({
|
||||
where: {
|
||||
key_action_bucket: {
|
||||
key: `ip:${params.ip}`,
|
||||
action: config.action,
|
||||
bucket,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
key: `ip:${params.ip}`,
|
||||
action: config.action,
|
||||
bucket,
|
||||
count: 1,
|
||||
},
|
||||
update: {
|
||||
count: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// Check IP against globalMax if set, or against max if no identifier is provided.
|
||||
let ipCheckLimit = config.globalMax;
|
||||
|
||||
if (!params.identifier) {
|
||||
ipCheckLimit = config.max;
|
||||
}
|
||||
|
||||
if (ipCheckLimit && ipResult.count > ipCheckLimit) {
|
||||
logger.warn({
|
||||
msg: 'Rate limit exceeded',
|
||||
action: config.action,
|
||||
keyType: 'ip',
|
||||
key: params.ip,
|
||||
count: ipResult.count,
|
||||
limit: ipCheckLimit,
|
||||
});
|
||||
|
||||
return {
|
||||
isLimited: true,
|
||||
remaining: 0,
|
||||
limit: ipCheckLimit,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
// Upsert the identifier counter if provided.
|
||||
if (params.identifier) {
|
||||
const identifierResult = await prisma.rateLimit.upsert({
|
||||
where: {
|
||||
key_action_bucket: {
|
||||
key: `id:${params.identifier}`,
|
||||
action: config.action,
|
||||
bucket,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
key: `id:${params.identifier}`,
|
||||
action: config.action,
|
||||
bucket,
|
||||
count: 1,
|
||||
},
|
||||
update: {
|
||||
count: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
if (identifierResult.count > config.max) {
|
||||
logger.warn({
|
||||
msg: 'Rate limit exceeded',
|
||||
action: config.action,
|
||||
keyType: 'identifier',
|
||||
key: params.identifier,
|
||||
count: identifierResult.count,
|
||||
limit: config.max,
|
||||
});
|
||||
|
||||
return {
|
||||
isLimited: true,
|
||||
remaining: 0,
|
||||
limit: config.max,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isLimited: false,
|
||||
remaining: Math.max(0, config.max - identifierResult.count),
|
||||
limit: config.max,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isLimited: false,
|
||||
remaining: Math.max(0, ipLimit - ipResult.count),
|
||||
limit: ipLimit,
|
||||
reset,
|
||||
};
|
||||
} catch (error) {
|
||||
// Fail-open: if the rate limit DB query fails, allow the request through.
|
||||
logger.error({
|
||||
msg: 'Rate limit check failed, failing open',
|
||||
action: config.action,
|
||||
error,
|
||||
});
|
||||
|
||||
const limit = params.identifier ? config.max : ipLimit;
|
||||
|
||||
return {
|
||||
isLimited: false,
|
||||
remaining: limit,
|
||||
limit,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createRateLimit } from './rate-limit';
|
||||
|
||||
// ---- Auth (Tier 1 - Critical, sends emails) ----
|
||||
|
||||
export const signupRateLimit = createRateLimit({
|
||||
action: 'auth.signup',
|
||||
max: 10,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const forgotPasswordRateLimit = createRateLimit({
|
||||
action: 'auth.forgot-password',
|
||||
max: 3,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const resendVerifyEmailRateLimit = createRateLimit({
|
||||
action: 'auth.resend-verify-email',
|
||||
max: 3,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const request2FAEmailRateLimit = createRateLimit({
|
||||
action: 'auth.request-2fa-email',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
// ---- Auth (Tier 2 - Unauthenticated) ----
|
||||
|
||||
export const loginRateLimit = createRateLimit({
|
||||
action: 'auth.login',
|
||||
max: 10,
|
||||
globalMax: 50,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const resetPasswordRateLimit = createRateLimit({
|
||||
action: 'auth.reset-password',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const verifyEmailRateLimit = createRateLimit({
|
||||
action: 'auth.verify-email',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const passkeyRateLimit = createRateLimit({
|
||||
action: 'auth.passkey',
|
||||
max: 10,
|
||||
globalMax: 50,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const linkOrgAccountRateLimit = createRateLimit({
|
||||
action: 'auth.link-org-account',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
// ---- API (Tier 4 - Standard) ----
|
||||
|
||||
export const apiV1RateLimit = createRateLimit({
|
||||
action: 'api.v1',
|
||||
max: 100,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const apiV2RateLimit = createRateLimit({
|
||||
action: 'api.v2',
|
||||
max: 100,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const apiTrpcRateLimit = createRateLimit({
|
||||
action: 'api.trpc',
|
||||
max: 100,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const aiRateLimit = createRateLimit({
|
||||
action: 'api.ai',
|
||||
max: 3,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const fileUploadRateLimit = createRateLimit({
|
||||
action: 'api.file-upload',
|
||||
max: 20,
|
||||
window: '1m',
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "RateLimit" (
|
||||
"key" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"bucket" TIMESTAMP(3) NOT NULL,
|
||||
"count" INTEGER NOT NULL DEFAULT 1,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RateLimit_pkey" PRIMARY KEY ("key","action","bucket")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RateLimit_createdAt_idx" ON "RateLimit"("createdAt");
|
||||
@@ -1086,3 +1086,15 @@ model Counter {
|
||||
id String @id
|
||||
value Int
|
||||
}
|
||||
|
||||
model RateLimit {
|
||||
key String
|
||||
action String
|
||||
bucket DateTime
|
||||
count Int @default(1)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@id([key, action, bucket])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user