-
-
Notifications
You must be signed in to change notification settings - Fork 3.1k
fix: prevent abuse of preview chat endpoints via rate limiting #2463
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,11 +6,14 @@ import { | |||||||||||||||||||||||||||||
| } from "@typebot.io/chat-api/schemas"; | ||||||||||||||||||||||||||||||
| import { restartSession } from "@typebot.io/chat-session/queries/restartSession"; | ||||||||||||||||||||||||||||||
| import { createId } from "@typebot.io/lib/createId"; | ||||||||||||||||||||||||||||||
| import { getIp } from "@typebot.io/lib/getIp"; | ||||||||||||||||||||||||||||||
| import { withSessionStore } from "@typebot.io/runtime-session-store"; | ||||||||||||||||||||||||||||||
| import { ORPCError } from "@orpc/server"; | ||||||||||||||||||||||||||||||
| import { z } from "zod"; | ||||||||||||||||||||||||||||||
| import { computeCurrentProgress } from "../computeCurrentProgress"; | ||||||||||||||||||||||||||||||
| import { saveStateToDatabase } from "../saveStateToDatabase"; | ||||||||||||||||||||||||||||||
| import { startSession } from "../startSession"; | ||||||||||||||||||||||||||||||
| import { startPreviewRateLimiter } from "../lib/rateLimiter"; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export const startPreviewChatInputSchema = z.object({ | ||||||||||||||||||||||||||||||
| typebotId: z | ||||||||||||||||||||||||||||||
|
|
@@ -60,6 +63,7 @@ export const startPreviewChatInputSchema = z.object({ | |||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| type Context = { | ||||||||||||||||||||||||||||||
| user: { id: string } | null; | ||||||||||||||||||||||||||||||
| req?: any; | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export const handleStartChatPreview = async ({ | ||||||||||||||||||||||||||||||
|
|
@@ -74,11 +78,22 @@ export const handleStartChatPreview = async ({ | |||||||||||||||||||||||||||||
| sessionId: sessionIdProp, | ||||||||||||||||||||||||||||||
| textBubbleContentFormat, | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| context: { user }, | ||||||||||||||||||||||||||||||
| context: { user, req }, | ||||||||||||||||||||||||||||||
| }: { | ||||||||||||||||||||||||||||||
| input: z.infer<typeof startPreviewChatInputSchema>; | ||||||||||||||||||||||||||||||
| context: Context; | ||||||||||||||||||||||||||||||
| }) => { | ||||||||||||||||||||||||||||||
| if (startPreviewRateLimiter) { | ||||||||||||||||||||||||||||||
| const ip = req ? getIp(req) : undefined; | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| 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
AI
Apr 16, 2026
There was a problem hiding this comment.
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
AI
Apr 16, 2026
There was a problem hiding this comment.
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
AI
Apr 16, 2026
There was a problem hiding this comment.
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.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,54 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { env } from "@typebot.io/env"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Ratelimit } from "@upstash/ratelimit"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import Redis from "ioredis"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type Unit = "ms" | "s" | "m" | "h" | "d"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type Duration = `${number} ${Unit}` | `${number}${Unit}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type RateLimitConfig = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| requests: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| window: Duration; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| prefix?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const createRateLimiter = (config: RateLimitConfig) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!env.REDIS_URL) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const redis = new Redis(env.REDIS_URL); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const rateLimitCompatibleRedis = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sadd: <TData>(key: string, ...members: TData[]) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| redis.sadd(key, ...members.map((m) => String(m))), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| eval: async <TArgs extends unknown[], TData = unknown>( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| script: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| keys: string[], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| args: TArgs, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| redis.eval( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| script, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| keys.length, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ...keys, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ...(args ?? []).map((a) => String(a)), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) as Promise<TData>, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return new Ratelimit({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| redis: rateLimitCompatibleRedis as any, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| limiter: Ratelimit.slidingWindow(config.requests, config.window), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| prefix: config.prefix, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
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; | |
| 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
reqis typed asanyin the handler context, which hides type issues (and contributed to the incorrectgetIp(req)usage). SincecreateContextnow passes a FetchRequest, type this asreq?: Request(or a minimal{ headers: Headers }shape) so header access and IP extraction stay type-safe.