Typebot Vulnerable to Credential Theft via Client-Side Script Execution and API Authorization Bypass
Description
Typebot is an open-source chatbot builder. In versions prior to 3.13.2, client-side script execution in Typebot allows stealing all stored credentials from any user. When a victim previews a malicious typebot by clicking "Run", JavaScript executes in their browser and exfiltrates their OpenAI keys, Google Sheets tokens, and SMTP passwords. The /api/trpc/credentials.getCredentials endpoint returns plaintext API keys without verifying credential ownership. Version 3.13.2 fixes the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@typebot.io/jsnpm | < 0.9.15 | 0.9.15 |
Affected products
1- Range: js-lib-v2.1.4, js-lib-v2.2.0, js-lib-v2.2.1, …
Patches
1a68f0c91790a🔒️ Restrict client code execution on imported bot
27 files changed · +503 −159
apps/builder/src/features/blocks/logic/script/components/ScriptSettings.tsx+6 −0 modified@@ -5,6 +5,7 @@ import { MoreInfoTooltip } from "@typebot.io/ui/components/MoreInfoTooltip"; import { Switch } from "@typebot.io/ui/components/Switch"; import { CodeEditor } from "@/components/inputs/CodeEditor"; import { DebouncedTextInput } from "@/components/inputs/DebouncedTextInput"; +import { UnsafeScriptAlert } from "./UnsafeScriptAlert"; type Props = { options: ScriptBlock["options"]; @@ -21,6 +22,8 @@ export const ScriptSettings = ({ options, onOptionsChange }: Props) => { const updateClientExecution = (isExecutedOnClient: boolean) => onOptionsChange({ ...options, isExecutedOnClient }); + const updateIsUnsafe = () => onOptionsChange({ ...options, isUnsafe: false }); + return ( <div className="flex flex-col gap-4"> <Field.Root> @@ -46,6 +49,9 @@ export const ScriptSettings = ({ options, onOptionsChange }: Props) => { </MoreInfoTooltip> </Field.Label> </Field.Root> + {options?.isUnsafe === true && options?.isExecutedOnClient !== false && ( + <UnsafeScriptAlert onTrustClick={updateIsUnsafe} /> + )} <CodeEditor defaultValue={options?.content} lang="javascript"
apps/builder/src/features/blocks/logic/script/components/UnsafeScriptAlert.tsx+24 −0 added@@ -0,0 +1,24 @@ +import { Alert } from "@typebot.io/ui/components/Alert"; +import { Button } from "@typebot.io/ui/components/Button"; +import { TriangleAlertIcon } from "@typebot.io/ui/icons/TriangleAlertIcon"; + +export const UnsafeScriptAlert = ({ + onTrustClick, +}: { + onTrustClick: () => void; +}) => ( + <Alert.Root variant="warning"> + <TriangleAlertIcon /> + <Alert.Description className="flex flex-col gap-2"> + <p> + For security reasons, since this bot was imported from a potential + untrusted source, we have disabled some access on bot preview. Only + enable this option if you understand what the code is doing and you know + what you are doing, otherwise it could be a security risk. + </p> + <Button variant="outline" onClick={onTrustClick}> + I trust this code + </Button> + </Alert.Description> + </Alert.Root> +);
apps/builder/src/features/blocks/logic/setVariable/components/SetVariableSettings.tsx+68 −62 modified@@ -25,6 +25,7 @@ import { DebouncedTextInput } from "@/components/inputs/DebouncedTextInput"; import { VariablesCombobox } from "@/components/inputs/VariablesCombobox"; import { WhatsAppLogo } from "@/components/logos/WhatsAppLogo"; import { useTypebot } from "@/features/editor/providers/TypebotProvider"; +import { UnsafeScriptAlert } from "../../script/components/UnsafeScriptAlert"; type Props = { options: SetVariableBlock["options"]; @@ -217,74 +218,79 @@ const SetVariableValue = ({ }); }; + const updateIsUnsafe = () => onOptionsChange({ ...options, isUnsafe: false }); + switch (options?.type) { case "Custom": case undefined: return ( - <> - <Field.Root className="flex-row items-center"> - <Switch - checked={ - options?.isExecutedOnClient ?? - defaultSetVariableOptions.isExecutedOnClient - } - onCheckedChange={updateClientExecution} - /> - <Field.Label> - Execute on client{" "} - <MoreInfoTooltip> - Check this if you need access to client-only variables like - `window` or `document`. - </MoreInfoTooltip> - </Field.Label> - </Field.Root> - <div className="flex flex-col gap-2"> - <RadioGroup - onValueChange={(value) => updateIsCode(value as "Text" | "Code")} - defaultValue={ - (options?.isCode ?? defaultSetVariableOptions.isCode) - ? "Code" - : "Text" - } - > - <Label className="hover:bg-gray-2/50 rounded-md p-2 border flex-1 flex justify-center"> - <Radio value="Text" className="hidden" /> - Text - </Label> - <Label className="hover:bg-gray-2/50 rounded-md p-2 border flex-1 flex justify-center"> - <Radio value="Code" className="hidden" /> - Code - </Label> - </RadioGroup> - {options?.isCode ? ( - <div className="flex flex-col gap-2"> - <DebouncedTextInput - placeholder="Code description" - defaultValue={options?.expressionDescription} - onValueChange={updateExpressionDescription} - /> - <CodeEditor - defaultValue={options?.expressionToEvaluate ?? ""} - onChange={updateExpression} - lang="javascript" - withLineNumbers={true} - /> - <Field.Root> - <Field.Label>Save error</Field.Label> - <VariablesCombobox - initialVariableId={options.saveErrorInVariableId} - onSelectVariable={updateSaveErrorInVariableId} - /> - </Field.Root> - </div> - ) : ( - <DebouncedTextareaWithVariablesButton + <div className="flex flex-col gap-2"> + <RadioGroup + onValueChange={(value) => updateIsCode(value as "Text" | "Code")} + defaultValue={ + (options?.isCode ?? defaultSetVariableOptions.isCode) + ? "Code" + : "Text" + } + > + <Label className="hover:bg-gray-2/50 rounded-md p-2 border flex-1 flex justify-center"> + <Radio value="Text" className="hidden" /> + Text + </Label> + <Label className="hover:bg-gray-2/50 rounded-md p-2 border flex-1 flex justify-center"> + <Radio value="Code" className="hidden" /> + Code + </Label> + </RadioGroup> + {options?.isCode ? ( + <div className="flex flex-col gap-2"> + <DebouncedTextInput + placeholder="Code description" + defaultValue={options?.expressionDescription} + onValueChange={updateExpressionDescription} + /> + <CodeEditor defaultValue={options?.expressionToEvaluate ?? ""} - onValueChange={updateExpression} + onChange={updateExpression} + lang="javascript" + withLineNumbers={true} /> - )} - </div> - </> + <Field.Root className="flex-row items-center"> + <Switch + checked={ + options?.isExecutedOnClient ?? + defaultSetVariableOptions.isExecutedOnClient + } + onCheckedChange={updateClientExecution} + /> + <Field.Label> + Execute on client{" "} + <MoreInfoTooltip> + Check this if you need access to client-only variables like + `window` or `document`. + </MoreInfoTooltip> + </Field.Label> + </Field.Root> + {options?.isUnsafe === true && + options?.isExecutedOnClient === true && + options.isCode && ( + <UnsafeScriptAlert onTrustClick={updateIsUnsafe} /> + )} + <Field.Root> + <Field.Label>Save error</Field.Label> + <VariablesCombobox + initialVariableId={options.saveErrorInVariableId} + onSelectVariable={updateSaveErrorInVariableId} + /> + </Field.Root> + </div> + ) : ( + <DebouncedTextareaWithVariablesButton + defaultValue={options?.expressionToEvaluate ?? ""} + onValueChange={updateExpression} + /> + )} + </div> ); case "Pop": case "Shift":
apps/builder/src/features/templates/components/CreateNewTypebotButtons.tsx+3 −2 modified@@ -57,7 +57,7 @@ export const CreateNewTypebotButtons = () => { const handleCreateSubmit = async ( typebot?: Typebot, - fromTemplate?: string, + args?: { enableSafetyFlags?: boolean; fromTemplate?: string }, ) => { if (!user || !workspace) return; const folderId = router.query.folderId?.toString() ?? null; @@ -68,7 +68,8 @@ export const CreateNewTypebotButtons = () => { ...typebot, folderId, }, - fromTemplate, + fromTemplate: args?.fromTemplate, + enableSafetyFlags: args?.enableSafetyFlags, }); else createTypebot({
apps/builder/src/features/templates/components/ImportTypebotFromFileButton.tsx+10 −7 modified@@ -9,7 +9,7 @@ import type { ChangeEvent } from "react"; import { toast } from "@/lib/toast"; type Props = { - onNewTypebot: (typebot: Typebot) => void; + onNewTypebot: (typebot: Typebot, args: { enableSafetyFlags: true }) => void; } & ButtonProps; export const ImportTypebotFromFileButton = ({ @@ -24,12 +24,15 @@ export const ImportTypebotFromFileButton = ({ const fileContent = await readFile(file); try { const typebot = JSON.parse(fileContent); - onNewTypebot({ - ...typebot, - events: typebot.events ?? null, - icon: typebot.icon ?? null, - name: typebot.name ?? "My typebot", - } as Typebot); + onNewTypebot( + { + ...typebot, + events: typebot.events ?? null, + icon: typebot.icon ?? null, + name: typebot.name ?? "My typebot", + } as Typebot, + { enableSafetyFlags: true }, + ); } catch (err) { console.error(err); toast(await parseUnknownClientError({ err }));
apps/builder/src/features/templates/components/TemplatesDialog.tsx+2 −2 modified@@ -14,7 +14,7 @@ import type { TemplateProps } from "../types"; type Props = { isOpen: boolean; onClose: () => void; - onTypebotChoose: (typebot: Typebot, fromTemplate: string) => void; + onTypebotChoose: (typebot: Typebot, args: { fromTemplate: string }) => void; isLoading: boolean; }; @@ -60,7 +60,7 @@ export const TemplatesDialog = ({ const onUseThisTemplateClick = async () => { if (!typebot) return; - onTypebotChoose(typebot, selectedTemplate.name); + onTypebotChoose(typebot, { fromTemplate: selectedTemplate.name }); }; return (
apps/builder/src/features/typebot/api/createTypebot.ts+1 −1 modified@@ -84,7 +84,7 @@ export const createTypebot = authenticatedProcedure } const groups = ( - typebot.groups ? await sanitizeGroups(workspace)(typebot.groups) : [] + typebot.groups ? await sanitizeGroups(typebot.groups, { workspace }) : [] ) as TypebotV6["groups"]; const newTypebot = await prisma.typebot.create({ data: {
apps/builder/src/features/typebot/api/importTypebot.ts+6 −2 modified@@ -109,6 +109,7 @@ export const importTypebot = authenticatedProcedure ), typebot: importingTypebotSchema, fromTemplate: z.string().optional(), + enableSafetyFlags: z.boolean().optional(), }), ) .output( @@ -118,7 +119,7 @@ export const importTypebot = authenticatedProcedure ) .mutation( async ({ - input: { typebot, workspaceId, fromTemplate }, + input: { typebot, workspaceId, fromTemplate, enableSafetyFlags }, ctx: { user }, }) => { const workspace = await prisma.workspace.findUnique({ @@ -146,7 +147,10 @@ export const importTypebot = authenticatedProcedure const groups = ( duplicatingBot.groups - ? await sanitizeGroups(workspace)(duplicatingBot.groups) + ? await sanitizeGroups(duplicatingBot.groups, { + workspace, + enableSafetyFlags, + }) : [] ) as TypebotV6["groups"];
apps/builder/src/features/typebot/api/updateTypebot.ts+3 −1 modified@@ -166,7 +166,9 @@ export const updateTypebot = authenticatedProcedure } const groups = typebot.groups - ? await sanitizeGroups(existingTypebot.workspace)(typebot.groups) + ? await sanitizeGroups(typebot.groups, { + workspace: existingTypebot.workspace, + }) : undefined; const newTypebot = await prisma.typebot.update({
apps/builder/src/features/typebot/helpers/sanitizers.ts+71 −39 modified@@ -39,48 +39,80 @@ export const sanitizeSettings = ( : undefined, }); -export const sanitizeGroups = - (workspace: Pick<Workspace, "id" | "plan">) => - async (groups: Typebot["groups"]): Promise<Typebot["groups"]> => - Promise.all( - groups.map(async (group) => ({ - ...group, - blocks: await Promise.all(group.blocks.map(sanitizeBlock(workspace))), - })), - ) as Promise<Typebot["groups"]>; +export const sanitizeGroups = async ( + groups: Typebot["groups"], + { + enableSafetyFlags, + workspace, + }: { + enableSafetyFlags?: boolean; + workspace: Pick<Workspace, "id" | "plan">; + }, +): Promise<Typebot["groups"]> => + Promise.all( + groups.map(async (group) => ({ + ...group, + blocks: await Promise.all( + group.blocks.map((block) => + sanitizeBlock(block, { enableSafetyFlags, workspace }), + ), + ), + })), + ) as Promise<Typebot["groups"]>; + +const sanitizeBlock = async ( + block: Block, + { + enableSafetyFlags, + workspace, + }: { enableSafetyFlags?: boolean; workspace: Pick<Workspace, "id" | "plan"> }, +): Promise<Block> => { + if (!("options" in block) || !block.options) return block; -const sanitizeBlock = - (workspace: Pick<Workspace, "id" | "plan">) => - async (block: Block): Promise<Block> => { - if (!("options" in block) || !block.options) return block; + if ( + enableSafetyFlags && + (block.type === LogicBlockType.SCRIPT || + block.type === LogicBlockType.SET_VARIABLE) + ) { + return { + ...block, + options: { + ...block.options, + isUnsafe: + block.options.isExecutedOnClient === true || + (block.type === LogicBlockType.SCRIPT && + block.options.isExecutedOnClient === undefined), + }, + }; + } - switch (block.type) { - case IntegrationBlockType.EMAIL: - return { - ...block, - options: { - ...block.options, - credentialsId: - (await sanitizeCredentialsId(workspace.id)( - block.options?.credentialsId, - )) ?? getDefaultEmailCredentialsId(workspace.plan), - }, - }; - default: - return { - ...block, - options: { - ...block.options, - proxyCredentialsId: await sanitizeCredentialsId(workspace.id)( - block.options?.proxyCredentialsId, - ), - credentialsId: await sanitizeCredentialsId(workspace.id)( + switch (block.type) { + case IntegrationBlockType.EMAIL: + return { + ...block, + options: { + ...block.options, + credentialsId: + (await sanitizeCredentialsId(workspace.id)( block.options?.credentialsId, - ), - }, - }; - } - }; + )) ?? getDefaultEmailCredentialsId(workspace.plan), + }, + }; + default: + return { + ...block, + options: { + ...block.options, + proxyCredentialsId: await sanitizeCredentialsId(workspace.id)( + block.options?.proxyCredentialsId, + ), + credentialsId: await sanitizeCredentialsId(workspace.id)( + block.options?.credentialsId, + ), + }, + }; + } +}; const sanitizeCredentialsId = (workspaceId: string) =>
apps/docs/openapi/builder.json+35 −0 modified@@ -9097,6 +9097,9 @@ }, "fromTemplate": { "type": "string" + }, + "enableSafetyFlags": { + "type": "boolean" } }, "required": [ @@ -15142,6 +15145,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "shouldExecuteInParentContext": { "type": "boolean" } @@ -15213,6 +15220,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "expressionToEvaluate": { "type": "string" }, @@ -15236,6 +15247,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "expressionToEvaluate": { "type": "string" }, @@ -15268,6 +15283,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "type": { "type": "string", "enum": [ @@ -15293,6 +15312,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "type": { "type": "string", "enum": [ @@ -15325,6 +15348,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "type": { "type": "string", "enum": [ @@ -15359,6 +15386,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "type": { "type": "string", "enum": [ @@ -15382,6 +15413,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "type": { "type": "string", "enum": [
apps/docs/openapi/viewer.json+41 −0 modified@@ -7206,6 +7206,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "shouldExecuteInParentContext": { "type": "boolean" } @@ -7277,6 +7281,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "expressionToEvaluate": { "type": "string" }, @@ -7300,6 +7308,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "expressionToEvaluate": { "type": "string" }, @@ -7332,6 +7344,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "type": { "type": "string", "enum": [ @@ -7357,6 +7373,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "type": { "type": "string", "enum": [ @@ -7389,6 +7409,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "type": { "type": "string", "enum": [ @@ -7423,6 +7447,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "type": { "type": "string", "enum": [ @@ -7446,6 +7474,10 @@ "isExecutedOnClient": { "type": "boolean" }, + "isUnsafe": { + "type": "boolean", + "description": "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access" + }, "type": { "type": "string", "enum": [ @@ -16806,6 +16838,9 @@ "content": { "type": "string" }, + "isUnsafe": { + "type": "boolean" + }, "isCode": { "type": "boolean" }, @@ -16922,6 +16957,9 @@ "content": { "type": "string" }, + "isUnsafe": { + "type": "boolean" + }, "isCode": { "type": "boolean" }, @@ -17095,6 +17133,9 @@ "content": { "type": "string" }, + "isUnsafe": { + "type": "boolean" + }, "isCode": { "type": "boolean" },
packages/blocks/logic/src/script/schema.ts+6 −0 modified@@ -6,6 +6,12 @@ export const scriptOptionsSchema = z.object({ name: z.string().optional(), content: z.string().optional(), isExecutedOnClient: z.boolean().optional(), + isUnsafe: z + .boolean() + .optional() + .describe( + "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access", + ), shouldExecuteInParentContext: z.boolean().optional(), });
packages/blocks/logic/src/setVariable/schema.ts+6 −0 modified@@ -6,6 +6,12 @@ import { valueTypesWithNoOptions } from "./constants"; const baseOptions = z.object({ variableId: z.string().optional(), isExecutedOnClient: z.boolean().optional(), + isUnsafe: z + .boolean() + .optional() + .describe( + "Enabled by default for imported bots to prevent code to be executed in preview with priviledged access", + ), }); const basicSetVariableOptionsSchema = baseOptions.extend({
packages/bot-engine/src/blocks/logic/script/executeScript.ts+4 −1 modified@@ -66,7 +66,10 @@ export const executeScript = async ( clientSideActions: [ { type: "scriptToExecute", - scriptToExecute: scriptToExecute, + scriptToExecute: { + ...scriptToExecute, + isUnsafe: block.options.isUnsafe, + }, }, ], };
packages/bot-engine/src/blocks/logic/setVariable/executeSetVariable.ts+1 −0 modified@@ -78,6 +78,7 @@ export const executeSetVariable = async ( scriptToExecute: { ...scriptToExecute, isCode, + isUnsafe: block.options.isUnsafe, }, }, expectsDedicatedReply: true,
packages/chat-api/src/clientSideAction.ts+1 −0 modified@@ -16,6 +16,7 @@ export type StartPropsToInject = z.infer<typeof startPropsToInjectSchema>; const scriptToExecuteSchema = z.object({ content: z.string(), + isUnsafe: z.boolean().optional(), isCode: z.boolean().optional(), args: z.array( z.object({
packages/embeds/js/package.json+1 −1 modified@@ -1,6 +1,6 @@ { "name": "@typebot.io/js", - "version": "0.9.14", + "version": "0.9.15", "description": "Javascript library to display typebots on your website", "license": "FSL-1.1-ALv2", "type": "module",
packages/embeds/js/src/components/ConversationContainer/ChatContainer.tsx+1 −0 modified@@ -345,6 +345,7 @@ export const ChatContainer = (props: Props) => { const response = await executeClientSideAction({ clientSideAction: action, context: { + isPreview: props.context.isPreview, apiHost: props.context.apiHost, wsHost: props.context.wsHost, sessionId: props.initialChatReply.sessionId,
packages/embeds/js/src/features/blocks/integrations/chatwoot/utils/executeChatwoot.ts+8 −4 modified@@ -1,11 +1,15 @@ import type { ScriptToExecute } from "@typebot.io/chat-api/clientSideAction"; import { executeScript } from "@/features/blocks/logic/script/executeScript"; +import type { ClientSideActionContext } from "@/types"; import { chatwootWebWidgetOpenedMessage } from "../constants"; -export const executeChatwoot = (chatwoot: { - scriptToExecute: ScriptToExecute; -}) => { - executeScript(chatwoot.scriptToExecute); +export const executeChatwoot = ( + chatwoot: { + scriptToExecute: ScriptToExecute; + }, + { isPreview }: Pick<ClientSideActionContext, "isPreview">, +) => { + executeScript(chatwoot.scriptToExecute, { isPreview }); return { scriptCallbackMessage: chatwootWebWidgetOpenedMessage, };
packages/embeds/js/src/features/blocks/integrations/httpRequest/executeHttpRequest.ts+2 −0 modified@@ -2,13 +2,15 @@ import type { ExecutableHttpRequest } from "@typebot.io/blocks-integrations/http export const executeHttpRequest = async ( httpRequestToExecute: ExecutableHttpRequest, + isPreview: boolean, ): Promise<string> => { const { url, method, body, headers } = httpRequestToExecute; try { const response = await fetch(url, { method, body: method !== "GET" && body ? JSON.stringify(body) : undefined, headers, + credentials: isPreview ? "omit" : undefined, }); const statusCode = response.status; const data = await response.json();
packages/embeds/js/src/features/blocks/logic/script/executeScript.ts+23 −17 modified@@ -1,19 +1,32 @@ import type { ScriptToExecute } from "@typebot.io/chat-api/clientSideAction"; import { parseUnknownClientError } from "@typebot.io/lib/parseUnknownClientError"; +import type { ClientSideActionContext } from "@/types"; +import { runUserCodeInWorker } from "./scriptRunner"; const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor; -export const executeScript = async ({ content, args }: ScriptToExecute) => { +export const executeScript = async ( + { content, args, isUnsafe }: ScriptToExecute, + { isPreview }: Pick<ClientSideActionContext, "isPreview">, +) => { try { - const func = AsyncFunction( - ...args.map((arg) => arg.id), - parseContent(content), - ); - const result = await func(...args.map((arg) => arg.value)); - if (result && typeof result === "string") - return { - scriptCallbackMessage: result, - }; + const code = content.replace(/<script>/g, "").replace(/<\/script>/g, ""); + if (isPreview && isUnsafe) { + const argsRecord = Object.fromEntries(args.map((a) => [a.id, a.value])); + + const result = await runUserCodeInWorker(code, argsRecord); + + if (result && typeof result === "string") { + return { scriptCallbackMessage: result }; + } + } else { + const func = AsyncFunction(...args.map((arg) => arg.id), code); + const result = await func(...args.map((arg) => arg.value)); + if (result && typeof result === "string") + return { + scriptCallbackMessage: result, + }; + } } catch (err) { console.log(err); return { @@ -27,13 +40,6 @@ export const executeScript = async ({ content, args }: ScriptToExecute) => { } }; -const parseContent = (content: string) => { - const contentWithoutScriptTags = content - .replace(/<script>/g, "") - .replace(/<\/script>/g, ""); - return contentWithoutScriptTags; -}; - export const executeCode = async ({ args, content,
packages/embeds/js/src/features/blocks/logic/script/scriptRunner.ts+143 −0 added@@ -0,0 +1,143 @@ +// Inline the worker code as a string to avoid bundler issues +const workerCode = ` +const AsyncFunction = Object.getPrototypeOf(async () => {}) + .constructor; + +const originalFetch = self.fetch.bind(self); + +// Wrap fetch to force credentials: "omit" and strip sensitive headers +async function safeFetch( + input, + init, +) { + const safeInit = { + ...(init || {}), + credentials: "omit", + }; + + if (input instanceof Request) { + const safeRequest = new Request(input, safeInit); + return originalFetch(safeRequest); + } + + return originalFetch(input, safeInit); +} + +// Override global fetch BEFORE any user code runs +self.fetch = safeFetch; + +// Disable other network APIs that could carry cookies automatically +self.XMLHttpRequest = () => { + console.warn("XMLHttpRequest is disabled in preview mode."); +}; + +self.WebSocket = () => { + console.warn("WebSocket is disabled in preview mode."); +}; + +self.EventSource = () => { + console.warn("EventSource is disabled in preview mode."); +}; + +self.Worker = () => { + console.warn("Creating nested workers is disabled in preview mode."); +}; + +self.SharedWorker = () => { + console.warn("Shared workers are disabled in preview mode."); +}; + +self.onmessage = async (event) => { + const { id, code, args = {} } = event.data; + + try { + const argNames = Object.keys(args); + const argValues = Object.values(args); + + // Create an async function with the given args + const userFunc = new AsyncFunction(...argNames, code); + + const result = await userFunc(...argValues); + + const message = { id, ok: true, result }; + self.postMessage(message); + } catch (err) { + const message = { + id, + ok: false, + error: + err instanceof Error + ? err.message + : typeof err === "string" + ? err + : JSON.stringify(err), + }; + self.postMessage(message); + } +}; +`; + +let workerPromise: Promise<Worker> | null = null; + +function getScriptRunnerWorker(): Promise<Worker> { + if (!workerPromise) { + workerPromise = new Promise((resolve, reject) => { + try { + if (typeof window === "undefined") { + throw new Error("Script runner worker cannot be used on the server."); + } + + const blob = new Blob([workerCode], { type: "application/javascript" }); + const workerUrl = URL.createObjectURL(blob); + const worker = new Worker(workerUrl); + + worker.addEventListener("error", (e) => { + console.error("[ScriptRunnerWorker] error", e); + }); + + resolve(worker); + } catch (err) { + reject(err); + } + }); + } + return workerPromise; +} + +type WorkerRequest = { + id: number; + code: string; + args: Record<string, unknown>; +}; + +type WorkerResponse = + | { id: number; ok: true; result: unknown } + | { id: number; ok: false; error: string }; + +let nextId = 0; + +export const runUserCodeInWorker = async ( + code: string, + args: Record<string, unknown>, +): Promise<unknown> => { + const worker = await getScriptRunnerWorker(); + + return new Promise((resolve, reject) => { + const id = nextId++; + + const listener = (event: MessageEvent<WorkerResponse>) => { + const msg = event.data; + if (!msg || msg.id !== id) return; + + worker.removeEventListener("message", listener); + + if (msg.ok) resolve(msg.result); + else reject(new Error(msg.error)); + }; + + worker.addEventListener("message", listener); + + const payload: WorkerRequest = { id, code, args }; + worker.postMessage(payload); + }); +};
packages/embeds/js/src/features/blocks/logic/setVariable/executeSetVariable.ts+25 −16 modified@@ -2,31 +2,40 @@ import type { ScriptToExecute } from "@typebot.io/chat-api/clientSideAction"; import { parseUnknownClientError } from "@typebot.io/lib/parseUnknownClientError"; import { safeStringify } from "@typebot.io/lib/safeStringify"; import type { LogInSession } from "@typebot.io/logs/schemas"; +import type { ClientSideActionContext } from "@/types"; +import { runUserCodeInWorker } from "../script/scriptRunner"; const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor; -export const executeSetVariable = async ({ - content, - args, - isCode, -}: ScriptToExecute): Promise<{ +export const executeSetVariable = async ( + { content, args, isCode, isUnsafe }: ScriptToExecute, + { isPreview }: Pick<ClientSideActionContext, "isPreview">, +): Promise<{ replyToSend: string | undefined; logs?: LogInSession[]; }> => { try { - // To avoid octal number evaluation - if (!isNaN(content as unknown as number) && /0[^.].+/.test(content)) + if (isPreview && isUnsafe) { + const argsRecord = Object.fromEntries(args.map((a) => [a.id, a.value])); + const result = await runUserCodeInWorker(content, argsRecord); return { - replyToSend: content, + replyToSend: safeStringify(result) ?? undefined, }; - const func = AsyncFunction( - ...args.map((arg) => arg.id), - content.includes("return ") ? content : `return ${content}`, - ); - const replyToSend = await func(...args.map((arg) => arg.value)); - return { - replyToSend: safeStringify(replyToSend) ?? undefined, - }; + } else { + // To avoid octal number evaluation + if (!isNaN(content as unknown as number) && /0[^.].+/.test(content)) + return { + replyToSend: content, + }; + const func = AsyncFunction( + ...args.map((arg) => arg.id), + content.includes("return ") ? content : `return ${content}`, + ); + const replyToSend = await func(...args.map((arg) => arg.value)); + return { + replyToSend: safeStringify(replyToSend) ?? undefined, + }; + } } catch (err) { console.error(err); return {
packages/embeds/js/src/types.ts+1 −0 modified@@ -18,6 +18,7 @@ export type ClientSideActionContext = { wsHost?: string; sessionId: string; resultId?: string; + isPreview: boolean; }; export type ChatChunk = Pick<
packages/embeds/js/src/utils/executeClientSideActions.ts+10 −3 modified@@ -37,13 +37,17 @@ export const executeClientSideAction = async ({ onStreamError, }: Props): Promise<ClientSideActionResponse> => { if ("chatwoot" in clientSideAction) { - return executeChatwoot(clientSideAction.chatwoot); + return executeChatwoot(clientSideAction.chatwoot, { + isPreview: context.isPreview, + }); } if ("googleAnalytics" in clientSideAction) { return executeGoogleAnalyticsBlock(clientSideAction.googleAnalytics); } if ("scriptToExecute" in clientSideAction) { - return executeScript(clientSideAction.scriptToExecute); + return executeScript(clientSideAction.scriptToExecute, { + isPreview: context.isPreview, + }); } if ("redirect" in clientSideAction) { return executeRedirect(clientSideAction.redirect); @@ -55,7 +59,9 @@ export const executeClientSideAction = async ({ : undefined; } if ("setVariable" in clientSideAction) { - return executeSetVariable(clientSideAction.setVariable.scriptToExecute); + return executeSetVariable(clientSideAction.setVariable.scriptToExecute, { + isPreview: context.isPreview, + }); } if ( "streamOpenAiChatCompletion" in clientSideAction || @@ -85,6 +91,7 @@ export const executeClientSideAction = async ({ if ("httpRequestToExecute" in clientSideAction) { const response = await executeHttpRequest( clientSideAction.httpRequestToExecute, + context.isPreview, ); return { replyToSend: response }; }
packages/embeds/react/package.json+1 −1 modified@@ -1,6 +1,6 @@ { "name": "@typebot.io/react", - "version": "0.9.14", + "version": "0.9.15", "description": "Convenient library to display typebots on your React app", "license": "FSL-1.1-ALv2", "type": "module",
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.