diff --git a/.agents/plans/smooth-coral-earth-database-rate-limiting.md b/.agents/plans/smooth-coral-earth-database-rate-limiting.md new file mode 100644 index 000000000..9949ba430 --- /dev/null +++ b/.agents/plans/smooth-coral-earth-database-rate-limiting.md @@ -0,0 +1,551 @@ +--- +date: 2026-02-19 +title: Database Rate Limiting +--- + +## Summary + +Replace the in-memory `hono-rate-limiter` with a database-backed rate limiting system using Prisma and PostgreSQL. The current in-memory approach is ineffective in multi-instance deployments since there are no sticky sessions. The new system uses **bucketed counters** (one row per key/action/time-bucket with atomic increment) to efficiently handle both high-throughput API rate limiting and granular auth/email rate limiting. + +### Design Decisions + +- **Bucketed counters** over row-per-request: high-throughput consumers would create thousands of rows per minute; bucketed counters reduce this to one row per key per time bucket +- **Fixed time windows**: simpler than sliding windows, the 2x burst-at-boundary scenario is acceptable for rate limiting purposes +- **Dual-key rate limiting**: per-identifier (`max`) and per-IP (`globalMax`) checked independently via separate rows with a `key` prefix (`id:` / `ip:`) +- **Accept slight over-count**: the upsert is atomic (increment + return count in one operation) but concurrent requests near the limit may both see a count just under the threshold before either commits, allowing a slight overshoot +- **Fail-open on errors**: if the rate limit DB query fails, allow the request through rather than blocking legitimate users +- **Prisma upsert** with `{ increment: 1 }` for atomic counter updates, returns the updated row so count check is a single operation +- **Application cron job** for cleanup of expired bucket rows + +### Rate Limit Check Flow + +``` +check({ ip, identifier }) -> + 1. Upsert IP row (ip:{ip} / action / bucket) with count + 1, RETURNING count + -> if globalMax is set and count >= globalMax, return { isLimited: true } + 2. Upsert identifier row (id:{identifier} / action / bucket) with count + 1, RETURNING count + -> if count >= max, return { isLimited: true } + 3. Neither limited -> return { isLimited: false } +``` + +Each upsert atomically increments and returns the new count in a single operation. Both counters always increment on every check — there's no conditional logic to skip one based on the other. This keeps the implementation simple and avoids read-then-write race conditions. If only IP is provided (API rate limiting), only step 1 runs. + +--- + +## 1. Database Schema + +### 1.1 Prisma model + +Add to `packages/prisma/schema.prisma` after the `Counter` model: + +```prisma +model RateLimit { + key String + action String + bucket DateTime + count Int @default(1) + + createdAt DateTime @default(now()) + + @@id([key, action, bucket]) + @@index([createdAt]) +} +``` + +- **Composite primary key** `(key, action, bucket)` serves as both the unique constraint for upserts and the lookup index +- **`key`** is prefixed: `ip:1.2.3.4` or `id:user@example.com` +- **`action`** is the rate limit action name: `auth.forgot-password`, `api.v1`, etc. +- **`bucket`** is the start of the time window, truncated to the window size (e.g., `2026-02-19T10:05:00Z` for a 5-minute bucket) +- **`createdAt` index** is for the cleanup job to efficiently delete old rows +- **`count`** starts at 1 (set by the create side of the upsert) + +### 1.2 Migration + +Generate with `npx prisma migrate dev --name add-rate-limits`. + +--- + +## 2. Rate Limit Library + +### 2.1 Core module + +Create `packages/lib/server-only/rate-limit/rate-limit.ts`: + +```typescript +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 const rateLimit = (config: RateLimitConfig) => { + return { + async check(params: CheckParams): Promise<{ + isLimited: boolean; + remaining: number; + limit: number; + reset: Date; + }> { ... } + }; +}; +``` + +### 2.2 Window parsing and bucket computation + +```typescript +const parseWindow = (window: WindowStr): number => { + const value = parseInt(window.slice(0, -1), 10); + const unit = window.slice(-1) as WindowUnit; + const multipliers: Record = { + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + }; + return value * multipliers[unit]; +}; + +const getBucket = (windowMs: number): Date => { + const now = Date.now(); + return new Date(now - (now % windowMs)); +}; +``` + +### 2.3 Check implementation + +The `check()` method: + +1. Compute the current bucket from the window +2. Compute `reset` as `bucket + windowMs` (the start of the next window) +3. If `globalMax` is set, upsert the IP row and check count +4. If `identifier` is provided, upsert the identifier row and check count +5. Wrap in try/catch — **fail-open** on any database error (log the error, return `{ isLimited: false }`) + +Each upsert uses Prisma's `upsert` with `{ increment: 1 }`: + +```typescript +const result = 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 }, + }, +}); + +if (config.globalMax && result.count >= config.globalMax) { + return { isLimited: true, remaining: 0, limit: config.globalMax }; +} +``` + +### 2.4 Rate limit definitions + +Create `packages/lib/server-only/rate-limit/rate-limits.ts` with all rate limit instances: + +```typescript +// ---- Auth (Tier 1 - Critical, sends emails) ---- +export const signupRateLimit = rateLimit({ + action: 'auth.signup', + max: 5, + globalMax: 10, + window: '1h', +}); + +export const forgotPasswordRateLimit = rateLimit({ + action: 'auth.forgot-password', + max: 3, + globalMax: 20, + window: '1h', +}); + +export const resendVerifyEmailRateLimit = rateLimit({ + action: 'auth.resend-verify-email', + max: 3, + globalMax: 20, + window: '1h', +}); + +export const request2FAEmailRateLimit = rateLimit({ + action: 'auth.request-2fa-email', + max: 5, + globalMax: 20, + window: '15m', +}); + +// ---- Auth (Tier 2 - Unauthenticated) ---- +export const loginRateLimit = rateLimit({ + action: 'auth.login', + max: 10, + globalMax: 50, + window: '15m', +}); + +export const resetPasswordRateLimit = rateLimit({ + action: 'auth.reset-password', + max: 5, + globalMax: 20, + window: '1h', +}); + +export const verifyEmailRateLimit = rateLimit({ + action: 'auth.verify-email', + max: 5, + globalMax: 20, + window: '15m', +}); + +export const passkeyRateLimit = rateLimit({ + action: 'auth.passkey', + max: 10, + globalMax: 50, + window: '15m', +}); + +export const oauthRateLimit = rateLimit({ + action: 'auth.oauth', + max: 10, + globalMax: 50, + window: '15m', +}); + +export const linkOrgAccountRateLimit = rateLimit({ + action: 'auth.link-org-account', + max: 5, + globalMax: 20, + window: '1h', +}); + +// ---- API (Tier 4 - Standard) ---- +export const apiV1RateLimit = rateLimit({ + action: 'api.v1', + max: 100, + window: '1m', +}); + +export const apiV2RateLimit = rateLimit({ + action: 'api.v2', + max: 100, + window: '1m', +}); + +export const apiTrpcRateLimit = rateLimit({ + action: 'api.trpc', + max: 100, + window: '1m', +}); + +export const aiRateLimit = rateLimit({ + action: 'api.ai', + max: 3, + window: '1m', +}); + +export const fileUploadRateLimit = rateLimit({ + action: 'api.file-upload', + max: 20, + window: '1m', +}); +``` + +Exact limits are initial values — tune based on observed traffic patterns. These should be easy to adjust. + +--- + +## 3. Integration Points + +### 3.1 Hono middleware for API routes + +Create a reusable Hono middleware factory in `packages/lib/server-only/rate-limit/rate-limit-middleware.ts` that wraps the `rateLimit` checker into Hono middleware: + +```typescript +import { type MiddlewareHandler } from 'hono'; + +import { getIpAddress } from '@documenso/lib/universal/get-ip-address'; + +export const createRateLimitMiddleware = ( + limiter: ReturnType, + 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 }); + + 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))); + + if (result.isLimited) { + c.header('Retry-After', String(Math.ceil((result.reset.getTime() - Date.now()) / 1000))); + return c.json({ error: 'Too many requests, please try again later.' }, 429); + } + + await next(); + }; +}; +``` + +### 3.2 Replace existing Hono rate limiters + +In `apps/remix/server/router.ts`: + +- Remove `hono-rate-limiter` import and both `rateLimiter()` instances +- Replace with `createRateLimitMiddleware()` calls using the defined rate limits +- API routes use IP-only limiting (no identifier) +- AI route uses IP-only limiting with the stricter 3/min limit + +```typescript +// Before +import { rateLimiter } from 'hono-rate-limiter'; +const rateLimitMiddleware = rateLimiter({ ... }); + +// After +import { createRateLimitMiddleware } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware'; +import { apiV1RateLimit, apiV2RateLimit, aiRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits'; + +const apiV1RateLimitMiddleware = createRateLimitMiddleware(apiV1RateLimit); +const apiV2RateLimitMiddleware = createRateLimitMiddleware(apiV2RateLimit); +const aiRateLimitMiddleware = createRateLimitMiddleware(aiRateLimit); +``` + +### 3.3 Response helpers for inline checks + +For auth routes (Hono handlers) and tRPC routes where rate limiting is applied inline rather than via middleware, provide helpers that handle the response formatting and headers consistently. + +**Hono helper** — returns a 429 `Response` with headers if limited, or `null` if allowed: + +```typescript +export const rateLimitResponse = (c: Context, result: RateLimitCheckResult): Response | null => { + 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))); + + if (result.isLimited) { + c.header('Retry-After', String(Math.ceil((result.reset.getTime() - Date.now()) / 1000))); + return c.json({ error: 'Too many requests, please try again later.' }, 429); + } + + return null; +}; +``` + +Usage in auth routes: + +```typescript +const result = await loginRateLimit.check({ + ip: requestMetadata.ipAddress ?? 'unknown', + identifier: input.email, +}); + +const limited = rateLimitResponse(c, result); +if (limited) return limited; +``` + +**tRPC helper** — throws a `TRPCError` with rate limit headers if limited: + +```typescript +export const assertRateLimit = (result: RateLimitCheckResult): void => { + if (result.isLimited) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + }); + } +}; +``` + +Usage in tRPC routes: + +```typescript +const result = await request2FAEmailRateLimit.check({ + ip: ctx.requestMetadata.ipAddress ?? 'unknown', + identifier: input.recipientId, +}); + +assertRateLimit(result); +``` + +Both helpers live in `packages/lib/server-only/rate-limit/rate-limit-middleware.ts` alongside the Hono middleware. + +### 3.4 Auth endpoint rate limiting + +In `packages/auth/server/routes/email-password.ts`, add rate limit checks at the start of each handler using the `rateLimitResponse` helper. + +Apply to each endpoint per the tier list: + +| Endpoint | Rate Limit | +| --------------------------- | ----------------------------------------------------- | +| `POST /signup` | `signupRateLimit` with `identifier: email` | +| `POST /authorize` (login) | `loginRateLimit` with `identifier: email` | +| `POST /forgot-password` | `forgotPasswordRateLimit` with `identifier: email` | +| `POST /resend-verify-email` | `resendVerifyEmailRateLimit` with `identifier: email` | +| `POST /verify-email` | `verifyEmailRateLimit` with `identifier: token` | +| `POST /reset-password` | `resetPasswordRateLimit` with `identifier: token` | +| `POST /passkey/authorize` | `passkeyRateLimit` (IP only, no identifier) | +| `POST /oauth/authorize/*` | `oauthRateLimit` (IP only) | + +### 3.4 tRPC unauthenticated route rate limiting + +For unauthenticated tRPC routes that send emails, add rate limit checks at the start of the route handler: + +| Route | Rate Limit | Identifier | +| ---------------------------------------------------------- | ------------------------------------ | ---------------------- | +| `document.accessAuth.request2FAEmail` | `request2FAEmailRateLimit` | `recipientId` or token | +| `enterprise.organisation.authenticationPortal.linkAccount` | `linkOrgAccountRateLimit` | email | +| `template.createDocumentFromDirectTemplate` | Dedicated direct template rate limit | IP only | + +Access `requestMetadata` from the tRPC context (`ctx.requestMetadata.ipAddress`). + +### 3.5 tRPC and file routes — general API rate limiting + +Add rate limit middleware for currently unprotected routes: + +- `/api/trpc/*` — apply `apiTrpcRateLimit` middleware +- `/api/files/*` — apply `fileUploadRateLimit` middleware + +--- + +## 4. Cleanup Job + +### 4.1 Job definition + +Create `packages/lib/jobs/definitions/internal/cleanup-rate-limits.ts`: + +```typescript +export const CLEANUP_RATE_LIMITS_JOB_DEFINITION = { + id: 'internal.cleanup-rate-limits', + name: 'Cleanup Rate Limits', + version: '1.0.0', + trigger: { + name: 'internal.cleanup-rate-limits', + schema: z.object({}), + 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<...>; +``` + +### 4.2 Job handler + +Create `packages/lib/jobs/definitions/internal/cleanup-rate-limits.handler.ts`: + +- Delete all `RateLimit` rows where `createdAt` is older than 24 hours (covers all possible windows with margin) +- Use batched deletes to avoid long-running transactions +- Batch in chunks of 10,000 rows + +```typescript +export const run = async () => { + const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000); + + let deleted = 0; + do { + // Prisma doesn't support DELETE with LIMIT, so use raw SQL for batching + deleted = await prisma.$executeRaw` + DELETE FROM "RateLimit" + WHERE "createdAt" < ${cutoff} + AND ctid IN ( + SELECT ctid FROM "RateLimit" + WHERE "createdAt" < ${cutoff} + LIMIT 10000 + ) + `; + } while (deleted > 0); +}; +``` + +### 4.3 Register in job client + +Add `CLEANUP_RATE_LIMITS_JOB_DEFINITION` to the job registry in `packages/lib/jobs/client.ts`. + +--- + +## 5. Remove hono-rate-limiter Dependency + +After the migration is complete: + +- Remove `hono-rate-limiter` from `apps/remix/package.json` +- Run `npm install` to clean up + +--- + +## 6. Files to Create or Modify + +### New Files + +| File | Purpose | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `packages/lib/server-only/rate-limit/rate-limit.ts` | Core rate limit factory (`rateLimit()`) with window parsing, bucket computation, Prisma upsert, fail-open | +| `packages/lib/server-only/rate-limit/rate-limits.ts` | All rate limit instances (auth, API, AI, file upload) | +| `packages/lib/server-only/rate-limit/rate-limit-middleware.ts` | Hono middleware factory, `rateLimitResponse` helper for Hono handlers, `assertRateLimit` helper for tRPC routes | +| `packages/lib/jobs/definitions/internal/cleanup-rate-limits.ts` | Cleanup cron job definition | +| `packages/lib/jobs/definitions/internal/cleanup-rate-limits.handler.ts` | Cleanup handler (batched deletes) | + +### Modified Files + +| File | Change | +| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `packages/prisma/schema.prisma` | Add `RateLimit` model | +| `apps/remix/server/router.ts` | Replace `hono-rate-limiter` with DB-backed middleware, add rate limits for `/api/trpc/*` and `/api/files/*` | +| `apps/remix/package.json` | Remove `hono-rate-limiter` dependency | +| `packages/auth/server/routes/email-password.ts` | Add rate limit checks to signup, login, forgot-password, resend-verify-email, verify-email, reset-password | +| `packages/auth/server/routes/passkey.ts` | Add rate limit check to passkey authorize | +| `packages/auth/server/routes/oauth.ts` | Add rate limit check to OAuth authorize endpoints | +| `packages/trpc/server/document-router/access-auth-request-2fa-email.ts` | Add rate limit check (sends email, unauthenticated) | +| `packages/trpc/server/enterprise-router/link-organisation-account.ts` | Add rate limit check (sends email, unauthenticated) | +| `packages/lib/jobs/client.ts` | Register cleanup-rate-limits job definition | + +--- + +## 7. Considerations + +### 7.1 Fail-open + +All rate limit checks must be wrapped in try/catch. On any DB error, log the error and allow the request through. Rate limiting should never block legitimate traffic due to infrastructure issues. + +### 7.2 Performance + +- Each API request adds 1 upsert query (~1ms) +- Auth requests add 2 upsert queries (~2ms total) +- The composite primary key ensures all lookups and upserts are index-only operations +- No `COUNT(*)` queries — the count is stored directly in the row + +### 7.3 Monitoring + +Log rate limit hits at `warn` level with the action, key type (IP/identifier), and count. This provides visibility into traffic patterns and helps tune limits. + +### 7.4 Testing + +The rate limit module should be mockable in tests. Consider exporting the bucket computation and window parsing as standalone functions for unit testing. Integration tests can verify the upsert + count logic against a test database. + +### 7.5 Future improvements + +- **Redis backend**: if DB pressure from rate limiting becomes measurable, swap the Prisma upsert for Redis `INCR` + `EXPIRE` with no API changes +- **System-wide circuit breaker**: add a `systemMax` config option that counts all requests for an action regardless of key diff --git a/.env.example b/.env.example index 29fffa4e9..2ba589d9d 100644 --- a/.env.example +++ b/.env.example @@ -175,6 +175,8 @@ GOOGLE_VERTEX_API_KEY="" E2E_TEST_AUTHENTICATE_USERNAME="Test User" E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com" E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123" +# OPTIONAL: Set to "true" to disable all rate limiting. Only use for E2E tests. +DANGEROUS_BYPASS_RATE_LIMITS= # [[LOGGER]] # OPTIONAL: The file to save the logger output to. Will disable stdout if provided. diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 274a22f2e..a5f5bc9ee 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -41,6 +41,7 @@ jobs: env: # Needed since we use next start which will set the NODE_ENV to production NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH: './example/cert.p12' + DANGEROUS_BYPASS_RATE_LIMITS: 'true' - uses: actions/upload-artifact@v4 if: always() diff --git a/apps/remix/package.json b/apps/remix/package.json index dce0cc408..f056fd86f 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -46,7 +46,6 @@ "content-disposition": "^1.0.1", "framer-motion": "^12.23.24", "hono": "4.11.4", - "hono-rate-limiter": "^0.4.2", "hono-react-router-adapter": "^0.6.5", "input-otp": "^1.4.2", "isbot": "^5.1.32", diff --git a/apps/remix/server/router.ts b/apps/remix/server/router.ts index dd0eca8a9..8285f1e4a 100644 --- a/apps/remix/server/router.ts +++ b/apps/remix/server/router.ts @@ -1,20 +1,25 @@ import { Hono } from 'hono'; -import { rateLimiter } from 'hono-rate-limiter'; import { contextStorage } from 'hono/context-storage'; import { cors } from 'hono/cors'; -import { requestId } from 'hono/request-id'; import type { RequestIdVariables } from 'hono/request-id'; +import { requestId } from 'hono/request-id'; import type { Logger } from 'pino'; import { tsRestHonoApp } from '@documenso/api/hono'; import { auth } from '@documenso/auth/server'; -import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app'; import { jobsClient } from '@documenso/lib/jobs/client'; import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; +import { createRateLimitMiddleware } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware'; +import { + aiRateLimit, + apiTrpcRateLimit, + apiV1RateLimit, + apiV2RateLimit, + fileUploadRateLimit, +} from '@documenso/lib/server-only/rate-limit/rate-limits'; import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client'; import { migrateDeletedAccountServiceAccount } from '@documenso/lib/server-only/user/service-accounts/deleted-account'; import { migrateLegacyServiceAccount } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account'; -import { getIpAddress } from '@documenso/lib/universal/get-ip-address'; import { env } from '@documenso/lib/utils/env'; import { logger } from '@documenso/lib/utils/logger'; import { openApiDocument } from '@documenso/trpc/server/open-api'; @@ -37,38 +42,13 @@ export interface HonoEnv { const app = new Hono(); /** - * Rate limiting for v1 and v2 API routes only. - * - 100 requests per minute per IP address + * Database-backed rate limiting for API routes. */ -const rateLimitMiddleware = rateLimiter({ - windowMs: 60 * 1000, // 1 minute - limit: 100, // 100 requests per window - keyGenerator: (c) => { - try { - return getIpAddress(c.req.raw); - } catch (error) { - return 'unknown'; - } - }, - message: { - error: 'Too many requests, please try again later.', - }, -}); - -const aiRateLimitMiddleware = rateLimiter({ - windowMs: 60 * 1000, // 1 minute - limit: 3, // 3 requests per window - keyGenerator: (c) => { - try { - return getIpAddress(c.req.raw); - } catch (error) { - return 'unknown'; - } - }, - message: { - error: 'Too many requests, please try again later.', - }, -}); +const apiV1RateLimitMiddleware = createRateLimitMiddleware(apiV1RateLimit); +const apiV2RateLimitMiddleware = createRateLimitMiddleware(apiV2RateLimit); +const aiRateLimitMiddleware = createRateLimitMiddleware(aiRateLimit); +const trpcRateLimitMiddleware = createRateLimitMiddleware(apiTrpcRateLimit); +const fileRateLimitMiddleware = createRateLimitMiddleware(fileUploadRateLimit); /** * Attach session and context to requests. @@ -96,14 +76,20 @@ app.use(async (c, next) => { await next(); }); -// Apply rate limit to /api/v1/* -app.use('/api/v1/*', rateLimitMiddleware); -app.use('/api/v2/*', rateLimitMiddleware); +// Apply cors and rate limits to API routes. +app.use(`/api/v1/*`, cors()); +app.use('/api/v1/*', apiV1RateLimitMiddleware); +app.use(`/api/v2/*`, cors()); +app.use('/api/v2/*', apiV2RateLimitMiddleware); +app.use(`/api/v2-beta/*`, cors()); +app.use('/api/v2-beta/*', apiV2RateLimitMiddleware); // Auth server. app.route('/api/auth', auth); // Files route. +app.use('/api/files/upload-pdf', fileRateLimitMiddleware); +app.use('/api/files/presigned-post-url', fileRateLimitMiddleware); app.route('/api/files', filesRoute); // AI route. @@ -111,28 +97,26 @@ app.use('/api/ai/*', aiRateLimitMiddleware); app.route('/api/ai', aiRoute); // API servers. -app.use(`/api/v1/*`, cors()); app.route('/api/v1', tsRestHonoApp); app.use('/api/jobs/*', jobsClient.getApiHandler()); +app.use('/api/trpc/*', trpcRateLimitMiddleware); app.use('/api/trpc/*', reactRouterTrpcServer); // Unstable API server routes. Order matters for these two. -app.get(`${API_V2_URL}/openapi.json`, (c) => c.json(openApiDocument)); -app.use(`${API_V2_URL}/*`, cors()); +app.get(`/api/v2/openapi.json`, (c) => c.json(openApiDocument)); // Shadows the download routes that tRPC defines since tRPC-to-openapi doesn't support their return types. -app.route(`${API_V2_URL}`, downloadRoute); -app.use(`${API_V2_URL}/*`, async (c) => +app.route(`/api/v2`, downloadRoute); +app.use(`/api/v2/*`, async (c) => openApiTrpcServerHandler(c, { isBeta: false, }), ); // Unstable API server routes. Order matters for these two. -app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument)); -app.use(`${API_V2_BETA_URL}/*`, cors()); +app.get(`/api/v2-beta/openapi.json`, (c) => c.json(openApiDocument)); // Shadows the download routes that tRPC defines since tRPC-to-openapi doesn't support their return types. -app.route(`${API_V2_BETA_URL}`, downloadRoute); -app.use(`${API_V2_BETA_URL}/*`, async (c) => +app.route(`/api/v2-beta`, downloadRoute); +app.use(`/api/v2-beta/*`, async (c) => openApiTrpcServerHandler(c, { isBeta: true, }), diff --git a/package-lock.json b/package-lock.json index 389f17010..e9e90652f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -143,7 +143,6 @@ "content-disposition": "^1.0.1", "framer-motion": "^12.23.24", "hono": "4.11.4", - "hono-rate-limiter": "^0.4.2", "hono-react-router-adapter": "^0.6.5", "input-otp": "^1.4.2", "isbot": "^5.1.32", @@ -25094,15 +25093,6 @@ "node": ">=16.9.0" } }, - "node_modules/hono-rate-limiter": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/hono-rate-limiter/-/hono-rate-limiter-0.4.2.tgz", - "integrity": "sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw==", - "license": "MIT", - "peerDependencies": { - "hono": "^4.1.1" - } - }, "node_modules/hono-react-router-adapter": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/hono-react-router-adapter/-/hono-react-router-adapter-0.6.5.tgz", diff --git a/packages/auth/server/routes/email-password.ts b/packages/auth/server/routes/email-password.ts index 7d7a9b2a9..da8c6e9d2 100644 --- a/packages/auth/server/routes/email-password.ts +++ b/packages/auth/server/routes/email-password.ts @@ -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() 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() * 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() 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() * 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() * 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() * 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() * 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() return c.text('FORBIDDEN', 403); } - const requestMetadata = c.get('requestMetadata'); - const { userId } = await resetPassword({ token, password, diff --git a/packages/auth/server/routes/passkey.ts b/packages/auth/server/routes/passkey.ts index b9f057be5..41b842a4a 100644 --- a/packages/auth/server/routes/passkey.ts +++ b/packages/auth/server/routes/passkey.ts @@ -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() .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) { diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index 2ee4579f2..fc5681aee 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -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; }; export class AppError extends Error { @@ -82,6 +87,8 @@ export class AppError extends Error { */ statusCode?: number; + headers?: Record; + 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; } /** diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index be699d6cd..0471e2018 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -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; diff --git a/packages/lib/jobs/definitions/internal/cleanup-rate-limits.handler.ts b/packages/lib/jobs/definitions/internal/cleanup-rate-limits.handler.ts new file mode 100644 index 000000000..b0eb186b7 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/cleanup-rate-limits.handler.ts @@ -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'); + } +}; diff --git a/packages/lib/jobs/definitions/internal/cleanup-rate-limits.ts b/packages/lib/jobs/definitions/internal/cleanup-rate-limits.ts new file mode 100644 index 000000000..607f3c181 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/cleanup-rate-limits.ts @@ -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 +>; diff --git a/packages/lib/server-only/rate-limit/rate-limit-middleware.ts b/packages/lib/server-only/rate-limit/rate-limit-middleware.ts new file mode 100644 index 000000000..c83fdd1d3 --- /dev/null +++ b/packages/lib/server-only/rate-limit/rate-limit-middleware.ts @@ -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, + 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, + }, + }); + } +}; diff --git a/packages/lib/server-only/rate-limit/rate-limit.ts b/packages/lib/server-only/rate-limit/rate-limit.ts new file mode 100644 index 000000000..cc59abd22 --- /dev/null +++ b/packages/lib/server-only/rate-limit/rate-limit.ts @@ -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 = { + 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 { + 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, + }; + } + }, + }; +}; diff --git a/packages/lib/server-only/rate-limit/rate-limits.ts b/packages/lib/server-only/rate-limit/rate-limits.ts new file mode 100644 index 000000000..25069a3bf --- /dev/null +++ b/packages/lib/server-only/rate-limit/rate-limits.ts @@ -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', +}); diff --git a/packages/prisma/migrations/20260219141233_add_rate_limit_table/migration.sql b/packages/prisma/migrations/20260219141233_add_rate_limit_table/migration.sql new file mode 100644 index 000000000..cbc154463 --- /dev/null +++ b/packages/prisma/migrations/20260219141233_add_rate_limit_table/migration.sql @@ -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"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 17cbb166c..63a9f6a0e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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]) +} diff --git a/packages/trpc/server/document-router/access-auth-request-2fa-email.ts b/packages/trpc/server/document-router/access-auth-request-2fa-email.ts index 9fcd12f28..986705b98 100644 --- a/packages/trpc/server/document-router/access-auth-request-2fa-email.ts +++ b/packages/trpc/server/document-router/access-auth-request-2fa-email.ts @@ -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 diff --git a/packages/trpc/server/enterprise-router/link-organisation-account.ts b/packages/trpc/server/enterprise-router/link-organisation-account.ts index 84619859f..ef4cdffbd 100644 --- a/packages/trpc/server/enterprise-router/link-organisation-account.ts +++ b/packages/trpc/server/enterprise-router/link-organisation-account.ts @@ -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, diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index a880dfa49..9b53d1809 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -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), diff --git a/turbo.json b/turbo.json index 8bf840e53..2a4f650e1 100644 --- a/turbo.json +++ b/turbo.json @@ -133,6 +133,7 @@ "E2E_TEST_AUTHENTICATE_USERNAME", "E2E_TEST_AUTHENTICATE_USER_EMAIL", "E2E_TEST_AUTHENTICATE_USER_PASSWORD", + "DANGEROUS_BYPASS_RATE_LIMITS", "NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS", "NEXT_PRIVATE_OIDC_PROMPT" ]