Skip to content

fix: prevent abuse of preview chat endpoints via rate limiting#2463

Open
utsav1213 wants to merge 1 commit intobaptisteArno:mainfrom
utsav1213:fix/preview-rate-limit
Open

fix: prevent abuse of preview chat endpoints via rate limiting#2463
utsav1213 wants to merge 1 commit intobaptisteArno:mainfrom
utsav1213:fix/preview-rate-limit

Conversation

@utsav1213
Copy link
Copy Markdown

@utsav1213 utsav1213 commented Apr 16, 2026

Fixes #2423

🐛 Problem

Preview chat endpoints (/preview/startChat and /sessions/continueChat) bypass usage tracking and do not increment totalChatsUsed.

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.

  • Applied rate limiting to preview session creation (startChatPreview)
  • Uses @upstash/ratelimit with a limit of 20 requests per minute
  • Rate limiting is applied per authenticated user ID, or per IP address as a fallback
  • Throws a TOO_MANY_REQUESTS error when the limit is exceeded
  • Designed to prevent automated abuse while preserving normal developer experience

⚠️ Note

This does not affect normal builder usage, as limits are high enough for manual testing but prevent high-frequency automated abuse.


🧪 Testing

  • Run local environment (docker compose up -d + bun run dev)
  • Open preview repeatedly or hit /preview/startChat endpoint directly
  • After ~20 requests/minute, API returns TOO_MANY_REQUESTS

📌 Future Improvements

  • Extend rate limiting to /sessions/continueChat to fully prevent bypass via existing preview sessions
  • Make limits configurable per environment

Copilot AI review requested due to automatic review settings April 16, 2026 09:57
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 16, 2026

@utsav1213 is attempting to deploy a commit to the Typebot Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Request through ORPC contexts so handlers can access request metadata (e.g., headers/IP).
  • Introduces a Redis-backed Upstash Ratelimit helper and applies it to startChat preview handling.
  • Adds dependencies/lockfile updates for @upstash/ratelimit and ioredis.

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;
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +89
const id = user?.id ?? ip ?? "unknown";
const { success } = await startPreviewRateLimiter.limit(id);
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +86 to +95
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.",
});
}
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

type Context = {
user: { id: string } | null;
req?: any;
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
req?: any;
req?: Request;

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +54
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;
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
Comment on lines +86 to +95
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.",
});
}
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Using all of TypeBot's features in the preview.

2 participants