mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-20 11:38:38 -05:00
fix: harden storage presigned URL issuance (#8021)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
committed by
GitHub
parent
1032702b65
commit
e2bf79ce6c
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user