mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +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:
@@ -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<WindowUnit, number> = {
|
||||
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<typeof rateLimit>,
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
+31
-47
@@ -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<HonoEnv>();
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}),
|
||||
|
||||
Generated
-10
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user