🐛 Make allowed origins stricter and prevent the bot being consumable from public URL as mentioned in the docs

Closes #1812
This commit is contained in:
Baptiste Arnaud 2025-03-26 13:36:47 +01:00
parent c9b57f58bf
commit 7f44ca4410
No known key found for this signature in database
11 changed files with 267 additions and 106 deletions

View File

@ -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"

View File

@ -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;
},
}),
);

View File

@ -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,

View File

@ -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,

View File

@ -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,
}),
);

View File

@ -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,
};
}

View File

@ -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

View File

@ -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

View File

@ -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",
});
};

View File

@ -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(),
}),
})

View File

@ -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<StartChatResponse>() };
} catch (error) {
return { error };