🔧 Migrate S3 uploads from presigned POST to presigned PUT (#2429)

## Changes

- **Presigned POST → PUT**: Replace `generatePresignedPostPolicy` with
`generatePresignedPutUrl` across all upload endpoints (builder + viewer
v1/v2/v3). This makes uploads compatible with Cloudflare R2 which
doesn't support the S3 POST Object API. Frontend consumers now use `PUT`
with raw file body + `Content-Type`/`Cache-Control` headers instead of
`POST` with FormData.
- **XSS mitigation**: Block dangerous content types (SVG, HTML, XML, JS)
in the builder `generateUploadUrl` endpoint. Restrict frontend `accept`
attributes from `image/*` to an explicit list of safe raster types
(`png, jpeg, gif, webp, avif, bmp, tiff`). Addresses
GHSA-jj87-c343-26vp.
- **Fix file upload URL validation**: `isURL` with `require_tld: true`
rejected `localhost` and `NEXTAUTH_URL` proxy URLs for private files.
Now uses a trusted host allowlist (`localhost`, `NEXTAUTH_URL`,
`S3_PUBLIC_CUSTOM_DOMAIN`) to skip TLD requirement.
- **Docs**: Update S3 CORS policy from `POST` to `PUT`, add Cloudflare
R2 to supported providers list.
- **Bump**: `@typebot.io/js` and `@typebot.io/react` → `0.10.0`

## Verification

- Tested avatar upload on builder with R2 bucket (PUT succeeds, image
displays)
- Verified CORS preflight passes after R2 bucket config
- Confirmed `generateUploadUrl` rejects `image/svg+xml` with 400
- All unit tests pass (`nx affected -t test`)
- Typecheck passes on all affected packages

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Baptiste Arnaud 2026-04-07 15:34:35 +02:00 committed by GitHub
parent a33051755f
commit cc9839f2e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 181 additions and 96 deletions

View File

@ -27,14 +27,13 @@ export const UploadButton = ({
filePathProps,
fileType: file.type,
});
const formData = new FormData();
Object.entries(data.formData).forEach(([key, value]) => {
formData.append(key, value);
});
formData.append("file", file);
const upload = await fetch(data.presignedUrl, {
method: "POST",
body: formData,
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
"Cache-Control": "public, max-age=86400",
},
});
if (!upload.ok) {
toast({
@ -47,7 +46,11 @@ export const UploadButton = ({
return (
<UploadButtonPrimitive
accept={fileType === "image" ? "image/avif, image/*" : "audio/*"}
accept={
fileType === "image"
? "image/avif, image/png, image/jpeg, image/gif, image/webp, image/bmp, image/tiff"
: "audio/*"
}
variant={variant}
size={size}
onFileUploadRequest={handleFileUploadRequest}

View File

@ -1,7 +1,7 @@
import { ORPCError } from "@orpc/server";
import { authenticatedProcedure } from "@typebot.io/config/orpc/builder/middlewares";
import { env } from "@typebot.io/env";
import { generatePresignedPostPolicy } from "@typebot.io/lib/s3/generatePresignedPostPolicy";
import { generatePresignedPutUrl } from "@typebot.io/lib/s3/generatePresignedPutUrl";
import prisma from "@typebot.io/prisma";
import { z } from "zod";
import { isWriteTypebotForbidden } from "@/features/typebot/helpers/isWriteTypebotForbidden";
@ -38,6 +38,18 @@ const inputSchema = z.object({
fileType: z.string().optional(),
});
const dangerousContentTypes = new Set([
"image/svg+xml",
"text/html",
"text/xml",
"application/xml",
"application/xhtml+xml",
"application/javascript",
"application/ecmascript",
"text/javascript",
"text/ecmascript",
]);
export type FilePathUploadProps = z.infer<
typeof inputSchema.shape.filePathProps
>;
@ -52,6 +64,13 @@ export const generateUploadUrl = authenticatedProcedure
"S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY",
});
const normalizedFileType = fileType?.toLowerCase().split(";")[0]?.trim();
if (!normalizedFileType || dangerousContentTypes.has(normalizedFileType))
throw new ORPCError("BAD_REQUEST", {
message:
"File type not allowed. SVG, HTML, and XML files cannot be uploaded.",
});
if ("resultId" in filePathProps && !user)
throw new ORPCError("UNAUTHORIZED", {
message: "You must be logged in to upload a file",
@ -62,18 +81,10 @@ export const generateUploadUrl = authenticatedProcedure
uploadProps: filePathProps,
});
const presignedPostPolicy = await generatePresignedPostPolicy({
return generatePresignedPutUrl({
fileType,
filePath,
});
return {
presignedUrl: presignedPostPolicy.postURL,
formData: presignedPostPolicy.formData,
fileUrl: env.S3_PUBLIC_CUSTOM_DOMAIN
? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}`
: `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`,
};
},
);

View File

@ -164,14 +164,13 @@ export const SpacesList = ({
},
fileType: file.type,
});
const formData = new FormData();
Object.entries(data.formData).forEach(([key, value]) => {
formData.append(key, value);
});
formData.append("file", file);
const upload = await fetch(data.presignedUrl, {
method: "POST",
body: formData,
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
"Cache-Control": "public, max-age=86400",
},
});
if (!upload.ok) {
toast({

View File

@ -7,6 +7,7 @@ In order for Typebot to store your uploaded files, you need to configure an S3 b
You can use any S3-compatible storage provider. Here are some examples:
- [AWS S3](https://aws.amazon.com/s3/)
- [Cloudflare R2](https://developers.cloudflare.com/r2/)
- [DigitalOcean Spaces](https://www.digitalocean.com/products/spaces/)
- [Wasabi](https://wasabi.com/)
- [MinIO](https://min.io/)
@ -26,7 +27,7 @@ To function properly, your S3 bucket must have the following configuration:
"CORSRules": [
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "POST"],
"AllowedMethods": ["PUT"],
"AllowedOrigins": ["*"],
"ExposeHeaders": ["ETag"]
}
@ -34,18 +35,18 @@ To function properly, your S3 bucket must have the following configuration:
}
```
If you are using the amazon console online you should paste it like this :
If you are using the amazon console online you should paste it like this :
```json
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "POST"],
"AllowedOrigins": ["*"],
"ExposeHeaders": ["ETag"]
}
]
```
```json
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT"],
"AllowedOrigins": ["*"],
"ExposeHeaders": ["ETag"]
}
]
```
- Access policy (replace `<bucket-name>` with the name of your S3 bucket):
```json
@ -78,7 +79,7 @@ Some S3 providers like AWS provide a user interface that allows you to directly
"CORSRules": [
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "POST"],
"AllowedMethods": ["PUT"],
"AllowedOrigins": ["*"],
"ExposeHeaders": ["ETag"]
}

View File

@ -4,7 +4,7 @@ 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 { generatePresignedPostPolicy } from "@typebot.io/lib/s3/generatePresignedPostPolicy";
import { generatePresignedPutUrl } from "@typebot.io/lib/s3/generatePresignedPutUrl";
import prisma from "@typebot.io/prisma";
import { z } from "zod";
@ -23,10 +23,11 @@ export const generateUploadUrlV1InputSchema = z.object({
}),
),
fileType: z.string().optional(),
fileSize: z.number().optional(),
});
export const handleGenerateUploadUrlV1 = async ({
input: { filePathProps, fileType },
input: { filePathProps, fileType, fileSize },
}: {
input: z.infer<typeof generateUploadUrlV1InputSchema>;
}) => {
@ -72,21 +73,20 @@ export const handleGenerateUploadUrlV1 = async ({
message: "Can't find file upload block",
});
const presignedPostPolicy = await generatePresignedPostPolicy({
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 generatePresignedPutUrl({
fileType,
filePath,
maxFileSize:
fileUploadBlock.options?.sizeLimit ??
env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE,
maxFileSize,
});
return {
presignedUrl: presignedPostPolicy.postURL,
formData: presignedPostPolicy.formData,
fileUrl: env.S3_PUBLIC_CUSTOM_DOMAIN
? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}`
: `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`,
};
}
const session = await getSession(filePathProps.sessionId);
@ -145,23 +145,29 @@ export const handleGenerateUploadUrlV1 = async ({
filePathProps.fileName
}`;
const presignedPostPolicy = await generatePresignedPostPolicy({
const maxFileSize =
fileUploadBlock.options && "sizeLimit" in fileUploadBlock.options
? (fileUploadBlock.options.sizeLimit as number)
: 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, fileType: resolvedFileType, maxFileSize: maxFileSizeMB } = await generatePresignedPutUrl({
fileType,
filePath,
maxFileSize:
fileUploadBlock.options && "sizeLimit" in fileUploadBlock.options
? (fileUploadBlock.options.sizeLimit as number)
: env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE,
maxFileSize,
});
return {
presignedUrl: presignedPostPolicy.postURL,
formData: presignedPostPolicy.formData,
presignedUrl,
fileType: resolvedFileType,
maxFileSize: maxFileSizeMB,
fileUrl:
fileUploadBlock.options?.visibility === "Private"
? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${filePathProps.fileName}`
: env.S3_PUBLIC_CUSTOM_DOMAIN
? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}`
: `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`,
: defaultFileUrl,
};
};

View File

@ -6,7 +6,7 @@ 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 { generatePresignedPostPolicy } from "@typebot.io/lib/s3/generatePresignedPostPolicy";
import { generatePresignedPutUrl } from "@typebot.io/lib/s3/generatePresignedPutUrl";
import prisma from "@typebot.io/prisma";
import type { Prisma } from "@typebot.io/prisma/types";
import { z } from "zod";
@ -15,10 +15,11 @@ export const generateUploadUrlV2InputSchema = z.object({
sessionId: z.string(),
fileName: z.string(),
fileType: z.string().optional(),
fileSize: z.number().optional(),
});
export const handleGenerateUploadUrlV2 = async ({
input: { fileName, sessionId, fileType },
input: { fileName, sessionId, fileType, fileSize },
}: {
input: z.infer<typeof generateUploadUrlV2InputSchema>;
}) => {
@ -72,6 +73,11 @@ export const handleGenerateUploadUrlV2 = async ({
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 filePath =
@ -81,21 +87,20 @@ export const handleGenerateUploadUrlV2 = async ({
}/typebots/${typebotId}/results/${resultId}/${fileName}`
: `public/tmp/${typebotId}/${fileName}`;
const presignedPostPolicy = await generatePresignedPostPolicy({
const { presignedUrl, fileUrl: defaultFileUrl, fileType: resolvedFileType, maxFileSize: maxFileSizeMB } = await generatePresignedPutUrl({
fileType,
filePath,
maxFileSize,
});
return {
presignedUrl: presignedPostPolicy.postURL,
formData: presignedPostPolicy.formData,
presignedUrl,
fileType: resolvedFileType,
maxFileSize: maxFileSizeMB,
fileUrl:
visibility === "Private" && !isPreview
? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${fileName}`
: env.S3_PUBLIC_CUSTOM_DOMAIN
? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}`
: `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`,
: defaultFileUrl,
};
};

View File

@ -7,7 +7,7 @@ import { env } from "@typebot.io/env";
import { getBlockById } from "@typebot.io/groups/helpers/getBlockById";
import { parseGroups } from "@typebot.io/groups/helpers/parseGroups";
import { parseAllowedFileTypesMetadata } from "@typebot.io/lib/extensionFromMimeType";
import { generatePresignedPostPolicy } from "@typebot.io/lib/s3/generatePresignedPostPolicy";
import { generatePresignedPutUrl } from "@typebot.io/lib/s3/generatePresignedPutUrl";
import prisma from "@typebot.io/prisma";
import type { Prisma } from "@typebot.io/prisma/types";
import { z } from "zod";
@ -17,10 +17,11 @@ export const generateUploadUrlInputSchema = z.object({
blockId: z.string(),
fileName: z.string(),
fileType: z.string().optional(),
fileSize: z.number().optional(),
});
export const handleGenerateUploadUrl = async ({
input: { fileName, sessionId, fileType, blockId },
input: { fileName, sessionId, fileType, blockId, fileSize },
}: {
input: z.infer<typeof generateUploadUrlInputSchema>;
}) => {
@ -94,6 +95,11 @@ export const handleGenerateUploadUrl = async ({
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 filePath =
@ -103,21 +109,20 @@ export const handleGenerateUploadUrl = async ({
}/typebots/${typebotId}/results/${resultId}/blocks/${blockId}/${fileName}`
: `public/tmp/typebots/${typebotId}/blocks/${blockId}/${fileName}`;
const presignedPostPolicy = await generatePresignedPostPolicy({
const { presignedUrl, fileUrl: defaultFileUrl, fileType: resolvedFileType, maxFileSize: maxFileSizeMB } = await generatePresignedPutUrl({
fileType,
filePath,
maxFileSize,
});
return {
presignedUrl: presignedPostPolicy.postURL,
formData: presignedPostPolicy.formData,
presignedUrl,
fileType: resolvedFileType,
maxFileSize: maxFileSizeMB,
fileUrl:
visibility === "Private" && !isPreview
? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/blocks/${blockId}/${fileName}`
: env.S3_PUBLIC_CUSTOM_DOMAIN
? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}`
: `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`,
: defaultFileUrl,
};
};

View File

@ -54,7 +54,8 @@ export const fileUploadBuilderRouter = {
.output(
z.object({
presignedUrl: z.string(),
formData: z.record(z.string(), z.any()),
fileType: z.string().optional(),
maxFileSize: z.number().optional(),
fileUrl: z.string(),
}),
)
@ -74,7 +75,8 @@ export const fileUploadViewerRouter = {
.output(
z.object({
presignedUrl: z.string(),
formData: z.record(z.string(), z.any()),
fileType: z.string().optional(),
maxFileSize: z.number().optional(),
fileUrl: z.string(),
}),
)
@ -92,7 +94,8 @@ export const fileUploadViewerRouter = {
.output(
z.object({
presignedUrl: z.string(),
formData: z.record(z.string(), z.any()),
fileType: z.string().optional(),
maxFileSize: z.number().optional(),
fileUrl: z.string(),
}),
)
@ -110,7 +113,8 @@ export const fileUploadViewerRouter = {
.output(
z.object({
presignedUrl: z.string(),
formData: z.record(z.string(), z.any()),
fileType: z.string().optional(),
maxFileSize: z.number().optional(),
fileUrl: z.string(),
}),
)

View File

@ -101,8 +101,25 @@ export const validateAndParseInputMessage = (
const replyValue = message.type === "audio" ? message.url : message.text;
const urls = replyValue.split(", ");
const hasValidUrls = urls.some((url) =>
isURL(url, { require_tld: env.S3_ENDPOINT !== "localhost" }),
const isTrustedHost = (url: string) => {
try {
const { hostname } = new URL(url);
if (hostname === "localhost") return true;
if (
env.S3_PUBLIC_CUSTOM_DOMAIN &&
new URL(env.S3_PUBLIC_CUSTOM_DOMAIN).hostname === hostname
)
return true;
if (env.NEXTAUTH_URL && new URL(env.NEXTAUTH_URL).hostname === hostname)
return true;
return false;
} catch {
return false;
}
};
const hasValidUrls = urls.some(
(url) =>
isURL(url, { require_tld: !isTrustedHost(url) }),
);
const allowedFileTypesMetadata =

View File

@ -1,6 +1,6 @@
{
"name": "@typebot.io/js",
"version": "0.9.24",
"version": "0.10.0",
"description": "Javascript library to display typebots on your website",
"license": "FSL-1.1-ALv2",
"type": "module",

View File

@ -30,7 +30,7 @@ export const TextInputAddFileButton = (props: Props) => {
<input
type="file"
id={photosUploadId}
accept="image/avif, image/*, video/*, capture=camera"
accept="image/avif, image/png, image/jpeg, image/gif, image/webp, image/bmp, image/tiff, video/*, capture=camera"
multiple
class="hidden"
onChange={(e) => {

View File

@ -33,8 +33,9 @@ export const uploadFiles = async ({
i += 1;
const { data, error } = await sendRequest<{
presignedUrl: string;
formData: Record<string, string>;
fileUrl: string;
fileType?: string;
maxFileSize?: number;
}>({
method: "POST",
url: `${apiHost}/api/v3/generate-upload-url`,
@ -42,6 +43,7 @@ export const uploadFiles = async ({
fileName: input.fileName,
sessionId: input.sessionId,
fileType: file.type,
fileSize: file.size,
blockId: input.blockId,
},
});
@ -53,14 +55,13 @@ export const uploadFiles = async ({
if (!data?.presignedUrl) continue;
const formData = new FormData();
Object.entries(data.formData).forEach(([key, value]) => {
formData.append(key, value);
});
formData.append("file", file);
const upload = await fetch(data.presignedUrl, {
method: "POST",
body: formData,
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
"Cache-Control": "public, max-age=86400",
},
});
if (!upload.ok) continue;

View File

@ -1,6 +1,6 @@
{
"name": "@typebot.io/react",
"version": "0.9.24",
"version": "0.10.0",
"description": "Convenient library to display typebots on your React app",
"license": "FSL-1.1-ALv2",
"type": "module",

View File

@ -0,0 +1,33 @@
import { env } from "@typebot.io/env";
import { initClient } from "./initClient";
type Props = {
filePath: string;
fileType?: string;
maxFileSize?: number;
};
const tenMinutes = 10 * 60;
export const generatePresignedPutUrl = async ({
filePath,
fileType,
maxFileSize,
}: Props) => {
const minioClient = initClient();
const presignedUrl = await minioClient.presignedPutObject(
env.S3_BUCKET,
filePath,
tenMinutes,
);
return {
presignedUrl,
fileUrl: env.S3_PUBLIC_CUSTOM_DOMAIN
? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}`
: `${presignedUrl.split("?")[0]}`,
fileType,
maxFileSize,
};
};

View File

@ -154,7 +154,7 @@ const SpaceIconPopover = ({
</Tabs.Panel>
<Tabs.Panel value="upload" className="flex justify-center py-4">
<UploadButton
accept="image/avif, image/*"
accept="image/avif, image/png, image/jpeg, image/gif, image/webp, image/bmp, image/tiff"
onFileUploadRequest={onFileUploadRequest}
onValueCommit={(icon) => { onValueCommit(icon); popoverControls.onClose(); }}
/>