diff --git a/apps/web/app/api/v1/client/[workspaceId]/storage/route.ts b/apps/web/app/api/v1/client/[workspaceId]/storage/route.ts index 2a143105bd..8423dc7aba 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/storage/route.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/storage/route.ts @@ -12,7 +12,7 @@ import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils"; import { getSignedUrlForUpload } from "@/modules/storage/service"; -import { getErrorResponseFromStorageError } from "@/modules/storage/utils"; +import { getErrorResponseFromStorageError, validateSurveyAllowsFileUpload } from "@/modules/storage/utils"; export const OPTIONS = async (): Promise => { return responses.successResponse( @@ -107,6 +107,23 @@ export const POST = withV1ApiWrapper({ }; } + const fileUploadPermission = validateSurveyAllowsFileUpload({ + fileName, + blocks: survey.blocks, + questions: survey.questions, + }); + + if (!fileUploadPermission.ok) { + return { + response: responses.badRequestResponse( + fileUploadPermission.reason === "no_file_upload_question" + ? "Survey does not allow file uploads" + : "File extension is not allowed for this survey", + undefined + ), + }; + } + const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.id); const maxFileUploadSize = isBiggerFileUploadAllowed ? MAX_FILE_UPLOAD_SIZES.big diff --git a/apps/web/modules/storage/utils.test.ts b/apps/web/modules/storage/utils.test.ts index 63afe24ace..7b3fc17fde 100644 --- a/apps/web/modules/storage/utils.test.ts +++ b/apps/web/modules/storage/utils.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { StorageErrorCode } from "@formbricks/storage"; import { TResponseData } from "@formbricks/types/responses"; import { ZAllowedFileExtension } from "@formbricks/types/storage"; +import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; import { TSurveyQuestion } from "@formbricks/types/surveys/types"; import { isAllowedFileExtension, @@ -12,6 +13,7 @@ import { sanitizeFileName, validateFileUploads, validateSingleFile, + validateSurveyAllowsFileUpload, } from "@/modules/storage/utils"; // Mock the getOriginalFileNameFromUrl function @@ -351,6 +353,148 @@ describe("storage utils", () => { }); }); + describe("validateSurveyAllowsFileUpload", () => { + test("should allow a matching extension from a modern file upload block element", () => { + const blocks = [ + { + id: "block1", + name: "Block 1", + elements: [ + { + id: "element1", + type: "fileUpload" as const, + allowedFileExtensions: ["pdf"], + }, + ], + }, + ] as unknown as TSurveyBlock[]; + + expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true }); + }); + + test("should allow a matching extension from a legacy file upload question", () => { + const questions = [ + { + id: "question1", + type: "fileUpload" as const, + allowedFileExtensions: ["png"], + }, + ] as TSurveyQuestion[]; + + expect(validateSurveyAllowsFileUpload({ fileName: "image.png", questions })).toEqual({ ok: true }); + }); + + test("should allow any globally safe extension when a file upload has no survey restriction", () => { + const blocks = [ + { + id: "block1", + name: "Block 1", + elements: [ + { + id: "element1", + type: "fileUpload" as const, + }, + ], + }, + ] as unknown as TSurveyBlock[]; + + expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true }); + }); + + test("should reject surveys without file upload blocks or questions", () => { + const blocks = [ + { + id: "block1", + name: "Block 1", + elements: [ + { + id: "element1", + type: "openText" as const, + }, + ], + }, + ] as unknown as TSurveyBlock[]; + const questions = [ + { + id: "question1", + type: "openText" as const, + }, + ] as TSurveyQuestion[]; + + expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks, questions })).toEqual({ + ok: false, + reason: "no_file_upload_question", + }); + }); + + test("should reject when no file upload entry allows the requested extension", () => { + const blocks = [ + { + id: "block1", + name: "Block 1", + elements: [ + { + id: "element1", + type: "fileUpload" as const, + allowedFileExtensions: ["jpg"], + }, + { + id: "element2", + type: "fileUpload" as const, + allowedFileExtensions: ["png"], + }, + ], + }, + ] as unknown as TSurveyBlock[]; + + expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ + ok: false, + reason: "file_extension_not_allowed", + }); + }); + + test("should allow when any file upload entry permits the requested extension", () => { + const blocks = [ + { + id: "block1", + name: "Block 1", + elements: [ + { + id: "element1", + type: "fileUpload" as const, + allowedFileExtensions: ["jpg"], + }, + { + id: "element2", + type: "fileUpload" as const, + allowedFileExtensions: ["pdf"], + }, + ], + }, + ] as unknown as TSurveyBlock[]; + + expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true }); + }); + + test("should reject files without a globally safe extension even when the survey has an unrestricted upload", () => { + const questions = [ + { + id: "question1", + type: "fileUpload" as const, + }, + ] as TSurveyQuestion[]; + + expect(validateSurveyAllowsFileUpload({ fileName: "report", questions })).toEqual({ + ok: false, + reason: "file_extension_not_allowed", + }); + expect(validateSurveyAllowsFileUpload({ fileName: "malware.exe", questions })).toEqual({ + ok: false, + reason: "file_extension_not_allowed", + }); + }); + }); + describe("isValidImageFile", () => { test("should return true for valid image file extensions", () => { expect(isValidImageFile("https://example.com/image.jpg")).toBe(true); diff --git a/apps/web/modules/storage/utils.ts b/apps/web/modules/storage/utils.ts index 8bbaadd480..8cdffef4e1 100644 --- a/apps/web/modules/storage/utils.ts +++ b/apps/web/modules/storage/utils.ts @@ -2,6 +2,8 @@ import "server-only"; import { type StorageError, StorageErrorCode } from "@formbricks/storage"; import { TResponseData } from "@formbricks/types/responses"; import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage"; +import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; +import { TSurveyElementTypeEnum, TSurveyFileUploadElement } from "@formbricks/types/surveys/elements"; import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { responses } from "@/app/lib/api/response"; import { WEBAPP_URL } from "@/lib/constants"; @@ -57,15 +59,27 @@ export const sanitizeFileName = (rawFileName: string): string => { return result; }; +/** + * Extracts the lowercase file extension from a file name + * @param fileName The name of the file + * @returns {string | null} The lowercase extension, or null when no extension exists + */ +const extractFileExtension = (fileName: string): string | null => { + const extension = fileName.split(".").pop()?.toLowerCase(); + + if (!extension || extension === fileName.toLowerCase()) return null; + + return extension; +}; + /** * Validates if the file extension is allowed * @param fileName The name of the file to validate * @returns {boolean} True if the file extension is allowed, false otherwise */ export const isAllowedFileExtension = (fileName: string): boolean => { - // Extract the file extension - const extension = fileName.split(".").pop()?.toLowerCase(); - if (!extension || extension === fileName.toLowerCase()) return false; + const extension = extractFileExtension(fileName); + if (!extension) return false; // Check if the extension is in the allowed list return Object.values(ZAllowedFileExtension.enum).includes(extension as TAllowedFileExtension); @@ -77,7 +91,7 @@ export const validateSingleFile = ( ): boolean => { const fileName = getOriginalFileNameFromUrl(fileUrl); if (!fileName) return false; - const extension = fileName.split(".").pop()?.toLowerCase(); + const extension = extractFileExtension(fileName); if (!extension) return false; return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension); }; @@ -100,6 +114,70 @@ export const validateFileUploads = (data?: TResponseData, questions?: TSurveyQue return true; }; +export type TSurveyFileUploadPermissionResult = + | { + ok: true; + } + | { + ok: false; + reason: "no_file_upload_question" | "file_extension_not_allowed"; + }; + +const getAllowedFileExtensionFromFileName = (fileName: string): TAllowedFileExtension | null => { + const extension = extractFileExtension(fileName); + if (!extension) return null; + + const extensionValidation = ZAllowedFileExtension.safeParse(extension); + + return extensionValidation.success ? extensionValidation.data : null; +}; + +export const validateSurveyAllowsFileUpload = ({ + fileName, + blocks, + questions, +}: { + fileName: string; + blocks?: TSurveyBlock[] | null; + questions?: TSurveyQuestion[] | null; +}): TSurveyFileUploadPermissionResult => { + const fileUploadConfigs = [ + ...(blocks ?? []) + .flatMap((block) => block.elements) + .filter((element) => element.type === TSurveyElementTypeEnum.FileUpload), + ...(questions ?? []).filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload), + ] as TSurveyFileUploadElement[]; + + if (fileUploadConfigs.length === 0) { + return { + ok: false, + reason: "no_file_upload_question", + }; + } + + const fileExtension = getAllowedFileExtensionFromFileName(fileName); + + if (!fileExtension) { + return { + ok: false, + reason: "file_extension_not_allowed", + }; + } + + const isFileExtensionAllowed = fileUploadConfigs.some((fileUploadConfig) => { + const { allowedFileExtensions } = fileUploadConfig; + + return allowedFileExtensions === undefined || allowedFileExtensions.includes(fileExtension); + }); + + return isFileExtensionAllowed + ? { ok: true } + : { + ok: false, + reason: "file_extension_not_allowed", + }; +}; + export const isValidImageFile = (fileUrl: string): boolean => { const fileName = getOriginalFileNameFromUrl(fileUrl); if (!fileName || fileName.endsWith(".")) return false;