🐛 Fix file upload advisory (#2544)

- Removed deprecated viewer upload URL V1/V2 routes and handlers.
- Hardened V3 upload paths by validating session-derived path segments.
- Updated API docs and OpenAPI output to advertise only V3
generate-upload-url.
- Added file-input upload URL test coverage and package test wiring.
This commit is contained in:
Baptiste Arnaud 2026-06-27 16:32:26 +02:00 committed by GitHub
parent 9c81300e5a
commit ab729561d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 159 additions and 668 deletions

View File

@ -1,6 +1,6 @@
---
title: 'Generate upload URL'
openapi: POST /v2/generate-upload-url
openapi: POST /v3/generate-upload-url
---
The `presignedUrl` and `formData` fields can be then used to upload the file to the S3 bucket, directly from the browser if necessary. Here is an example:

View File

@ -3936,253 +3936,6 @@
}
}
},
"/v1/generate-upload-url": {
"post": {
"operationId": "generateUploadUrlV1",
"summary": "Generate upload URL",
"description": "Used to upload anything from the client to S3 bucket",
"security": [
{
"Authorization": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"filePathProps": {
"anyOf": [
{
"type": "object",
"properties": {
"typebotId": {
"type": "string"
},
"blockId": {
"type": "string"
},
"resultId": {
"type": "string"
},
"fileName": {
"type": "string"
}
},
"required": [
"typebotId",
"blockId",
"resultId",
"fileName"
]
},
{
"type": "object",
"properties": {
"sessionId": {
"type": "string"
},
"fileName": {
"type": "string"
}
},
"required": [
"sessionId",
"fileName"
]
}
]
},
"fileType": {
"type": "string"
}
},
"required": [
"filePathProps"
]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"presignedUrl": {
"type": "string"
},
"formData": {
"type": "object",
"additionalProperties": {}
},
"fileUrl": {
"type": "string"
}
},
"required": [
"presignedUrl",
"formData",
"fileUrl"
]
}
}
}
},
"400": {
"description": "Invalid input data",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.BAD_REQUEST"
}
}
}
},
"401": {
"description": "Authorization not provided",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.UNAUTHORIZED"
}
}
}
},
"403": {
"description": "Insufficient access",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.FORBIDDEN"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
}
}
}
}
}
}
},
"/v2/generate-upload-url": {
"post": {
"operationId": "generateUploadUrlV2",
"summary": "Generate upload URL",
"description": "Used to upload anything from the client to S3 bucket",
"security": [
{
"Authorization": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"sessionId": {
"type": "string"
},
"fileName": {
"type": "string"
},
"fileType": {
"type": "string"
}
},
"required": [
"sessionId",
"fileName"
]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"presignedUrl": {
"type": "string"
},
"formData": {
"type": "object",
"additionalProperties": {}
},
"fileUrl": {
"type": "string"
}
},
"required": [
"presignedUrl",
"formData",
"fileUrl"
]
}
}
}
},
"400": {
"description": "Invalid input data",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.BAD_REQUEST"
}
}
}
},
"401": {
"description": "Authorization not provided",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.UNAUTHORIZED"
}
}
}
},
"403": {
"description": "Insufficient access",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.FORBIDDEN"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
}
}
}
}
}
}
},
"/v3/generate-upload-url": {
"post": {
"operationId": "generateUploadUrl",
@ -17876,4 +17629,4 @@
}
}
}
}
}

View File

@ -402,6 +402,9 @@
"@typebot.io/typebot": "workspace:*",
"zod": "^4.3.5",
},
"devDependencies": {
"@types/bun": "^1.3.9",
},
},
"packages/blocks/inputs": {
"name": "@typebot.io/blocks-inputs",

View File

@ -11,6 +11,9 @@
},
"./package.json": "./package.json"
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@orpc/server": "^1.13.9",
"@typebot.io/groups": "workspace:*",
@ -24,5 +27,8 @@
"@typebot.io/blocks-logic": "workspace:*",
"@typebot.io/typebot": "workspace:*",
"zod": "^4.3.5"
},
"devDependencies": {
"@types/bun": "^1.3.9"
}
}

View File

@ -1,197 +0,0 @@
import { ORPCError } from "@orpc/server";
import { InputBlockType } from "@typebot.io/blocks-inputs/constants";
import { getSession } from "@typebot.io/chat-session/queries/getSession";
import { env } from "@typebot.io/env";
import { getBlockById } from "@typebot.io/groups/helpers/getBlockById";
import { parseGroups } from "@typebot.io/groups/helpers/parseGroups";
import {
createUploadFileName,
resolveStoredUploadFileType,
resolveUploadContentDisposition,
resolveUploadFileType,
} from "@typebot.io/lib/s3/createUploadFilePath";
import { generateSignedUploadProxyUrl } from "@typebot.io/lib/s3/signedUploadProxy";
import prisma from "@typebot.io/prisma";
import { z } from "zod";
import { getUploadProxyBaseUrl } from "../getUploadProxyBaseUrl";
export const generateUploadUrlV1InputSchema = z.object({
filePathProps: z
.object({
typebotId: z.string(),
blockId: z.string(),
resultId: z.string(),
fileName: z.string(),
})
.or(
z.object({
sessionId: z.string(),
fileName: z.string(),
}),
),
fileType: z.string().optional(),
fileSize: z.number().optional(),
});
export const handleGenerateUploadUrlV1 = async ({
input: { filePathProps, fileType, fileSize },
context,
}: {
input: z.infer<typeof generateUploadUrlV1InputSchema>;
context?: { apiOrigin?: string };
}) => {
if (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY || !env.S3_SECRET_KEY)
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message:
"S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY",
});
const resolvedFileType = resolveUploadFileType(fileType);
const uploadFileName = createUploadFileName(resolvedFileType);
if ("typebotId" in filePathProps) {
const publicTypebot = await prisma.publicTypebot.findFirst({
where: {
typebotId: filePathProps.typebotId,
},
select: {
version: true,
groups: true,
typebot: {
select: {
workspaceId: true,
},
},
},
});
const workspaceId = publicTypebot?.typebot.workspaceId;
if (!workspaceId)
throw new ORPCError("BAD_REQUEST", {
message: "Can't find workspaceId",
});
const fileUploadBlock = parseGroups(publicTypebot.groups, {
typebotVersion: publicTypebot.version,
})
.flatMap((group) => group.blocks)
.find((block) => block.id === filePathProps.blockId);
if (fileUploadBlock?.type !== InputBlockType.FILE)
throw new ORPCError("BAD_REQUEST", {
message: "Can't find file upload block",
});
const maxFileSize =
fileUploadBlock.options?.sizeLimit ??
env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE;
if (maxFileSize && fileSize && fileSize > maxFileSize * 1024 * 1024)
throw new ORPCError("BAD_REQUEST", {
message: `File size exceeds the ${maxFileSize}MB limit`,
});
return {
...generateSignedUploadProxyUrl({
baseUrl: getUploadProxyBaseUrl(context?.apiOrigin),
fileType: resolveStoredUploadFileType(resolvedFileType),
contentDisposition: resolveUploadContentDisposition(resolvedFileType),
filePath: `public/workspaces/${workspaceId}/typebots/${filePathProps.typebotId}/results/${filePathProps.resultId}/${uploadFileName}`,
maxFileSize,
}),
fileType: resolvedFileType,
};
}
const session = await getSession(filePathProps.sessionId);
if (!session?.state)
throw new ORPCError("BAD_REQUEST", {
message: "Can't find session",
});
const typebotId = session.state.typebotsQueue[0].typebot.id;
const publicTypebot = await prisma.publicTypebot.findFirst({
where: {
typebotId,
},
select: {
version: true,
groups: true,
typebot: {
select: {
workspaceId: true,
},
},
},
});
const workspaceId = publicTypebot?.typebot.workspaceId;
if (!workspaceId)
throw new ORPCError("BAD_REQUEST", {
message: "Can't find workspaceId",
});
if (session.state.currentBlockId === undefined)
throw new ORPCError("BAD_REQUEST", {
message: "Can't find currentBlockId in session state",
});
const { block: fileUploadBlock } = getBlockById(
session.state.currentBlockId,
parseGroups(publicTypebot.groups, {
typebotVersion: publicTypebot.version,
}),
);
if (fileUploadBlock?.type !== InputBlockType.FILE)
throw new ORPCError("BAD_REQUEST", {
message: "Can't find file upload block",
});
const resultId = session.state.typebotsQueue[0].resultId;
const visibility =
fileUploadBlock.options?.visibility === "Private" ? "private" : "public";
const filePath = resultId
? `${visibility}/workspaces/${workspaceId}/typebots/${typebotId}/results/${resultId}/${uploadFileName}`
: `public/tmp/${typebotId}/${uploadFileName}`;
const maxFileSize =
fileUploadBlock.options && "sizeLimit" in fileUploadBlock.options
? fileUploadBlock.options.sizeLimit
: env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE;
if (maxFileSize && fileSize && fileSize > maxFileSize * 1024 * 1024)
throw new ORPCError("BAD_REQUEST", {
message: `File size exceeds the ${maxFileSize}MB limit`,
});
const {
presignedUrl,
fileUrl: defaultFileUrl,
formData,
maxFileSize: maxFileSizeMB,
} = generateSignedUploadProxyUrl({
baseUrl: getUploadProxyBaseUrl(context?.apiOrigin),
fileType: resolveStoredUploadFileType(resolvedFileType),
contentDisposition: resolveUploadContentDisposition(resolvedFileType),
filePath,
maxFileSize,
});
return {
presignedUrl,
formData,
fileType: resolvedFileType,
maxFileSize: maxFileSizeMB,
fileUrl:
fileUploadBlock.options?.visibility === "Private" && resultId
? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${uploadFileName}`
: defaultFileUrl,
};
};

View File

@ -1,170 +0,0 @@
import { ORPCError } from "@orpc/server";
import { InputBlockType } from "@typebot.io/blocks-inputs/constants";
import type { FileInputBlock } from "@typebot.io/blocks-inputs/file/schema";
import type { TextInputBlock } from "@typebot.io/blocks-inputs/text/schema";
import { getSession } from "@typebot.io/chat-session/queries/getSession";
import { env } from "@typebot.io/env";
import { getBlockById } from "@typebot.io/groups/helpers/getBlockById";
import { parseGroups } from "@typebot.io/groups/helpers/parseGroups";
import {
createUploadFileName,
resolveStoredUploadFileType,
resolveUploadContentDisposition,
resolveUploadFileType,
} from "@typebot.io/lib/s3/createUploadFilePath";
import { generateSignedUploadProxyUrl } from "@typebot.io/lib/s3/signedUploadProxy";
import prisma from "@typebot.io/prisma";
import { z } from "zod";
import { getUploadProxyBaseUrl } from "../getUploadProxyBaseUrl";
export const generateUploadUrlV2InputSchema = z.object({
sessionId: z.string(),
fileName: z.string(),
fileType: z.string().optional(),
fileSize: z.number().optional(),
});
export const handleGenerateUploadUrlV2 = async ({
input: { sessionId, fileType, fileSize },
context,
}: {
input: z.infer<typeof generateUploadUrlV2InputSchema>;
context?: { apiOrigin?: string };
}) => {
if (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY || !env.S3_SECRET_KEY)
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message:
"S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY",
});
const session = await getSession(sessionId);
if (!session?.state)
throw new ORPCError("BAD_REQUEST", {
message: "Can't find session",
});
const typebotId = session.state.typebotsQueue[0].typebot.id;
const isPreview = !session.state.typebotsQueue[0].resultId;
const typebot = session.state.typebotsQueue[0].resultId
? await getAndParsePublicTypebot(session.state.typebotsQueue[0].typebot.id)
: session.state.typebotsQueue[0].typebot;
if (!typebot?.version)
throw new ORPCError("BAD_REQUEST", {
message: "Can't find typebot",
});
if (session.state.currentBlockId === undefined)
throw new ORPCError("BAD_REQUEST", {
message: "Can't find currentBlockId in session state",
});
const { block } = getBlockById(
session.state.currentBlockId,
parseGroups(typebot.groups, {
typebotVersion: typebot.version,
}),
);
if (
!block ||
(block.type !== InputBlockType.FILE &&
(block.type !== InputBlockType.TEXT ||
!block.options?.attachments?.isEnabled) &&
(block.type !== InputBlockType.TEXT ||
!block.options?.audioClip?.isEnabled))
)
throw new ORPCError("BAD_REQUEST", {
message: "Current block does not expect file upload",
});
const resolvedFileType = resolveUploadFileType(fileType);
const { visibility, maxFileSize } = parseFileUploadParams(block);
if (maxFileSize && fileSize && fileSize > maxFileSize * 1024 * 1024)
throw new ORPCError("BAD_REQUEST", {
message: `File size exceeds the ${maxFileSize}MB limit`,
});
const resultId = session.state.typebotsQueue[0].resultId;
const uploadFileName = createUploadFileName(resolvedFileType);
const filePath =
"workspaceId" in typebot && typebot.workspaceId && resultId
? `${visibility === "Private" ? "private" : "public"}/workspaces/${
typebot.workspaceId
}/typebots/${typebotId}/results/${resultId}/${uploadFileName}`
: `public/tmp/${typebotId}/${uploadFileName}`;
const {
presignedUrl,
fileUrl: defaultFileUrl,
formData,
maxFileSize: maxFileSizeMB,
} = generateSignedUploadProxyUrl({
baseUrl: getUploadProxyBaseUrl(context?.apiOrigin),
fileType: resolveStoredUploadFileType(resolvedFileType),
contentDisposition: resolveUploadContentDisposition(resolvedFileType),
filePath,
maxFileSize,
});
return {
presignedUrl,
formData,
fileType: resolvedFileType,
maxFileSize: maxFileSizeMB,
fileUrl:
visibility === "Private" && !isPreview
? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${uploadFileName}`
: defaultFileUrl,
};
};
const getAndParsePublicTypebot = async (typebotId: string) => {
const publicTypebot = await prisma.publicTypebot.findFirst({
where: {
typebotId,
},
select: {
version: true,
groups: true,
typebot: {
select: {
workspaceId: true,
},
},
},
});
if (!publicTypebot) return null;
return { ...publicTypebot, workspaceId: publicTypebot.typebot.workspaceId };
};
const parseFileUploadParams = (
block: FileInputBlock | TextInputBlock,
): { visibility: "Public" | "Private"; maxFileSize: number | undefined } => {
if (block.type === InputBlockType.FILE) {
return {
visibility:
block.options?.visibility === "Private" ? "Private" : "Public",
maxFileSize:
block.options && "sizeLimit" in block.options
? block.options.sizeLimit
: env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE,
};
}
return {
visibility:
block.options?.attachments?.visibility === "Private"
? "Private"
: "Public",
maxFileSize: env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE,
};
};

View File

@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";
import { InputBlockType } from "@typebot.io/blocks-inputs/constants";
process.env.DATABASE_URL = "postgres://user:password@localhost:5432/typebot";
process.env.ENCRYPTION_SECRET = "12345678901234567890123456789012";
process.env.NEXTAUTH_URL = "http://localhost:3000";
process.env.NEXT_PUBLIC_VIEWER_URL = "http://localhost:3001";
process.env.S3_ACCESS_KEY = "minio";
process.env.S3_BUCKET = "typebot";
process.env.S3_ENDPOINT = "s3.example.com";
process.env.S3_SECRET_KEY = "miniostorage";
process.env.SKIP_ENV_CHECK = "false";
const getSessionMock = mock();
const publicTypebotFindFirstMock = mock();
mock.module("@typebot.io/chat-session/queries/getSession", () => ({
getSession: getSessionMock,
}));
mock.module("@typebot.io/prisma", () => ({
default: {
publicTypebot: {
findFirst: publicTypebotFindFirstMock,
},
},
}));
const { handleGenerateUploadUrl } = await import("./handleGenerateUploadUrl");
const blockIdFromSession = "block-session";
const resultIdFromSession = "result-session";
const sessionId = "session-id";
const typebotIdFromSession = "typebot-session";
const workspaceIdFromPublicTypebot = "workspace-session";
describe("handleGenerateUploadUrl", () => {
beforeEach(() => {
getSessionMock.mockReset();
publicTypebotFindFirstMock.mockReset();
getSessionMock.mockResolvedValue(buildSession());
publicTypebotFindFirstMock.mockResolvedValue(buildPublicTypebot());
});
it("uses the session block for current uploads", async () => {
const response = await handleGenerateUploadUrl({
input: {
sessionId,
blockId: "block-request",
fileName: "file.png",
fileType: "image/png",
fileSize: 1024,
},
context: { apiOrigin: "http://localhost:3001" },
});
const filePath = parseSignedUploadFilePath(response.presignedUrl);
expect(filePath.startsWith(expectedCurrentResultPrefix())).toBe(true);
expect(filePath.includes("block-request")).toBe(false);
expect(filePath.endsWith(".png")).toBe(true);
expect(response.fileUrl.startsWith(expectedCurrentPrivateFileUrl())).toBe(
true,
);
expect(response.maxFileSize).toBe(5);
});
});
const buildSession = () => ({
state: {
currentBlockId: blockIdFromSession,
typebotsQueue: [
{
resultId: resultIdFromSession,
typebot: {
id: typebotIdFromSession,
},
},
],
},
});
const buildPublicTypebot = () => ({
version: "5",
groups: [
{
id: "group",
title: "Group",
graphCoordinates: { x: 0, y: 0 },
blocks: [
{
id: blockIdFromSession,
type: InputBlockType.FILE,
options: {
visibility: "Private",
sizeLimit: 5,
},
},
],
},
],
typebot: {
workspaceId: workspaceIdFromPublicTypebot,
},
});
const parseSignedUploadFilePath = (presignedUrl: string) => {
const token = new URL(presignedUrl).pathname.split("/").at(-1);
if (!token) throw new Error("Missing upload token");
const encodedPayload = decodeURIComponent(token).split(".")[0];
if (!encodedPayload) throw new Error("Missing upload payload");
const payload: unknown = JSON.parse(
Buffer.from(encodedPayload, "base64url").toString("utf8"),
);
if (
typeof payload !== "object" ||
payload === null ||
!("filePath" in payload) ||
typeof payload.filePath !== "string"
)
throw new Error("Invalid upload payload");
return payload.filePath;
};
const expectedResultPrefix = () =>
`private/workspaces/${workspaceIdFromPublicTypebot}/typebots/${typebotIdFromSession}/results/${resultIdFromSession}/`;
const expectedCurrentResultPrefix = () =>
`${expectedResultPrefix()}blocks/${blockIdFromSession}/`;
const expectedPrivateFileUrl = () =>
`${process.env.NEXTAUTH_URL}/api/typebots/${typebotIdFromSession}/results/${resultIdFromSession}/`;
const expectedCurrentPrivateFileUrl = () =>
`${expectedPrivateFileUrl()}blocks/${blockIdFromSession}/`;

View File

@ -9,6 +9,7 @@ import { parseGroups } from "@typebot.io/groups/helpers/parseGroups";
import { parseAllowedFileTypesMetadata } from "@typebot.io/lib/extensionFromMimeType";
import {
createUploadFileName,
parseUploadPathSegment,
resolveStoredUploadFileType,
resolveUploadContentDisposition,
resolveUploadFileType,
@ -116,13 +117,17 @@ export const handleGenerateUploadUrl = async ({
const resultId = session.state.typebotsQueue[0].resultId;
const uploadFileName = createUploadFileName(resolvedFileType);
const typebotPathSegment = parseUploadPathSegment(typebotId);
const blockPathSegment = parseUploadPathSegment(currentBlockId);
const filePath =
"workspaceId" in typebot && typebot.workspaceId && resultId
? `${visibility === "Private" ? "private" : "public"}/workspaces/${
typebot.workspaceId
}/typebots/${typebotId}/results/${resultId}/blocks/${currentBlockId}/${uploadFileName}`
: `public/tmp/typebots/${typebotId}/blocks/${currentBlockId}/${uploadFileName}`;
? `${visibility === "Private" ? "private" : "public"}/workspaces/${parseUploadPathSegment(
typebot.workspaceId,
)}/typebots/${typebotPathSegment}/results/${parseUploadPathSegment(
resultId,
)}/blocks/${blockPathSegment}/${uploadFileName}`
: `public/tmp/typebots/${typebotPathSegment}/blocks/${blockPathSegment}/${uploadFileName}`;
const {
presignedUrl,

View File

@ -4,14 +4,6 @@ import {
} from "@typebot.io/config/orpc/builder/middlewares";
import { publicProcedure } from "@typebot.io/config/orpc/viewer/middlewares";
import { z } from "zod";
import {
generateUploadUrlV1InputSchema,
handleGenerateUploadUrlV1,
} from "./deprecated/handleGenerateUploadUrlV1";
import {
generateUploadUrlV2InputSchema,
handleGenerateUploadUrlV2,
} from "./deprecated/handleGenerateUploadUrlV2";
import {
generateUploadUrlInputSchema,
handleGenerateUploadUrl,
@ -79,44 +71,4 @@ export const fileUploadViewerRouter = {
}),
)
.handler(handleGenerateUploadUrl),
generateUploadUrlV1Procedure: publicProcedure
.route({
method: "POST",
path: "/v1/generate-upload-url",
summary: "Generate upload URL",
description: "Used to upload anything from the client to S3 bucket",
deprecated: true,
tags: ["File upload"],
})
.input(generateUploadUrlV1InputSchema)
.output(
z.object({
presignedUrl: z.string(),
formData: z.record(z.string(), z.string()),
fileType: z.string().optional(),
maxFileSize: z.number().optional(),
fileUrl: z.string(),
}),
)
.handler(handleGenerateUploadUrlV1),
generateUploadUrlV2Procedure: publicProcedure
.route({
method: "POST",
path: "/v2/generate-upload-url",
summary: "Generate upload URL",
description: "Used to upload anything from the client to S3 bucket",
deprecated: true,
tags: ["File upload"],
})
.input(generateUploadUrlV2InputSchema)
.output(
z.object({
presignedUrl: z.string(),
formData: z.record(z.string(), z.string()),
fileType: z.string().optional(),
maxFileSize: z.number().optional(),
fileUrl: z.string(),
}),
)
.handler(handleGenerateUploadUrlV2),
};