Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/builder/src/app/api/[[...rest]]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ async function handleRequest(
const { response } = await handler.handle(resolvedRequest, {
prefix: "/api",
context: createContext({
req: request,
authenticate: async () => {
const user =
(await auth())?.user ||
Expand Down
1 change: 1 addition & 0 deletions apps/builder/src/app/api/orpc/[[...rest]]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ async function handleRequest(request: Request) {
const { response } = await handler.handle(request, {
prefix: "/api/orpc",
context: createContext({
req: request,
authenticate: async () => {
const session = await auth();
if (!session?.user) return null;
Expand Down
128 changes: 65 additions & 63 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"@types/node": "^24.10.13",
"esbuild": "^0.27.4",
"husky": "^9.1.7",
"ioredis": "^5.4.1",
"jiti": "2.6.1",
"nx": "22.5.4",
"rimraf": "^6.1.3",
Expand Down
1 change: 1 addition & 0 deletions packages/bot-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@typebot.io/theme": "workspace:*",
"@typebot.io/typebot": "workspace:*",
"@typebot.io/variables": "workspace:*",
"@upstash/ratelimit": "^0.4.3",
"chrono-node": "2.7.6",
"date-fns": "^2.30.0",
"date-fns-tz": "^2.0.0",
Expand Down
17 changes: 16 additions & 1 deletion packages/bot-engine/src/api/handleStartChatPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,6 +63,7 @@ export const startPreviewChatInputSchema = z.object({

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.
};

export const handleStartChatPreview = async ({
Expand All @@ -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;
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.
const id = user?.id ?? ip ?? "unknown";
const { success } = await startPreviewRateLimiter.limit(id);
Comment on lines +88 to +89
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.
if (!success) {
throw new ORPCError("TOO_MANY_REQUESTS", {
message: "Rate limit exceeded. Please try again later.",
});
}
}
Comment on lines +86 to +95
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.
Comment on lines +86 to +95
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.

const sessionId = sessionIdProp ?? createId();
return withSessionStore(sessionId, async (sessionStore) => {
const {
Expand Down
54 changes: 54 additions & 0 deletions packages/bot-engine/src/lib/rateLimiter.ts
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
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.
3 changes: 3 additions & 0 deletions packages/config/src/orpc/builder/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ export type UserInOrpcContext = {

export function createContext({
authenticate,
req,
}: {
authenticate: () => Promise<UserInOrpcContext | null>;
req?: Request;
}) {
return {
authenticate,
req,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/config/src/orpc/viewer/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function createContext({
iframeReferrerOrigin:
req.headers.get("x-typebot-iframe-referrer-origin") ?? undefined,
authenticate,
req,
};
}

Expand Down
Loading