fix: server side checks for file upload (#5566)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Dhruwang Jariwala
2025-04-30 21:54:54 +05:30
committed by GitHub
parent 20466c3800
commit 8bdb818995
25 changed files with 659 additions and 190 deletions

View File

@@ -1,6 +1,7 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { logger } from "@formbricks/logger";
@@ -11,6 +12,20 @@ export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
const handleDatabaseError = (error: Error, url: string, endpoint: string, responseId: string): Response => {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof DatabaseError) {
logger.error({ error, url }, `Error in ${endpoint}`);
return responses.internalServerErrorResponse(error.message, true);
}
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
export const PUT = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
@@ -23,7 +38,6 @@ export const PUT = async (
}
const responseUpdate = await request.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
@@ -39,19 +53,8 @@ export const PUT = async (
try {
response = await updateResponse(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: request.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return responses.internalServerErrorResponse(error.message);
}
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return handleDatabaseError(error, request.url, endpoint, responseId);
}
// get survey to get environmentId
@@ -59,16 +62,12 @@ export const PUT = async (
try {
survey = await getSurvey(response.surveyId);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: request.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return responses.internalServerErrorResponse(error.message);
}
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return handleDatabaseError(error, request.url, endpoint, responseId);
}
if (!validateFileUploads(response.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response", undefined, true);
}
// send response update to pipeline
@@ -87,7 +86,7 @@ export const PUT = async (
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: response,
response,
});
}
return responses.successResponse({}, true);

View File

@@ -1,6 +1,7 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -86,6 +87,10 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
let response: TResponse;
try {
const meta: TResponseInput["meta"] = {

View File

@@ -4,6 +4,7 @@
import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
import { validateLocalSignedUrl } from "@/lib/crypto";
import { validateFile } from "@/lib/fileValidation";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { putFileToLocalStorage } from "@/lib/storage/service";
import { getSurvey } from "@/lib/survey/service";
@@ -86,8 +87,14 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
const fileName = decodeURIComponent(encodedFileName);
// validate signature
// Perform server-side file validation again
// This is crucial as attackers could bypass the initial validation and directly call this endpoint
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType });
}
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,

View File

@@ -1,5 +1,6 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { validateFile } from "@/lib/fileValidation";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
@@ -28,7 +29,6 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
const environmentId = params.environmentId;
const jsonInput = await req.json();
const inputValidation = ZUploadFileRequest.safeParse({
...jsonInput,
environmentId,
@@ -44,6 +44,12 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
const { fileName, fileType, surveyId } = inputValidation.data;
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType }, true);
}
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),

View File

@@ -1,6 +1,7 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { validateFileUploads } from "@/lib/fileValidation";
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -26,7 +27,7 @@ async function fetchAndAuthorizeResponse(
return { error: responses.unauthorizedResponse() };
}
return { response };
return { response, survey };
}
export const GET = async (
@@ -86,6 +87,10 @@ export const PUT = async (
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
if (!validateFileUploads(responseUpdate.data, result.survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return responses.badRequestResponse(

View File

@@ -1,13 +1,14 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
export const GET = async (request: NextRequest) => {
@@ -47,72 +48,85 @@ export const GET = async (request: NextRequest) => {
}
};
export const POST = async (request: Request): Promise<Response> => {
const validateInput = async (request: Request) => {
let jsonInput;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
jsonInput = await request.json();
} catch (err) {
logger.error({ error: err, url: request.url }, "Error parsing JSON input");
return { error: responses.badRequestResponse("Malformed JSON input, please check your request body") };
}
let jsonInput;
try {
jsonInput = await request.json();
} catch (err) {
logger.error({ error: err, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
const inputValidation = ZResponseInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
const inputValidation = ZResponseInput.safeParse(jsonInput);
if (!inputValidation.success) {
return {
error: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
),
};
}
const responseInput = inputValidation.data;
return { data: inputValidation.data };
};
const environmentId = responseInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
// get and check survey
const survey = await getSurvey(responseInput.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
}
if (survey.environmentId !== environmentId) {
return responses.badRequestResponse(
const validateSurvey = async (responseInput: TResponseInput, environmentId: string) => {
const survey = await getSurvey(responseInput.surveyId);
if (!survey) {
return { error: responses.notFoundResponse("Survey", responseInput.surveyId, true) };
}
if (survey.environmentId !== environmentId) {
return {
error: responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
);
),
};
}
return { survey };
};
export const POST = async (request: Request): Promise<Response> => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const inputResult = await validateInput(request);
if (inputResult.error) return inputResult.error;
const responseInput = inputResult.data;
const environmentId = responseInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
const surveyResult = await validateSurvey(responseInput, environmentId);
if (surveyResult.error) return surveyResult.error;
if (!validateFileUploads(responseInput.data, surveyResult.survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
// if there is a createdAt but no updatedAt, set updatedAt to createdAt
if (responseInput.createdAt && !responseInput.updatedAt) {
responseInput.updatedAt = responseInput.createdAt;
}
let response: TResponse;
try {
response = await createResponse(inputValidation.data);
const response = await createResponse(responseInput);
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return responses.internalServerErrorResponse(error.message);
}
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return responses.internalServerErrorResponse(error.message);
}
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);

View File

@@ -5,6 +5,7 @@ import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
import { validateLocalSignedUrl } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { validateFile } from "@/lib/fileValidation";
import { putFileToLocalStorage } from "@/lib/storage/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
@@ -65,6 +66,12 @@ export const POST = async (req: NextRequest): Promise<Response> => {
const fileName = decodeURIComponent(encodedFileName);
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file");
}
// validate signature
const validated = validateLocalSignedUrl(

View File

@@ -1,5 +1,6 @@
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { validateFile } from "@/lib/fileValidation";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
@@ -36,8 +37,15 @@ export const POST = async (req: NextRequest): Promise<Response> => {
return responses.badRequestResponse("environmentId is required");
}
// Perform server-side file validation first to block dangerous file types
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file type");
}
// Also perform client-specified allowed file extensions validation if provided
if (allowedFileExtensions?.length) {
const fileExtension = fileName.split(".").pop();
const fileExtension = fileName.split(".").pop()?.toLowerCase();
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
return responses.badRequestResponse(
`File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}`

View File

@@ -0,0 +1,316 @@
import * as storageUtils from "@/lib/storage/utils";
import { describe, expect, test, vi } from "vitest";
import { ZAllowedFileExtension } from "@formbricks/types/common";
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import {
isAllowedFileExtension,
isValidFileTypeForExtension,
isValidImageFile,
validateFile,
validateFileUploads,
validateSingleFile,
} from "./fileValidation";
// Mock getOriginalFileNameFromUrl function
vi.mock("@/lib/storage/utils", () => ({
getOriginalFileNameFromUrl: vi.fn((url) => {
// Extract filename from the URL for testing purposes
const parts = url.split("/");
return parts[parts.length - 1];
}),
}));
describe("fileValidation", () => {
describe("isAllowedFileExtension", () => {
test("should return false for a file with no extension", () => {
expect(isAllowedFileExtension("filename")).toBe(false);
});
test("should return false for a file with extension not in allowed list", () => {
expect(isAllowedFileExtension("malicious.exe")).toBe(false);
expect(isAllowedFileExtension("script.php")).toBe(false);
expect(isAllowedFileExtension("config.js")).toBe(false);
expect(isAllowedFileExtension("page.html")).toBe(false);
});
test("should return true for an allowed file extension", () => {
Object.values(ZAllowedFileExtension.enum).forEach((ext) => {
expect(isAllowedFileExtension(`file.${ext}`)).toBe(true);
});
});
test("should handle case insensitivity correctly", () => {
expect(isAllowedFileExtension("image.PNG")).toBe(true);
expect(isAllowedFileExtension("document.PDF")).toBe(true);
});
test("should handle filenames with multiple dots", () => {
expect(isAllowedFileExtension("example.backup.pdf")).toBe(true);
expect(isAllowedFileExtension("document.old.exe")).toBe(false);
});
});
describe("isValidFileTypeForExtension", () => {
test("should return false for a file with no extension", () => {
expect(isValidFileTypeForExtension("filename", "application/octet-stream")).toBe(false);
});
test("should return true for valid extension and MIME type combinations", () => {
expect(isValidFileTypeForExtension("image.jpg", "image/jpeg")).toBe(true);
expect(isValidFileTypeForExtension("image.png", "image/png")).toBe(true);
expect(isValidFileTypeForExtension("document.pdf", "application/pdf")).toBe(true);
});
test("should return false for mismatched extension and MIME type", () => {
expect(isValidFileTypeForExtension("image.jpg", "image/png")).toBe(false);
expect(isValidFileTypeForExtension("document.pdf", "image/jpeg")).toBe(false);
expect(isValidFileTypeForExtension("image.png", "application/pdf")).toBe(false);
});
test("should handle case insensitivity correctly", () => {
expect(isValidFileTypeForExtension("image.JPG", "image/jpeg")).toBe(true);
expect(isValidFileTypeForExtension("image.jpg", "IMAGE/JPEG")).toBe(true);
});
});
describe("validateFile", () => {
test("should return valid: false when file extension is not allowed", () => {
const result = validateFile("script.php", "application/php");
expect(result.valid).toBe(false);
expect(result.error).toContain("File type not allowed");
});
test("should return valid: false when file type does not match extension", () => {
const result = validateFile("image.png", "application/pdf");
expect(result.valid).toBe(false);
expect(result.error).toContain("File type doesn't match");
});
test("should return valid: true when file is allowed and type matches extension", () => {
const result = validateFile("image.jpg", "image/jpeg");
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
test("should return valid: true for allowed file types", () => {
Object.values(ZAllowedFileExtension.enum).forEach((ext) => {
// Skip testing extensions that don't have defined MIME types in the test
if (["jpg", "png", "pdf"].includes(ext)) {
const mimeType = ext === "jpg" ? "image/jpeg" : ext === "png" ? "image/png" : "application/pdf";
const result = validateFile(`file.${ext}`, mimeType);
expect(result.valid).toBe(true);
}
});
});
test("should return valid: false for files with no extension", () => {
const result = validateFile("noextension", "application/octet-stream");
expect(result.valid).toBe(false);
});
test("should handle attempts to bypass with double extension", () => {
const result = validateFile("malicious.jpg.php", "image/jpeg");
expect(result.valid).toBe(false);
});
});
describe("validateSingleFile", () => {
test("should return true for allowed file extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("image.jpg");
expect(validateSingleFile("https://example.com/image.jpg", ["jpg", "png"])).toBe(true);
});
test("should return false for disallowed file extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("malicious.exe");
expect(validateSingleFile("https://example.com/malicious.exe", ["jpg", "png"])).toBe(false);
});
test("should return true when no allowed extensions are specified", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("image.jpg");
expect(validateSingleFile("https://example.com/image.jpg")).toBe(true);
});
test("should return false when file name cannot be extracted", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce(undefined);
expect(validateSingleFile("https://example.com/unknown")).toBe(false);
});
test("should return false when file has no extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("filewithoutextension");
expect(validateSingleFile("https://example.com/filewithoutextension", ["jpg"])).toBe(false);
});
});
describe("validateFileUploads", () => {
test("should return true for valid file uploads in response data", () => {
const responseData = {
question1: ["https://example.com/storage/file1.jpg", "https://example.com/storage/file2.pdf"],
};
const questions = [
{
id: "question1",
type: "fileUpload" as const,
allowedFileExtensions: ["jpg", "pdf"],
} as TSurveyQuestion,
];
expect(validateFileUploads(responseData, questions)).toBe(true);
});
test("should return false when file url is not a string", () => {
const responseData = {
question1: [123, "https://example.com/storage/file.jpg"],
} as TResponseData;
const questions = [
{
id: "question1",
type: "fileUpload" as const,
allowedFileExtensions: ["jpg"],
} as TSurveyQuestion,
];
expect(validateFileUploads(responseData, questions)).toBe(false);
});
test("should return false when file urls are not in an array", () => {
const responseData = {
question1: "https://example.com/storage/file.jpg",
};
const questions = [
{
id: "question1",
type: "fileUpload" as const,
allowedFileExtensions: ["jpg"],
} as TSurveyQuestion,
];
expect(validateFileUploads(responseData, questions)).toBe(false);
});
test("should return false when file extension is not allowed", () => {
const responseData = {
question1: ["https://example.com/storage/file.exe"],
};
const questions = [
{
id: "question1",
type: "fileUpload" as const,
allowedFileExtensions: ["jpg", "pdf"],
} as TSurveyQuestion,
];
expect(validateFileUploads(responseData, questions)).toBe(false);
});
test("should return false when file name cannot be extracted", () => {
// Mock implementation to return null for this specific URL
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => undefined);
const responseData = {
question1: ["https://example.com/invalid-url"],
};
const questions = [
{
id: "question1",
type: "fileUpload" as const,
allowedFileExtensions: ["jpg"],
} as TSurveyQuestion,
];
expect(validateFileUploads(responseData, questions)).toBe(false);
});
test("should return false when file has no extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(
() => "file-without-extension"
);
const responseData = {
question1: ["https://example.com/storage/file-without-extension"],
};
const questions = [
{
id: "question1",
type: "fileUpload" as const,
allowedFileExtensions: ["jpg"],
} as TSurveyQuestion,
];
expect(validateFileUploads(responseData, questions)).toBe(false);
});
test("should ignore non-fileUpload questions", () => {
const responseData = {
question1: ["https://example.com/storage/file.jpg"],
question2: "Some text answer",
};
const questions = [
{
id: "question1",
type: "fileUpload" as const,
allowedFileExtensions: ["jpg"],
},
{
id: "question2",
type: "text" as const,
},
] as TSurveyQuestion[];
expect(validateFileUploads(responseData, questions)).toBe(true);
});
test("should return true when no questions are provided", () => {
const responseData = {
question1: ["https://example.com/storage/file.jpg"],
};
expect(validateFileUploads(responseData)).toBe(true);
});
});
describe("isValidImageFile", () => {
test("should return true for valid image file extensions", () => {
expect(isValidImageFile("https://example.com/image.jpg")).toBe(true);
expect(isValidImageFile("https://example.com/image.jpeg")).toBe(true);
expect(isValidImageFile("https://example.com/image.png")).toBe(true);
expect(isValidImageFile("https://example.com/image.webp")).toBe(true);
expect(isValidImageFile("https://example.com/image.heic")).toBe(true);
});
test("should return false for non-image file extensions", () => {
expect(isValidImageFile("https://example.com/document.pdf")).toBe(false);
expect(isValidImageFile("https://example.com/document.docx")).toBe(false);
expect(isValidImageFile("https://example.com/document.txt")).toBe(false);
});
test("should return false when file name cannot be extracted", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => undefined);
expect(isValidImageFile("https://example.com/invalid-url")).toBe(false);
});
test("should return false when file has no extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(
() => "image-without-extension"
);
expect(isValidImageFile("https://example.com/image-without-extension")).toBe(false);
});
test("should return false when file name ends with a dot", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => "image.");
expect(isValidImageFile("https://example.com/image.")).toBe(false);
});
test("should handle case insensitivity correctly", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => "image.JPG");
expect(isValidImageFile("https://example.com/image.JPG")).toBe(true);
});
});
});

View File

@@ -0,0 +1,94 @@
import { getOriginalFileNameFromUrl } from "@/lib/storage/utils";
import { TAllowedFileExtension, ZAllowedFileExtension, mimeTypes } from "@formbricks/types/common";
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
/**
* 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;
// Check if the extension is in the allowed list
return Object.values(ZAllowedFileExtension.enum).includes(extension as TAllowedFileExtension);
};
/**
* Validates if the file type matches the extension
* @param fileName The name of the file
* @param mimeType The MIME type of the file
* @returns {boolean} True if the file type matches the extension, false otherwise
*/
export const isValidFileTypeForExtension = (fileName: string, mimeType: string): boolean => {
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension || extension === fileName.toLowerCase()) return false;
// Basic MIME type validation for common file types
const mimeTypeLower = mimeType.toLowerCase();
// Check if the MIME type matches the expected type for this extension
return mimeTypes[extension] === mimeTypeLower;
};
/**
* Validates a file for security concerns
* @param fileName The name of the file to validate
* @param mimeType The MIME type of the file
* @returns {object} An object with validation result and error message if any
*/
export const validateFile = (fileName: string, mimeType: string): { valid: boolean; error?: string } => {
// Check for disallowed extensions
if (!isAllowedFileExtension(fileName)) {
return { valid: false, error: "File type not allowed for security reasons." };
}
// Check if the file type matches the extension
if (!isValidFileTypeForExtension(fileName, mimeType)) {
return { valid: false, error: "File type doesn't match the file extension." };
}
return { valid: true };
};
export const validateSingleFile = (
fileUrl: string,
allowedFileExtensions?: TAllowedFileExtension[]
): boolean => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
if (!fileName) return false;
const extension = fileName.split(".").pop();
if (!extension) return false;
return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension);
};
export const validateFileUploads = (data: TResponseData, questions?: TSurveyQuestion[]): boolean => {
for (const key of Object.keys(data)) {
const question = questions?.find((q) => q.id === key);
if (!question || question.type !== TSurveyQuestionTypeEnum.FileUpload) continue;
const fileUrls = data[key];
if (!Array.isArray(fileUrls) || !fileUrls.every((url) => typeof url === "string")) return false;
for (const fileUrl of fileUrls) {
if (!validateSingleFile(fileUrl, question.allowedFileExtensions)) return false;
}
}
return true;
};
export const isValidImageFile = (fileUrl: string): boolean => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
if (!fileName || fileName.endsWith(".")) return false;
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension) return false;
const imageExtensions = ["png", "jpeg", "jpg", "webp", "heic"];
return imageExtensions.includes(extension);
};

View File

@@ -18,7 +18,7 @@ import { ITEMS_PER_PAGE } from "../constants";
import { capturePosthogEnvironmentEvent } from "../posthogServer";
import { validateInputs } from "../utils/validate";
import { surveyCache } from "./cache";
import { transformPrismaSurvey } from "./utils";
import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
interface TriggerUpdate {
create?: Array<{ actionClassId: string }>;
@@ -337,6 +337,8 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
updatedSurvey;
checkForInvalidImagesInQuestions(questions);
if (languages) {
// Process languages update logic here
// Extract currentLanguageIds and updatedLanguageIds
@@ -678,6 +680,10 @@ export const createSurvey = async (
delete data.followUps;
}
if (data.questions) {
checkForInvalidImagesInQuestions(data.questions);
}
const survey = await prisma.survey.create({
data: {
...data,

View File

@@ -1,7 +1,9 @@
import "server-only";
import { isValidImageFile } from "@/lib/fileValidation";
import { InvalidInputError } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSurvey>(
surveyPrisma: any
@@ -32,3 +34,25 @@ export const anySurveyHasFilters = (surveys: TSurvey[]): boolean => {
return false;
});
};
export const checkForInvalidImagesInQuestions = (questions: TSurveyQuestion[]) => {
questions.forEach((question, qIndex) => {
if (question.imageUrl && !isValidImageFile(question.imageUrl)) {
throw new InvalidInputError(`Invalid image file in question ${String(qIndex + 1)}`);
}
if (question.type === TSurveyQuestionTypeEnum.PictureSelection) {
if (!Array.isArray(question.choices)) {
throw new InvalidInputError(`Choices missing for question ${String(qIndex + 1)}`);
}
question.choices.forEach((choice, cIndex) => {
if (!isValidImageFile(choice.imageUrl)) {
throw new InvalidInputError(
`Invalid image file for choice ${String(cIndex + 1)} in question ${String(qIndex + 1)}`
);
}
});
}
});
};

View File

@@ -1,5 +1,6 @@
import "server-only";
import { cache } from "@/lib/cache";
import { isValidImageFile } from "@/lib/fileValidation";
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
@@ -7,7 +8,7 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbricks/types/user";
import { validateInputs } from "../utils/validate";
import { userCache } from "./cache";
@@ -97,6 +98,7 @@ export const getUserByEmail = reactCache(
// function to update a user's user
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
validateInputs([personId, ZId], [data, ZUserUpdateInput.partial()]);
if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file");
try {
const updatedUser = await prisma.user.update({

View File

@@ -1,3 +1,4 @@
import { validateFileUploads } from "@/lib/fileValidation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
@@ -7,6 +8,7 @@ import {
getResponse,
updateResponse,
} from "@/modules/api/v2/management/responses/[responseId]/lib/response";
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { z } from "zod";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
@@ -115,6 +117,25 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
});
}
const existingResponse = await getResponse(params.responseId);
if (!existingResponse.ok) {
return handleApiError(request, existingResponse.error);
}
const questionsResponse = await getSurveyQuestions(existingResponse.data.surveyId);
if (!questionsResponse.ok) {
return handleApiError(request, questionsResponse.error);
}
if (!validateFileUploads(body.data, questionsResponse.data.questions)) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "response", issue: "Invalid file upload response" }],
});
}
const response = await updateResponse(params.responseId, body);
if (!response.ok) {

View File

@@ -1,7 +1,9 @@
import { validateFileUploads } from "@/lib/fileValidation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { Response } from "@prisma/client";
@@ -76,6 +78,18 @@ export const POST = async (request: Request) =>
body.updatedAt = body.createdAt;
}
const surveyQuestions = await getSurveyQuestions(body.surveyId);
if (!surveyQuestions.ok) {
return handleApiError(request, surveyQuestions.error);
}
if (!validateFileUploads(body.data, surveyQuestions.data.questions)) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "response", issue: "Invalid file upload response" }],
});
}
const createResponseResult = await createResponse(environmentId, body);
if (!createResponseResult.ok) {
return handleApiError(request, createResponseResult.error);

View File

@@ -1,4 +1,5 @@
import { cache } from "@/lib/cache";
import { isValidImageFile } from "@/lib/fileValidation";
import { userCache } from "@/lib/user/cache";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
@@ -12,6 +13,10 @@ import { TUserCreateInput, TUserUpdateInput, ZUserEmail, ZUserUpdateInput } from
export const updateUser = async (id: string, data: TUserUpdateInput) => {
validateInputs([id, ZId], [data, ZUserUpdateInput.partial()]);
if (data.imageUrl && !isValidImageFile(data.imageUrl)) {
throw new InvalidInputError("Invalid image file");
}
try {
const updatedUser = await prisma.user.update({
where: {

View File

@@ -1,6 +1,7 @@
import { segmentCache } from "@/lib/cache/segment";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { surveyCache } from "@/lib/survey/cache";
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
import { subscribeOrganizationMembersToSurveyResponses } from "@/modules/survey/components/template-list/lib/organization";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
@@ -63,6 +64,8 @@ export const createSurvey = async (
delete data.followUps;
}
if (data.questions) checkForInvalidImagesInQuestions(data.questions);
const survey = await prisma.survey.create({
data: {
...data,

View File

@@ -1,12 +1,15 @@
import { isValidImageFile } from "@/lib/fileValidation";
import { userCache } from "@/lib/user/cache";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser, TUserUpdateInput } from "@formbricks/types/user";
// function to update a user's user
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file");
try {
const updatedUser = await prisma.user.update({
where: {

View File

@@ -1,5 +1,6 @@
import { segmentCache } from "@/lib/cache/segment";
import { surveyCache } from "@/lib/survey/cache";
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
@@ -26,6 +27,8 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
updatedSurvey;
checkForInvalidImagesInQuestions(questions);
if (languages) {
// Process languages update logic here
// Extract currentLanguageIds and updatedLanguageIds

View File

@@ -5,6 +5,7 @@ import { segmentCache } from "@/lib/cache/segment";
import { projectCache } from "@/lib/project/cache";
import { responseCache } from "@/lib/response/cache";
import { surveyCache } from "@/lib/survey/cache";
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils";
import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment";
@@ -528,6 +529,9 @@ export const copySurveyToOtherEnvironment = async (
}
const targetProjectLanguageCodes = targetProject.languages.map((language) => language.code);
if (surveyData.questions) checkForInvalidImagesInQuestions(surveyData.questions);
const newSurvey = await prisma.survey.create({
data: surveyData,
select: {

View File

@@ -1,84 +0,0 @@
import { segmentCache } from "@/lib/cache/segment";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { surveyCache } from "@/lib/survey/cache";
import { Prisma, Survey } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
export const createSurvey = async (
environmentId: string,
surveyBody: Pick<Survey, "name" | "questions">
): Promise<{ id: string }> => {
try {
const survey = await prisma.survey.create({
data: {
...surveyBody,
environment: {
connect: {
id: environmentId,
},
},
},
select: {
id: true,
type: true,
environmentId: true,
resultShareKey: true,
},
});
// if the survey created is an "app" survey, we also create a private segment for it.
if (survey.type === "app") {
const newSegment = await prisma.segment.create({
data: {
title: survey.id,
filters: [],
isPrivate: true,
environment: {
connect: {
id: environmentId,
},
},
},
});
await prisma.survey.update({
where: {
id: survey.id,
},
data: {
segment: {
connect: {
id: newSegment.id,
},
},
},
});
segmentCache.revalidate({
id: newSegment.id,
environmentId: survey.environmentId,
});
}
surveyCache.revalidate({
id: survey.id,
environmentId: survey.environmentId,
resultShareKey: survey.resultShareKey ?? undefined,
});
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
surveyId: survey.id,
surveyType: survey.type,
});
return { id: survey.id };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error creating survey");
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -155,11 +155,13 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect(
page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("button").nth(0)
).toBeVisible();
await page.locator("input[type=file]").setInputFiles({
name: "file.txt",
mimeType: "text/plain",
name: "file.doc",
mimeType: "application/msword",
buffer: Buffer.from("this is test"),
});
await page.getByText("Uploading...").waitFor({ state: "hidden" });
await page.locator("#questionCard-8").getByRole("button", { name: "Next" }).click();
@@ -842,8 +844,8 @@ test.describe("Testing Survey with advanced logic", async () => {
page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("button").nth(0)
).toBeVisible();
await page.locator("input[type=file]").setInputFiles({
name: "file.txt",
mimeType: "text/plain",
name: "file.doc",
mimeType: "application/msword",
buffer: Buffer.from("this is test"),
});
await page.getByText("Uploading...").waitFor({ state: "hidden" });

View File

@@ -109,6 +109,11 @@ export default defineConfig({
"lib/utils/billing.ts",
"lib/crypto.ts",
"lib/surveyLogic/utils.ts",
"lib/utils/billing.ts",
"modules/ui/components/card/index.tsx",
"lib/fileValidation.ts",
"survey/editor/lib/utils.tsx",
"modules/ui/components/card/index.tsx",
"modules/ui/components/card/index.tsx",
],
exclude: [

View File

@@ -1,5 +1,5 @@
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
import { TAllowedFileExtension } from "@formbricks/types/common";
import { TAllowedFileExtension, mimeTypes } from "@formbricks/types/common";
import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
@@ -160,31 +160,5 @@ export const getDefaultLanguageCode = (survey: TJsEnvironmentStateSurvey): strin
if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
};
const mimeTypes: { [key in TAllowedFileExtension]: string } = {
heic: "image/heic",
png: "image/png",
jpeg: "image/jpeg",
jpg: "image/jpeg",
webp: "image/webp",
pdf: "application/pdf",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
ppt: "application/vnd.ms-powerpoint",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
plain: "text/plain",
csv: "text/csv",
mp4: "video/mp4",
mov: "video/quicktime",
avi: "video/x-msvideo",
mkv: "video/x-matroska",
webm: "video/webm",
zip: "application/zip",
rar: "application/vnd.rar",
"7z": "application/x-7z-compressed",
tar: "application/x-tar",
};
// Function to convert file extension to its MIME type
export const getMimeType = (extension: TAllowedFileExtension): string => mimeTypes[extension];

View File

@@ -44,6 +44,32 @@ export const ZAllowedFileExtension = z.enum([
"tar",
]);
export const mimeTypes: Record<TAllowedFileExtension, string> = {
heic: "image/heic",
png: "image/png",
jpeg: "image/jpeg",
jpg: "image/jpeg",
webp: "image/webp",
pdf: "application/pdf",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
ppt: "application/vnd.ms-powerpoint",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
plain: "text/plain",
csv: "text/csv",
mp4: "video/mp4",
mov: "video/quicktime",
avi: "video/x-msvideo",
mkv: "video/x-matroska",
webm: "video/webm",
zip: "application/zip",
rar: "application/vnd.rar",
"7z": "application/x-7z-compressed",
tar: "application/x-tar",
};
export type TAllowedFileExtension = z.infer<typeof ZAllowedFileExtension>;
export const ZId = z.string().cuid2();