fix: prevent abuse of preview chat endpoints via rate limiting#2463
fix: prevent abuse of preview chat endpoints via rate limiting#2463utsav1213 wants to merge 1 commit intobaptisteArno:mainfrom
Conversation
|
@utsav1213 is attempting to deploy a commit to the Typebot Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Pull request overview
Adds server-side rate limiting intended to prevent abuse of preview chat endpoints, addressing a gap where preview sessions could be used without impacting plan usage/limits.
Changes:
- Passes the incoming
Requestthrough ORPC contexts so handlers can access request metadata (e.g., headers/IP). - Introduces a Redis-backed Upstash
Ratelimithelper and applies it tostartChatpreview handling. - Adds dependencies/lockfile updates for
@upstash/ratelimitandioredis.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/config/src/orpc/viewer/context.ts | Exposes req on viewer ORPC context. |
| packages/config/src/orpc/builder/context.ts | Adds optional req to builder ORPC context. |
| packages/bot-engine/src/lib/rateLimiter.ts | New Redis-backed rate limiter singleton for preview start. |
| packages/bot-engine/src/api/handleStartChatPreview.ts | Enforces rate limiting before starting preview sessions. |
| apps/builder/src/app/api/orpc/[[...rest]]/route.ts | Passes request into builder ORPC context. |
| apps/builder/src/app/api/[[...rest]]/route.ts | Passes request into builder API context. |
| packages/bot-engine/package.json | Adds @upstash/ratelimit dependency. |
| package.json | Adds ioredis (root). |
| bun.lock | Lockfile updates for new/updated dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| context: Context; | ||
| }) => { | ||
| if (startPreviewRateLimiter) { | ||
| const ip = req ? getIp(req) : undefined; |
There was a problem hiding this comment.
getIp expects an object with the x-forwarded-for / cf-connecting-ip header values, but this code passes the Request object directly. As a result ip will always be null/undefined and the limiter key will fall back to user?.id or "unknown", which defeats the intended per-IP limiting. Build the header object from req.headers (like other call sites do) and then pass that into getIp.
| const ip = req ? getIp(req) : undefined; | |
| const headers = req?.headers | |
| ? { | |
| "x-forwarded-for": | |
| typeof req.headers.get === "function" | |
| ? (req.headers.get("x-forwarded-for") ?? undefined) | |
| : req.headers["x-forwarded-for"], | |
| "cf-connecting-ip": | |
| typeof req.headers.get === "function" | |
| ? (req.headers.get("cf-connecting-ip") ?? undefined) | |
| : req.headers["cf-connecting-ip"], | |
| } | |
| : undefined; | |
| const ip = headers ? getIp(headers) : undefined; |
| const id = user?.id ?? ip ?? "unknown"; | ||
| const { success } = await startPreviewRateLimiter.limit(id); |
There was a problem hiding this comment.
The new rate limit key uses user?.id before ip, but the PR description says preview requests are limited per IP. If per-IP behavior is intended, prefer using IP as the primary identifier (and only fall back to user ID when IP is unavailable), or combine both into a stable key.
| if (startPreviewRateLimiter) { | ||
| const ip = req ? getIp(req) : undefined; | ||
| const id = user?.id ?? ip ?? "unknown"; | ||
| const { success } = await startPreviewRateLimiter.limit(id); | ||
| if (!success) { | ||
| throw new ORPCError("TOO_MANY_REQUESTS", { | ||
| message: "Rate limit exceeded. Please try again later.", | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
The PR description mentions rate limiting both /preview/startChat and /sessions/continueChat, but the limiter is only enforced here in startChatPreview. continueChat can still be used indefinitely once a preview session is created, which leaves a large portion of the abuse path unprotected. Consider applying a similar limiter in the /v1/sessions/{sessionId}/continueChat handler for preview sessions (e.g., detect preview sessions and rate limit by IP).
|
|
||
| type Context = { | ||
| user: { id: string } | null; | ||
| req?: any; |
There was a problem hiding this comment.
req is typed as any in the handler context, which hides type issues (and contributed to the incorrect getIp(req) usage). Since createContext now passes a Fetch Request, type this as req?: Request (or a minimal { headers: Headers } shape) so header access and IP extraction stay type-safe.
| req?: any; | |
| req?: Request; |
| declare const window: { startPreviewRateLimiter: Ratelimit | undefined }; | ||
|
|
||
| const windowAny = globalThis as any; | ||
|
|
||
| if (!windowAny.startPreviewRateLimiter && env.REDIS_URL) { | ||
| windowAny.startPreviewRateLimiter = createRateLimiter({ | ||
| requests: 20, | ||
| window: "1 m", | ||
| prefix: "start-preview-ratelimit", | ||
| }); | ||
| } | ||
|
|
||
| export const startPreviewRateLimiter = windowAny.startPreviewRateLimiter as Ratelimit | undefined; |
There was a problem hiding this comment.
The global caching pattern here is inconsistent with other rate limiter modules in the repo (they use declare const global: { ... } and assign on global). Declaring window in a server-side module is also confusing and may conflict with DOM typings in some TS configs. Consider switching to the existing global pattern and dropping the window declaration entirely.
| declare const window: { startPreviewRateLimiter: Ratelimit | undefined }; | |
| const windowAny = globalThis as any; | |
| if (!windowAny.startPreviewRateLimiter && env.REDIS_URL) { | |
| windowAny.startPreviewRateLimiter = createRateLimiter({ | |
| requests: 20, | |
| window: "1 m", | |
| prefix: "start-preview-ratelimit", | |
| }); | |
| } | |
| export const startPreviewRateLimiter = windowAny.startPreviewRateLimiter as Ratelimit | undefined; | |
| declare const global: { | |
| startPreviewRateLimiter: Ratelimit | undefined; | |
| }; | |
| if (!global.startPreviewRateLimiter && env.REDIS_URL) { | |
| global.startPreviewRateLimiter = createRateLimiter({ | |
| requests: 20, | |
| window: "1 m", | |
| prefix: "start-preview-ratelimit", | |
| }); | |
| } | |
| export const startPreviewRateLimiter = global.startPreviewRateLimiter; |
| if (startPreviewRateLimiter) { | ||
| const ip = req ? getIp(req) : undefined; | ||
| const id = user?.id ?? ip ?? "unknown"; | ||
| const { success } = await startPreviewRateLimiter.limit(id); | ||
| if (!success) { | ||
| throw new ORPCError("TOO_MANY_REQUESTS", { | ||
| message: "Rate limit exceeded. Please try again later.", | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
There are Playwright API tests covering preview startChat/continueChat, but none validating the new rate limiting behavior. Adding a test that the preview endpoint returns a 429/TOO_MANY_REQUESTS after the threshold (with the limiter enabled) would prevent regressions and ensure the mitigation works as intended.
Fixes #2423
🐛 Problem
Preview chat endpoints (
/preview/startChatand/sessions/continueChat) bypass usage tracking and do not incrementtotalChatsUsed.This allows users to abuse preview sessions as production chats without hitting plan limits.
✅ Solution
Introduced a Redis-backed rate limiter to restrict abuse of preview endpoints without affecting legitimate builder usage.
startChatPreview)TOO_MANY_REQUESTSerror when the limit is exceededThis does not affect normal builder usage, as limits are high enough for manual testing but prevent high-frequency automated abuse.
🧪 Testing
docker compose up -d+bun run dev)/preview/startChatendpoint directlyTOO_MANY_REQUESTS📌 Future Improvements
/sessions/continueChatto fully prevent bypass via existing preview sessions