From 7f44ca4410850c0f644e02468ca35db4e0d2ede5 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Wed, 26 Mar 2025 13:36:47 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Make=20allowed=20origins=20stric?= =?UTF-8?q?ter=20and=20prevent=20the=20bot=20being=20consumable=20from=20p?= =?UTF-8?q?ublic=20URL=20as=20mentioned=20in=20the=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1812 --- apps/docs/openapi/viewer.json | 186 +++++++++++++++++- .../src/features/chat/api/continueChat.ts | 12 +- .../features/chat/api/legacy/sendMessageV1.ts | 35 +--- .../features/chat/api/legacy/sendMessageV2.ts | 36 ++-- .../viewer/src/features/chat/api/startChat.ts | 11 +- apps/viewer/src/helpers/server/context.ts | 9 +- .../src/apiHandlers/continueChat.ts | 20 +- .../bot-engine/src/apiHandlers/startChat.ts | 18 +- .../bot-engine/src/helpers/isOriginAllowed.ts | 29 +++ packages/bot-engine/src/schemas/api.ts | 5 +- .../embeds/js/src/queries/startChatQuery.ts | 12 -- 11 files changed, 267 insertions(+), 106 deletions(-) create mode 100644 packages/bot-engine/src/helpers/isOriginAllowed.ts diff --git a/apps/docs/openapi/viewer.json b/apps/docs/openapi/viewer.json index 9344ddf47..4eceef69a 100644 --- a/apps/docs/openapi/viewer.json +++ b/apps/docs/openapi/viewer.json @@ -1790,7 +1790,98 @@ "$ref": "#/components/schemas/theme" }, "settings": { - "$ref": "#/components/schemas/settings" + "type": "object", + "properties": { + "general": { + "type": "object", + "properties": { + "isBrandingEnabled": { + "type": "boolean" + }, + "isTypingEmulationEnabled": { + "type": "boolean" + }, + "isInputPrefillEnabled": { + "type": "boolean" + }, + "isHideQueryParamsEnabled": { + "type": "boolean" + }, + "isNewResultOnRefreshEnabled": { + "type": "boolean" + }, + "rememberUser": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "storage": { + "type": "string", + "enum": [ + "session", + "local" + ] + } + } + }, + "systemMessages": { + "type": "object", + "properties": { + "invalidMessage": { + "type": "string" + }, + "botClosed": { + "type": "string" + }, + "networkErrorTitle": { + "type": "string" + }, + "networkErrorMessage": { + "type": "string" + }, + "popupBlockedDescription": { + "type": "string" + }, + "popupBlockedButtonLabel": { + "type": "string" + }, + "fileUploadError": { + "type": "string" + }, + "fileUploadSizeError": { + "type": "string" + }, + "whatsAppPictureChoiceSelectLabel": { + "type": "string" + } + } + } + } + }, + "typingEmulation": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "speed": { + "type": "number" + }, + "maxDelay": { + "type": "number" + }, + "delayBetweenBubbles": { + "type": "number", + "minimum": 0, + "maximum": 5 + }, + "isDisabledOnFirstMessage": { + "type": "boolean" + } + } + } + } }, "publishedAt": { "type": "string" @@ -2958,7 +3049,98 @@ "$ref": "#/components/schemas/theme" }, "settings": { - "$ref": "#/components/schemas/settings" + "type": "object", + "properties": { + "general": { + "type": "object", + "properties": { + "isBrandingEnabled": { + "type": "boolean" + }, + "isTypingEmulationEnabled": { + "type": "boolean" + }, + "isInputPrefillEnabled": { + "type": "boolean" + }, + "isHideQueryParamsEnabled": { + "type": "boolean" + }, + "isNewResultOnRefreshEnabled": { + "type": "boolean" + }, + "rememberUser": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "storage": { + "type": "string", + "enum": [ + "session", + "local" + ] + } + } + }, + "systemMessages": { + "type": "object", + "properties": { + "invalidMessage": { + "type": "string" + }, + "botClosed": { + "type": "string" + }, + "networkErrorTitle": { + "type": "string" + }, + "networkErrorMessage": { + "type": "string" + }, + "popupBlockedDescription": { + "type": "string" + }, + "popupBlockedButtonLabel": { + "type": "string" + }, + "fileUploadError": { + "type": "string" + }, + "fileUploadSizeError": { + "type": "string" + }, + "whatsAppPictureChoiceSelectLabel": { + "type": "string" + } + } + } + } + }, + "typingEmulation": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "speed": { + "type": "number" + }, + "maxDelay": { + "type": "number" + }, + "delayBetweenBubbles": { + "type": "number", + "minimum": 0, + "maximum": 5 + }, + "isDisabledOnFirstMessage": { + "type": "boolean" + } + } + } + } }, "publishedAt": { "type": "string" diff --git a/apps/viewer/src/features/chat/api/continueChat.ts b/apps/viewer/src/features/chat/api/continueChat.ts index 1448d9273..a29ac7396 100644 --- a/apps/viewer/src/features/chat/api/continueChat.ts +++ b/apps/viewer/src/features/chat/api/continueChat.ts @@ -31,15 +31,13 @@ export const continueChat = publicProcedure .mutation( async ({ input: { sessionId, message, textBubbleContentFormat }, - ctx: { origin, res }, - }) => { - const { corsOrigin, ...response } = await continueChatFn({ + ctx: { origin, iframeReferrerOrigin }, + }) => + continueChatFn({ origin, + iframeReferrerOrigin, sessionId, message, textBubbleContentFormat, - }); - if (corsOrigin) res.setHeader("Access-Control-Allow-Origin", corsOrigin); - return response; - }, + }), ); diff --git a/apps/viewer/src/features/chat/api/legacy/sendMessageV1.ts b/apps/viewer/src/features/chat/api/legacy/sendMessageV1.ts index 0dfd7f526..5a44d32ee 100644 --- a/apps/viewer/src/features/chat/api/legacy/sendMessageV1.ts +++ b/apps/viewer/src/features/chat/api/legacy/sendMessageV1.ts @@ -2,6 +2,7 @@ import { publicProcedure } from "@/helpers/server/trpc"; import { TRPCError } from "@trpc/server"; import { BubbleBlockType } from "@typebot.io/blocks-bubbles/constants"; import { continueBotFlow } from "@typebot.io/bot-engine/continueBotFlow"; +import { assertOriginIsAllowed } from "@typebot.io/bot-engine/helpers/isOriginAllowed"; import { parseDynamicTheme } from "@typebot.io/bot-engine/parseDynamicTheme"; import { saveStateToDatabase } from "@typebot.io/bot-engine/saveStateToDatabase"; import { @@ -35,7 +36,7 @@ export const sendMessageV1 = publicProcedure .mutation( async ({ input: { sessionId, message, startParams, clientLogs }, - ctx: { user, origin, res }, + ctx: { user, origin, iframeReferrerOrigin }, }) => { const session = sessionId ? await getSession(sessionId) : null; const newSessionId = sessionId ?? createId(); @@ -120,18 +121,10 @@ export const sendMessageV1 = publicProcedure }); if (startParams.isPreview || typeof startParams.typebot !== "string") { - if ( - newSessionState.allowedOrigins && - newSessionState.allowedOrigins.length > 0 - ) { - if (origin && newSessionState.allowedOrigins.includes(origin)) - res.setHeader("Access-Control-Allow-Origin", origin); - else - res.setHeader( - "Access-Control-Allow-Origin", - newSessionState.allowedOrigins[0], - ); - } + assertOriginIsAllowed(origin, { + allowedOrigins: newSessionState.allowedOrigins, + iframeReferrerOrigin, + }); } const allLogs = clientLogs ? [...(logs ?? []), ...clientLogs] : logs; @@ -180,18 +173,10 @@ export const sendMessageV1 = publicProcedure clientSideActions, }; } else { - if ( - session.state.allowedOrigins && - session.state.allowedOrigins.length > 0 - ) { - if (origin && session.state.allowedOrigins.includes(origin)) - res.setHeader("Access-Control-Allow-Origin", origin); - else - res.setHeader( - "Access-Control-Allow-Origin", - session.state.allowedOrigins[0], - ); - } + assertOriginIsAllowed(origin, { + allowedOrigins: session.state.allowedOrigins, + iframeReferrerOrigin, + }); const { messages, diff --git a/apps/viewer/src/features/chat/api/legacy/sendMessageV2.ts b/apps/viewer/src/features/chat/api/legacy/sendMessageV2.ts index 3aec1ab2e..6edb6598d 100644 --- a/apps/viewer/src/features/chat/api/legacy/sendMessageV2.ts +++ b/apps/viewer/src/features/chat/api/legacy/sendMessageV2.ts @@ -2,6 +2,7 @@ import { publicProcedure } from "@/helpers/server/trpc"; import { TRPCError } from "@trpc/server"; import { BubbleBlockType } from "@typebot.io/blocks-bubbles/constants"; import { continueBotFlow } from "@typebot.io/bot-engine/continueBotFlow"; +import { assertOriginIsAllowed } from "@typebot.io/bot-engine/helpers/isOriginAllowed"; import { parseDynamicTheme } from "@typebot.io/bot-engine/parseDynamicTheme"; import { saveStateToDatabase } from "@typebot.io/bot-engine/saveStateToDatabase"; import { @@ -35,7 +36,7 @@ export const sendMessageV2 = publicProcedure .mutation( async ({ input: { sessionId, message, startParams, clientLogs }, - ctx: { user, res, origin }, + ctx: { user, origin, iframeReferrerOrigin }, }) => { const session = sessionId ? await getSession(sessionId) : null; const newSessionId = sessionId ?? createId(); @@ -120,18 +121,10 @@ export const sendMessageV2 = publicProcedure }); if (startParams.isPreview || typeof startParams.typebot !== "string") { - if ( - newSessionState.allowedOrigins && - newSessionState.allowedOrigins.length > 0 - ) { - if (origin && newSessionState.allowedOrigins.includes(origin)) - res.setHeader("Access-Control-Allow-Origin", origin); - else - res.setHeader( - "Access-Control-Allow-Origin", - newSessionState.allowedOrigins[0], - ); - } + assertOriginIsAllowed(origin, { + allowedOrigins: newSessionState.allowedOrigins, + iframeReferrerOrigin, + }); } const allLogs = clientLogs ? [...(logs ?? []), ...clientLogs] : logs; @@ -179,18 +172,11 @@ export const sendMessageV2 = publicProcedure clientSideActions, }; } else { - if ( - session.state.allowedOrigins && - session.state.allowedOrigins.length > 0 - ) { - if (origin && session.state.allowedOrigins.includes(origin)) - res.setHeader("Access-Control-Allow-Origin", origin); - else - res.setHeader( - "Access-Control-Allow-Origin", - session.state.allowedOrigins[0], - ); - } + assertOriginIsAllowed(origin, { + allowedOrigins: session.state.allowedOrigins, + iframeReferrerOrigin, + }); + const { messages, input, diff --git a/apps/viewer/src/features/chat/api/startChat.ts b/apps/viewer/src/features/chat/api/startChat.ts index fc20d67e6..e6cf36f6c 100644 --- a/apps/viewer/src/features/chat/api/startChat.ts +++ b/apps/viewer/src/features/chat/api/startChat.ts @@ -15,11 +15,10 @@ export const startChat = publicProcedure }) .input(startChatInputSchema) .output(startChatResponseSchema) - .mutation(async ({ input, ctx: { origin, res } }) => { - const { corsOrigin, ...response } = await startChatFn({ + .mutation(async ({ input, ctx: { origin, iframeReferrerOrigin } }) => + startChatFn({ ...input, origin, - }); - if (corsOrigin) res.setHeader("Access-Control-Allow-Origin", corsOrigin); - return response; - }); + iframeReferrerOrigin, + }), + ); diff --git a/apps/viewer/src/helpers/server/context.ts b/apps/viewer/src/helpers/server/context.ts index 1b0505c50..ad8acf0ba 100644 --- a/apps/viewer/src/helpers/server/context.ts +++ b/apps/viewer/src/helpers/server/context.ts @@ -11,11 +11,10 @@ export async function createContext(opts: trpcNext.CreateNextContextOptions) { return { user, - origin: - (opts.req.headers["x-typebot-iframe-referrer-origin"] as - | string - | undefined) ?? opts.req.headers.origin, - res: opts.res, + origin: opts.req.headers.origin, + iframeReferrerOrigin: opts.req.headers[ + "x-typebot-iframe-referrer-origin" + ] as string | undefined, }; } diff --git a/packages/bot-engine/src/apiHandlers/continueChat.ts b/packages/bot-engine/src/apiHandlers/continueChat.ts index 0132dabfc..f87f516c0 100644 --- a/packages/bot-engine/src/apiHandlers/continueChat.ts +++ b/packages/bot-engine/src/apiHandlers/continueChat.ts @@ -8,6 +8,7 @@ import { } from "@typebot.io/runtime-session-store"; import { computeCurrentProgress } from "../computeCurrentProgress"; import { continueBotFlow } from "../continueBotFlow"; +import { assertOriginIsAllowed } from "../helpers/isOriginAllowed"; import { filterPotentiallySensitiveLogs } from "../logs/filterPotentiallySensitiveLogs"; import { parseDynamicTheme } from "../parseDynamicTheme"; import { saveStateToDatabase } from "../saveStateToDatabase"; @@ -15,12 +16,14 @@ import type { Message } from "../schemas/api"; type Props = { origin: string | undefined; + iframeReferrerOrigin: string | undefined; message?: Message; sessionId: string; textBubbleContentFormat: "richText" | "markdown"; }; export const continueChat = async ({ origin, + iframeReferrerOrigin, sessionId, message, textBubbleContentFormat, @@ -34,6 +37,11 @@ export const continueChat = async ({ }); } + assertOriginIsAllowed(origin, { + allowedOrigins: session.state.allowedOrigins, + iframeReferrerOrigin, + }); + const isSessionExpired = session && isDefined(session.state.expiryTimeout) && @@ -45,17 +53,6 @@ export const continueChat = async ({ message: "Session expired. You need to start a new session.", }); - let corsOrigin; - - if ( - session?.state.allowedOrigins && - session.state.allowedOrigins.length > 0 - ) { - if (origin && session.state.allowedOrigins.includes(origin)) - corsOrigin = origin; - else corsOrigin = session.state.allowedOrigins[0]; - } - const sessionStore = getSessionStore(sessionId); const { messages, @@ -116,7 +113,6 @@ export const continueChat = async ({ dynamicTheme, logs: isPreview ? logs : logs?.filter(filterPotentiallySensitiveLogs), lastMessageNewFormat, - corsOrigin, progress: newSessionState.progressMetadata ? isEnded ? 100 diff --git a/packages/bot-engine/src/apiHandlers/startChat.ts b/packages/bot-engine/src/apiHandlers/startChat.ts index eef258bf4..9c76a2960 100644 --- a/packages/bot-engine/src/apiHandlers/startChat.ts +++ b/packages/bot-engine/src/apiHandlers/startChat.ts @@ -6,6 +6,7 @@ import { getSessionStore, } from "@typebot.io/runtime-session-store"; import { computeCurrentProgress } from "../computeCurrentProgress"; +import { assertOriginIsAllowed } from "../helpers/isOriginAllowed"; import { filterPotentiallySensitiveLogs } from "../logs/filterPotentiallySensitiveLogs"; import { saveStateToDatabase } from "../saveStateToDatabase"; import type { Message } from "../schemas/api"; @@ -13,6 +14,7 @@ import { startSession } from "../startSession"; type Props = { origin: string | undefined; + iframeReferrerOrigin: string | undefined; message?: Message; isOnlyRegistering: boolean; publicId: string; @@ -24,6 +26,7 @@ type Props = { export const startChat = async ({ origin, + iframeReferrerOrigin, message, isOnlyRegistering, publicId, @@ -61,16 +64,10 @@ export const startChat = async ({ }); deleteSessionStore(sessionId); - let corsOrigin; - - if ( - newSessionState.allowedOrigins && - newSessionState.allowedOrigins.length > 0 - ) { - if (origin && newSessionState.allowedOrigins.includes(origin)) - corsOrigin = origin; - else corsOrigin = newSessionState.allowedOrigins[0]; - } + assertOriginIsAllowed(origin, { + allowedOrigins: newSessionState.allowedOrigins, + iframeReferrerOrigin, + }); const session = isOnlyRegistering ? await restartSession({ @@ -118,7 +115,6 @@ export const startChat = async ({ dynamicTheme, logs: logs?.filter(filterPotentiallySensitiveLogs), clientSideActions, - corsOrigin, progress: newSessionState.progressMetadata ? isEnded ? 100 diff --git a/packages/bot-engine/src/helpers/isOriginAllowed.ts b/packages/bot-engine/src/helpers/isOriginAllowed.ts new file mode 100644 index 000000000..b18f2c1d0 --- /dev/null +++ b/packages/bot-engine/src/helpers/isOriginAllowed.ts @@ -0,0 +1,29 @@ +import { TRPCError } from "@trpc/server"; +import { env } from "@typebot.io/env"; + +const trustedOrigins = env.NEXT_PUBLIC_VIEWER_URL; + +export const assertOriginIsAllowed = ( + origin: string | undefined, + { + allowedOrigins, + iframeReferrerOrigin, + }: { + allowedOrigins: string[] | undefined; + iframeReferrerOrigin: string | undefined; + }, +) => { + if ( + !origin || + !allowedOrigins || + allowedOrigins.includes(origin) || + (iframeReferrerOrigin && + trustedOrigins.includes(origin) && + allowedOrigins.includes(iframeReferrerOrigin)) + ) + return; + throw new TRPCError({ + code: "FORBIDDEN", + message: "Origin not allowed", + }); +}; diff --git a/packages/bot-engine/src/schemas/api.ts b/packages/bot-engine/src/schemas/api.ts index be0507a04..1b6472113 100644 --- a/packages/bot-engine/src/schemas/api.ts +++ b/packages/bot-engine/src/schemas/api.ts @@ -416,7 +416,10 @@ export const startChatResponseSchema = z typebotV6Schema.shape.version, ]), theme: themeSchema, - settings: settingsSchema, + settings: settingsSchema.pick({ + general: true, + typingEmulation: true, + }), publishedAt: z.coerce.date().optional(), }), }) diff --git a/packages/embeds/js/src/queries/startChatQuery.ts b/packages/embeds/js/src/queries/startChatQuery.ts index fa859e99d..9838c95ed 100644 --- a/packages/embeds/js/src/queries/startChatQuery.ts +++ b/packages/embeds/js/src/queries/startChatQuery.ts @@ -3,7 +3,6 @@ import { removePaymentInProgressFromStorage, } from "@/features/blocks/inputs/payment/helpers/paymentInProgressStorage"; import type { BotContext } from "@/types"; -import { CorsError } from "@/utils/CorsError"; import { guessApiHost } from "@/utils/guessApiHost"; import type { ContinueChatResponse, @@ -16,7 +15,6 @@ import { isNotDefined, isNotEmpty } from "@typebot.io/lib/utils"; import ky from "ky"; type Props = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any typebot: string | any; stripeRedirectStatus?: string; apiHost?: string; @@ -91,16 +89,6 @@ export async function startChatQuery({ }, ); - const corsAllowOrigin = response.headers.get("access-control-allow-origin"); - - if ( - iframeReferrerOrigin && - corsAllowOrigin && - corsAllowOrigin !== "*" && - !iframeReferrerOrigin.includes(corsAllowOrigin) - ) - throw new CorsError(corsAllowOrigin); - return { data: await response.json() }; } catch (error) { return { error };