fix: harden storage presigned URL issuance (#8021)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Bhagya Amarasinghe
2026-05-18 17:11:25 +05:30
committed by GitHub
parent 1032702b65
commit e2bf79ce6c
3 changed files with 244 additions and 5 deletions
@@ -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<Response> => {
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
+144
View File
@@ -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);
+82 -4
View File
@@ -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;