Compare commits

...

2 Commits

Author SHA1 Message Date
Bhagya Amarasinghe a7f83c42a4 fix: harden scoped storage paths 2026-05-19 12:06:26 +05:30
Bhagya Amarasinghe 9e1a8e96c8 fix: bind storage uploads to survey questions 2026-05-19 00:22:25 +05:30
22 changed files with 567 additions and 75 deletions
@@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({
loggerError: vi.fn(),
sendToPipeline: vi.fn(),
updateResponseWithQuotaEvaluation: vi.fn(),
validateFileUploads: vi.fn(),
validateClientFileUploads: vi.fn(),
validateOtherOptionLengthForMultipleChoice: vi.fn(),
validateResponseData: vi.fn(),
}));
@@ -44,7 +44,7 @@ vi.mock("@/modules/api/v2/lib/element", () => ({
}));
vi.mock("@/modules/storage/utils", () => ({
validateFileUploads: mocks.validateFileUploads,
validateClientFileUploads: mocks.validateClientFileUploads,
}));
vi.mock("./response", () => ({
@@ -124,7 +124,7 @@ describe("putResponseHandler", () => {
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateClientFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
mocks.validateResponseData.mockReturnValue(null);
});
@@ -278,7 +278,7 @@ describe("putResponseHandler", () => {
});
test("rejects invalid file upload updates", async () => {
mocks.validateFileUploads.mockReturnValue(false);
mocks.validateClientFileUploads.mockReturnValue(false);
const result = await putResponseHandler(createHandlerParams());
@@ -11,7 +11,7 @@ import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { validateClientFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./response";
import { getValidatedResponseUpdateInput } from "./validated-response-update-input";
@@ -126,7 +126,8 @@ const getSurveyForResponse = async (
const validateUpdateRequest = (
existingResponse: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
responseUpdateInput: TResponseUpdateInput,
workspaceId: string
): TRouteResult | undefined => {
if (existingResponse.finished) {
return {
@@ -134,7 +135,15 @@ const validateUpdateRequest = (
};
}
if (!validateFileUploads(responseUpdateInput.data, survey.questions)) {
if (
!validateClientFileUploads({
data: responseUpdateInput.data,
workspaceId,
surveyId: survey.id,
blocks: survey.blocks,
questions: survey.questions,
})
) {
return {
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
};
@@ -241,7 +250,7 @@ export const putResponseHandler = async ({
};
}
const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput);
const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput, workspaceId);
if (validationResult) {
return validationResult;
}
@@ -17,7 +17,7 @@ import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { validateClientFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => {
@@ -147,7 +147,15 @@ export const POST = withV1ApiWrapper({
responseInputData.singleUseId = singleUseValidationResult.singleUseId;
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
if (
!validateClientFileUploads({
data: responseInputData.data,
workspaceId,
surveyId: survey.id,
blocks: survey.blocks,
questions: survey.questions,
})
) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
};
@@ -66,7 +66,7 @@ export const POST = withV1ApiWrapper({
};
}
const { fileName, fileType, surveyId } = parsedInputResult.data;
const { fileName, fileType, surveyId, questionId } = parsedInputResult.data;
const [survey, organizationId] = await Promise.all([
getSurvey(surveyId),
@@ -109,6 +109,7 @@ export const POST = withV1ApiWrapper({
const fileUploadPermission = validateSurveyAllowsFileUpload({
fileName,
questionId,
blocks: survey.blocks,
questions: survey.questions,
});
@@ -118,7 +119,9 @@ export const POST = withV1ApiWrapper({
response: responses.badRequestResponse(
fileUploadPermission.reason === "no_file_upload_question"
? "Survey does not allow file uploads"
: "File extension is not allowed for this survey",
: fileUploadPermission.reason === "file_upload_question_not_found"
? "Question does not allow file uploads"
: "File extension is not allowed for this question",
undefined
),
};
@@ -134,7 +137,8 @@ export const POST = withV1ApiWrapper({
workspaceId,
fileType,
"private",
maxFileUploadSize
maxFileUploadSize,
["surveys", surveyId, "questions", questionId]
);
if (!signedUrlResponse.ok) {
@@ -15,6 +15,7 @@ import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateClientFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation } from "./lib/response";
import { TResponseInputV2, ZResponseInputV2 } from "./types/response";
@@ -89,6 +90,18 @@ const validateResponseSubmission = async (
return surveyCheckResult;
}
if (
!validateClientFileUploads({
data: responseInputData.data,
workspaceId,
surveyId: survey.id,
blocks: survey.blocks,
questions: survey.questions,
})
) {
return responses.badRequestResponse("Invalid file upload response", undefined, true);
}
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseInputData.data,
surveyQuestions: getElementsFromBlocks(survey.blocks),
@@ -4,7 +4,7 @@ import { logger } from "@formbricks/logger";
import { ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { authorizePrivateDownload } from "@/app/storage/[workspaceId]/[accessType]/[fileName]/lib/auth";
import { authorizePrivateDownload } from "@/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/auth";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
@@ -15,10 +15,14 @@ import { logFileDeletion } from "./lib/audit-logs";
export const GET = async (
request: NextRequest,
props: { params: Promise<{ workspaceId: string; accessType: string; fileName: string }> }
props: { params: Promise<{ workspaceId: string; accessType: string; filePath: string[] }> }
): Promise<Response> => {
const params = await props.params;
const paramValidation = ZDownloadFileRequest.safeParse(params);
const fileName = params.filePath.join("/");
const paramValidation = ZDownloadFileRequest.safeParse({
accessType: params.accessType,
fileName,
});
if (!paramValidation.success) {
return responses.badRequestResponse(
@@ -28,7 +32,7 @@ export const GET = async (
);
}
const { accessType, fileName } = paramValidation.data;
const { accessType } = paramValidation.data;
const idParam = params.workspaceId;
// Resolve: the URL param may be an environmentId (old uploads) or workspaceId (new uploads)
@@ -72,10 +76,14 @@ export const GET = async (
export const DELETE = async (
request: NextRequest,
props: { params: Promise<{ workspaceId: string; accessType: string; fileName: string }> }
props: { params: Promise<{ workspaceId: string; accessType: string; filePath: string[] }> }
): Promise<Response> => {
const params = await props.params;
const paramValidation = ZDeleteFileRequest.safeParse(params);
const fileName = params.filePath.join("/");
const paramValidation = ZDeleteFileRequest.safeParse({
accessType: params.accessType,
fileName,
});
if (!paramValidation.success) {
const errorDetails = transformErrorToDetails(paramValidation.error);
@@ -88,7 +96,7 @@ export const DELETE = async (
return responses.badRequestResponse("Fields are missing or incorrectly formatted", errorDetails, true);
}
const { accessType, fileName } = paramValidation.data;
const { accessType } = paramValidation.data;
const idParam = params.workspaceId;
// Resolve: the URL param may be an environmentId (old uploads) or workspaceId (new uploads)
@@ -140,6 +148,20 @@ export const DELETE = async (
);
if (!deleteResult.ok) {
if (!("error" in deleteResult)) {
logger.error({ deleteResult }, "Unknown delete failure result shape");
await logFileDeletion({
failureReason: "unknown_delete_failure",
accessType,
userId: session?.user?.id,
workspaceId: resolved.workspaceId,
apiUrl: request.url,
});
return responses.internalServerErrorResponse("Failed to delete file", true);
}
const { error } = deleteResult;
logger.error({ error }, "Error deleting file");
+10 -6
View File
@@ -22,7 +22,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { deleteFile } from "@/modules/storage/service";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { parseStorageFileUrl, resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getOrganizationIdFromWorkspaceId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { ITEMS_PER_PAGE } from "../constants";
@@ -589,14 +589,18 @@ const findAndDeleteUploadedFilesInResponse = async (response: TResponse, survey:
const deletionPromises = fileUrls.map(async (fileUrl) => {
try {
const { pathname } = new URL(fileUrl);
const [, storageId, accessType, fileName] = pathname.split("/").filter(Boolean);
const storageFile = parseStorageFileUrl(fileUrl);
if (!storageId || !accessType || !fileName) {
throw new Error(`Invalid file path: ${pathname}`);
if (!storageFile) {
throw new Error(`Invalid storage file URL: ${fileUrl}`);
}
return deleteFile(storageId, accessType as "private" | "public", fileName, survey.workspaceId);
return deleteFile(
storageFile.storageId,
storageFile.accessType,
storageFile.fileName,
survey.workspaceId
);
} catch (error) {
logger.error(error, `Failed to delete file ${fileUrl}`);
}
@@ -26,7 +26,7 @@ export const fileUploadQuestion: Survey["questions"][number] = {
export const responseData: Response["data"] = {
[openTextQuestion.id]: "Open Text Answer",
[fileUploadQuestion.id]: [
`https://example.com/dummy/${workspaceId}/private/file1.png`,
`https://example.com/dummy/${workspaceId}/private/file2.pdf`,
`https://example.com/storage/${workspaceId}/private/file1.png`,
`https://example.com/storage/${workspaceId}/private/file2.pdf`,
],
};
@@ -4,6 +4,7 @@ import { Result, okVoid } from "@formbricks/types/error-handlers";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { deleteFile } from "@/modules/storage/service";
import { parseStorageFileUrl } from "@/modules/storage/utils";
export const findAndDeleteUploadedFilesInResponse = async (
responseData: Response["data"],
@@ -24,13 +25,12 @@ export const findAndDeleteUploadedFilesInResponse = async (
const deletionPromises = fileUrls.map(async (fileUrl) => {
try {
const { pathname } = new URL(fileUrl);
const [, storageId, accessType, fileName] = pathname.split("/").filter(Boolean);
const storageFile = parseStorageFileUrl(fileUrl);
if (!storageId || !accessType || !fileName) {
throw new Error(`Invalid file path: ${pathname}`);
if (!storageFile) {
throw new Error(`Invalid storage file URL: ${fileUrl}`);
}
return deleteFile(storageId, accessType as "private" | "public", fileName, workspaceId);
return deleteFile(storageFile.storageId, storageFile.accessType, storageFile.fileName, workspaceId);
} catch (error) {
logger.error({ error, fileUrl }, "Failed to delete file");
}
+55
View File
@@ -117,6 +117,61 @@ describe("storage service", () => {
}
});
test("should generate scoped private upload URL when path segments are provided", async () => {
const mockSignedUrlResponse = {
ok: true,
data: {
signedUrl: "https://s3.example.com/upload",
presignedFields: { key: "value" },
},
} as MockedSignedUploadReturn;
vi.mocked(getSignedUploadUrl).mockResolvedValue(mockSignedUrlResponse);
const result = await getSignedUrlForUpload(
"test-doc.pdf",
"ws-123",
"application/pdf",
"private" as TAccessType,
1024 * 1024 * 10,
["surveys", "survey-123", "questions", "question-123"]
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.fileUrl).toBe(
`/storage/ws-123/private/surveys/survey-123/questions/question-123/test-doc--fid--${mockUUID}.pdf`
);
}
expect(getSignedUploadUrl).toHaveBeenCalledWith(
`test-doc--fid--${mockUUID}.pdf`,
"application/pdf",
"ws-123/private/surveys/survey-123/questions/question-123",
1024 * 1024 * 10
);
});
test.each(["", ".", "..", "bad segment", "bad/segment", "bad\\segment", "bad?segment", "bad#segment"])(
"should reject unsafe scoped private upload path segment %s",
async (unsafeSegment) => {
const result = await getSignedUrlForUpload(
"test-doc.pdf",
"ws-123",
"application/pdf",
"private" as TAccessType,
1024 * 1024 * 10,
["surveys", unsafeSegment]
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe(StorageErrorCode.InvalidInput);
}
expect(getSignedUploadUrl).not.toHaveBeenCalled();
}
);
test("should properly sanitize filenames with special characters like # in URL", async () => {
const mockSignedUrlResponse = {
ok: true,
+17 -9
View File
@@ -10,15 +10,18 @@ import {
getSignedUploadUrl,
} from "@formbricks/storage";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { TAccessType } from "@formbricks/types/storage";
import { type TAccessType } from "@formbricks/types/storage";
import { sanitizeFileName } from "./utils";
const SAFE_FILE_PATH_SEGMENT = /^[A-Za-z0-9_-]+$/;
export const getSignedUrlForUpload = async (
fileName: string,
workspaceId: string,
fileType: string,
accessType: TAccessType,
maxFileUploadSize: number = 1024 * 1024 * 10 // 10MB
maxFileUploadSize: number = 1024 * 1024 * 10, // 10MB
filePathSegments: string[] = []
): Promise<
Result<
{
@@ -34,17 +37,19 @@ export const getSignedUrlForUpload = async (
if (!safeFileName) {
return err({ code: StorageErrorCode.InvalidInput });
}
if (filePathSegments.some((segment) => !SAFE_FILE_PATH_SEGMENT.test(segment))) {
return err({ code: StorageErrorCode.InvalidInput });
}
const encodedFilePathSegments = filePathSegments.map((segment) => encodeURIComponent(segment));
const fileNameWithoutExtension = safeFileName.split(".").slice(0, -1).join(".");
const fileExtension = safeFileName.split(".").pop();
const updatedFileName = `${fileNameWithoutExtension}--fid--${randomUUID()}.${fileExtension}`;
const filePath = [workspaceId, accessType, ...filePathSegments].join("/");
const signedUrlResult = await getSignedUploadUrl(
updatedFileName,
fileType,
`${workspaceId}/${accessType}`,
maxFileUploadSize
);
const signedUrlResult = await getSignedUploadUrl(updatedFileName, fileType, filePath, maxFileUploadSize);
if (!signedUrlResult.ok) {
return signedUrlResult;
@@ -54,7 +59,10 @@ export const getSignedUrlForUpload = async (
return ok({
signedUrl: signedUrlResult.data.signedUrl,
presignedFields: signedUrlResult.data.presignedFields,
fileUrl: `/storage/${workspaceId}/${accessType}/${encodeURIComponent(updatedFileName)}`,
fileUrl: `/storage/${workspaceId}/${accessType}/${[
...encodedFilePathSegments,
encodeURIComponent(updatedFileName),
].join("/")}`,
});
} catch (error) {
logger.error({ error }, "Error getting signed url for upload");
+161 -8
View File
@@ -7,10 +7,12 @@ import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import {
isAllowedFileExtension,
isValidImageFile,
parseStorageFileUrl,
resolveStorageUrl,
resolveStorageUrlAuto,
resolveStorageUrlsInObject,
sanitizeFileName,
validateClientFileUploads,
validateFileUploads,
validateSingleFile,
validateSurveyAllowsFileUpload,
@@ -369,7 +371,11 @@ describe("storage utils", () => {
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
expect(
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element1", blocks })
).toEqual({
ok: true,
});
});
test("should allow a matching extension from a legacy file upload question", () => {
@@ -381,7 +387,9 @@ describe("storage utils", () => {
},
] as TSurveyQuestion[];
expect(validateSurveyAllowsFileUpload({ fileName: "image.png", questions })).toEqual({ ok: true });
expect(
validateSurveyAllowsFileUpload({ fileName: "image.png", questionId: "question1", questions })
).toEqual({ ok: true });
});
test("should allow any globally safe extension when a file upload has no survey restriction", () => {
@@ -398,7 +406,11 @@ describe("storage utils", () => {
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
expect(
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element1", blocks })
).toEqual({
ok: true,
});
});
test("should reject surveys without file upload blocks or questions", () => {
@@ -421,7 +433,9 @@ describe("storage utils", () => {
},
] as TSurveyQuestion[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks, questions })).toEqual({
expect(
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "question1", blocks, questions })
).toEqual({
ok: false,
reason: "no_file_upload_question",
});
@@ -447,7 +461,9 @@ describe("storage utils", () => {
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({
expect(
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element2", blocks })
).toEqual({
ok: false,
reason: "file_extension_not_allowed",
});
@@ -473,7 +489,11 @@ describe("storage utils", () => {
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
expect(
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element2", blocks })
).toEqual({
ok: true,
});
});
test("should reject files without a globally safe extension even when the survey has an unrestricted upload", () => {
@@ -484,15 +504,148 @@ describe("storage utils", () => {
},
] as TSurveyQuestion[];
expect(validateSurveyAllowsFileUpload({ fileName: "report", questions })).toEqual({
expect(
validateSurveyAllowsFileUpload({ fileName: "report", questionId: "question1", questions })
).toEqual({
ok: false,
reason: "file_extension_not_allowed",
});
expect(validateSurveyAllowsFileUpload({ fileName: "malware.exe", questions })).toEqual({
expect(
validateSurveyAllowsFileUpload({ fileName: "malware.exe", questionId: "question1", questions })
).toEqual({
ok: false,
reason: "file_extension_not_allowed",
});
});
test("should reject a question id that is not the file upload 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", questionId: "element2", blocks })
).toEqual({
ok: false,
reason: "file_upload_question_not_found",
});
});
});
describe("validateClientFileUploads", () => {
const workspaceId = "clxworkspace123";
const surveyId = "clxsurvey123";
const questionId = "file_question";
const blocks = [
{
id: "block1",
name: "Block 1",
elements: [
{
id: questionId,
type: "fileUpload" as const,
allowedFileExtensions: ["pdf"],
},
],
},
] as unknown as TSurveyBlock[];
test("should accept scoped private storage URLs for the matching survey and question", () => {
const responseData = {
[questionId]: [
`/storage/${workspaceId}/private/surveys/${surveyId}/questions/${questionId}/report--fid--abc.pdf`,
],
};
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(true);
});
test("should reject unscoped legacy storage URLs for new client submissions", () => {
const responseData = {
[questionId]: [`/storage/${workspaceId}/private/report--fid--abc.pdf`],
};
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
});
test("should reject scoped URLs for a different survey", () => {
const responseData = {
[questionId]: [
`/storage/${workspaceId}/private/surveys/otherSurvey/questions/${questionId}/report--fid--abc.pdf`,
],
};
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
});
test("should reject scoped URLs for a different question", () => {
const responseData = {
[questionId]: [
`/storage/${workspaceId}/private/surveys/${surveyId}/questions/otherQuestion/report--fid--abc.pdf`,
],
};
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
});
test("should reject external URLs", () => {
const responseData = {
[questionId]: ["https://example.com/report--fid--abc.pdf"],
};
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
});
test("should reject file extensions not allowed by the matching upload question", () => {
const responseData = {
[questionId]: [
`/storage/${workspaceId}/private/surveys/${surveyId}/questions/${questionId}/image--fid--abc.png`,
],
};
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
});
});
describe("parseStorageFileUrl", () => {
test("should parse nested relative storage URLs", () => {
expect(
parseStorageFileUrl(
"/storage/workspace-123/private/surveys/survey-123/questions/question-123/report.pdf"
)
).toEqual({
storageId: "workspace-123",
accessType: "private",
fileName: "surveys/survey-123/questions/question-123/report.pdf",
});
});
test("should parse absolute storage URLs", () => {
expect(parseStorageFileUrl("https://example.com/storage/workspace-123/public/report.pdf")).toEqual({
storageId: "workspace-123",
accessType: "public",
fileName: "report.pdf",
});
});
test.each([
"https://example.com/not-storage/workspace-123/private/report.pdf",
"/storage/workspace-123/internal/report.pdf",
"/storage/workspace-123/private",
"not a url",
])("should reject invalid storage URL %s", (fileUrl) => {
expect(parseStorageFileUrl(fileUrl)).toBeNull();
});
});
describe("isValidImageFile", () => {
+156 -12
View File
@@ -1,7 +1,11 @@
import "server-only";
import { type StorageError, StorageErrorCode } from "@formbricks/storage";
import { TResponseData } from "@formbricks/types/responses";
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage";
import {
type TAccessType,
type 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";
@@ -120,7 +124,7 @@ export type TSurveyFileUploadPermissionResult =
}
| {
ok: false;
reason: "no_file_upload_question" | "file_extension_not_allowed";
reason: "no_file_upload_question" | "file_upload_question_not_found" | "file_extension_not_allowed";
};
const getAllowedFileExtensionFromFileName = (fileName: string): TAllowedFileExtension | null => {
@@ -132,21 +136,33 @@ const getAllowedFileExtensionFromFileName = (fileName: string): TAllowedFileExte
return extensionValidation.success ? extensionValidation.data : null;
};
export const validateSurveyAllowsFileUpload = ({
fileName,
const getSurveyFileUploadConfigs = ({
blocks,
questions,
}: {
fileName: string;
blocks?: TSurveyBlock[] | null;
questions?: TSurveyQuestion[] | null;
}): TSurveyFileUploadPermissionResult => {
const fileUploadConfigs = [
}): TSurveyFileUploadElement[] => {
return [
...(blocks ?? [])
.flatMap((block) => block.elements)
.filter((element) => element.type === TSurveyElementTypeEnum.FileUpload),
...(questions ?? []).filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload),
] as TSurveyFileUploadElement[];
};
export const validateSurveyAllowsFileUpload = ({
fileName,
questionId,
blocks,
questions,
}: {
fileName: string;
questionId: string;
blocks?: TSurveyBlock[] | null;
questions?: TSurveyQuestion[] | null;
}): TSurveyFileUploadPermissionResult => {
const fileUploadConfigs = getSurveyFileUploadConfigs({ blocks, questions });
if (fileUploadConfigs.length === 0) {
return {
@@ -155,6 +171,15 @@ export const validateSurveyAllowsFileUpload = ({
};
}
const fileUploadConfig = fileUploadConfigs.find((config) => config.id === questionId);
if (!fileUploadConfig) {
return {
ok: false,
reason: "file_upload_question_not_found",
};
}
const fileExtension = getAllowedFileExtensionFromFileName(fileName);
if (!fileExtension) {
@@ -164,11 +189,9 @@ export const validateSurveyAllowsFileUpload = ({
};
}
const isFileExtensionAllowed = fileUploadConfigs.some((fileUploadConfig) => {
const { allowedFileExtensions } = fileUploadConfig;
return allowedFileExtensions === undefined || allowedFileExtensions.includes(fileExtension);
});
const { allowedFileExtensions } = fileUploadConfig;
const isFileExtensionAllowed =
allowedFileExtensions === undefined || allowedFileExtensions.includes(fileExtension);
return isFileExtensionAllowed
? { ok: true }
@@ -178,6 +201,127 @@ export const validateSurveyAllowsFileUpload = ({
};
};
const getStorageUrlPathSegments = (fileUrl: string): string[] | null => {
if (!fileUrl.startsWith("/storage/")) return null;
const pathWithoutSearch = fileUrl.split(/[?#]/)[0];
return pathWithoutSearch.split("/").filter(Boolean);
};
type TParsedStorageFileUrl = {
storageId: string;
accessType: TAccessType;
fileName: string;
};
export const parseStorageFileUrl = (fileUrl: string): TParsedStorageFileUrl | null => {
let pathname: string;
try {
pathname = fileUrl.startsWith("/storage/") ? fileUrl : new URL(fileUrl).pathname;
} catch {
return null;
}
const pathWithoutSearch = pathname.split(/[?#]/)[0];
if (!pathWithoutSearch.startsWith("/storage/")) return null;
const [storageSegment, storageId, accessType, ...fileNameSegments] = pathWithoutSearch
.split("/")
.filter(Boolean);
const fileName = fileNameSegments.join("/");
if (
storageSegment !== "storage" ||
!storageId ||
!fileName ||
(accessType !== "private" && accessType !== "public")
) {
return null;
}
return { storageId, accessType, fileName };
};
const isScopedPrivateUploadUrl = ({
fileUrl,
workspaceId,
surveyId,
questionId,
}: {
fileUrl: string;
workspaceId: string;
surveyId: string;
questionId: string;
}): boolean => {
const segments = getStorageUrlPathSegments(fileUrl);
if (!segments || segments.length !== 8) return false;
const [
storageSegment,
storageWorkspaceId,
accessType,
surveysSegment,
storageSurveyId,
questionsSegment,
storageQuestionId,
fileName,
] = segments;
return (
storageSegment === "storage" &&
storageWorkspaceId === workspaceId &&
accessType === "private" &&
surveysSegment === "surveys" &&
storageSurveyId === surveyId &&
questionsSegment === "questions" &&
storageQuestionId === questionId &&
Boolean(fileName)
);
};
export const validateClientFileUploads = ({
data,
workspaceId,
surveyId,
blocks,
questions,
}: {
data?: TResponseData;
workspaceId: string;
surveyId: string;
blocks?: TSurveyBlock[] | null;
questions?: TSurveyQuestion[] | null;
}): boolean => {
if (!data) return true;
const fileUploadConfigs = getSurveyFileUploadConfigs({ blocks, questions });
for (const fileUploadConfig of fileUploadConfigs) {
const fileUrls = data[fileUploadConfig.id];
if (fileUrls === undefined) continue;
if (!Array.isArray(fileUrls) || !fileUrls.every((url) => typeof url === "string")) return false;
for (const fileUrl of fileUrls) {
if (!validateSingleFile(fileUrl, fileUploadConfig.allowedFileExtensions)) return false;
if (
!isScopedPrivateUploadUrl({
fileUrl,
workspaceId,
surveyId,
questionId: fileUploadConfig.id,
})
) {
return false;
}
}
}
return true;
};
export const isValidImageFile = (fileUrl: string): boolean => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
if (!fileName || fileName.endsWith(".")) return false;
+33 -12
View File
@@ -7,7 +7,6 @@ import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { CreateSurveyParams, CreateSurveyWithLogicParams } from "@/playwright/utils/mock";
const MOCK_STORAGE_UPLOAD_PATH = "/__playwright__/mock-storage-upload";
const MOCK_STORAGE_FILE_PATH = "/storage/playwright-mock";
type MockStorageFileFixture = {
name: string;
@@ -44,11 +43,19 @@ const DEFAULT_MOCK_STORAGE_FILE_FIXTURE: MockStorageFileFixture = {
),
};
const getMockStorageFileUrl = (
appOrigin: string,
fileName: string,
accessType: "public" | "private"
): string => {
const getMockStorageFileUrl = ({
appOrigin,
fileName,
accessType,
storageId = "playwright-mock",
filePathSegments = [],
}: {
appOrigin: string;
fileName: string;
accessType: "public" | "private";
storageId?: string;
filePathSegments?: string[];
}): string => {
if (accessType === "public") {
const fixture = PLAYWRIGHT_STORAGE_FILE_FIXTURES.get(fileName);
@@ -57,7 +64,7 @@ const getMockStorageFileUrl = (
}
}
return `${MOCK_STORAGE_FILE_PATH}/${accessType}/${encodeURIComponent(fileName)}`;
return `/storage/${storageId}/${accessType}/${[...filePathSegments, encodeURIComponent(fileName)].join("/")}`;
};
/**
@@ -86,7 +93,7 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
presignedFields: {
key: fileName,
},
fileUrl: getMockStorageFileUrl(appOrigin, fileName, "public"),
fileUrl: getMockStorageFileUrl({ appOrigin, fileName, accessType: "public" }),
signingData: null,
updatedFileName: fileName,
},
@@ -113,9 +120,17 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
return;
}
const payload = route.request().postDataJSON() as { fileName?: string } | undefined;
const payload = route.request().postDataJSON() as
| { fileName?: string; surveyId?: string; questionId?: string }
| undefined;
const fileName = payload?.fileName ?? "uploaded-file.bin";
const appOrigin = new URL(route.request().url()).origin;
const requestUrl = new URL(route.request().url());
const appOrigin = requestUrl.origin;
const workspaceId = requestUrl.pathname.split("/").filter(Boolean)[3] ?? "playwright-mock";
const filePathSegments =
payload?.surveyId && payload?.questionId
? ["surveys", payload.surveyId, "questions", payload.questionId]
: [];
await route.fulfill({
status: 200,
@@ -126,7 +141,13 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
presignedFields: {
key: fileName,
},
fileUrl: getMockStorageFileUrl(appOrigin, fileName, "private"),
fileUrl: getMockStorageFileUrl({
appOrigin,
fileName,
accessType: "private",
storageId: workspaceId,
filePathSegments,
}),
signingData: null,
updatedFileName: fileName,
},
@@ -148,7 +169,7 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
});
});
await page.route(`**${MOCK_STORAGE_FILE_PATH}/**`, async (route) => {
await page.route("**/storage/**", async (route) => {
if (!["GET", "HEAD"].includes(route.request().method())) {
await route.fallback();
return;
+1
View File
@@ -1,6 +1,7 @@
export interface TUploadFileConfig {
allowedFileExtensions?: string[] | undefined;
surveyId?: string | undefined;
questionId?: string | undefined;
}
export interface TUploadFileResponse {
@@ -259,6 +259,7 @@ export function FileUploadElement({
{
allowedFileExtensions: element.allowedFileExtensions,
surveyId,
questionId: element.id,
}
);
return { name: file.name, url: uploadedUrl };
@@ -183,6 +183,45 @@ describe("ApiClient", () => {
expect(fileUrl).toBe("https://fake-file-url.com");
});
test("includes surveyId and questionId in the upload signing request", async () => {
vi.mocked(global.fetch)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://fake-s3-url.com",
fileUrl: "/storage/ws-test/private/surveys/survey123/questions/question123/test.jpg",
presignedFields: { policy: "test" },
signingData: null,
updatedFileName: "test.jpg",
},
}),
} as unknown as Response)
.mockResolvedValueOnce({ ok: true } as unknown as Response);
await client.uploadFile(
{
base64: "data:image/jpeg;base64,abcd",
name: "test.jpg",
type: "image/jpeg",
},
{
allowedFileExtensions: ["jpg"],
surveyId: "survey123",
questionId: "question123",
}
);
const requestInit = vi.mocked(global.fetch).mock.calls[0][1] as RequestInit;
expect(JSON.parse(requestInit.body as string)).toEqual({
fileName: "test.jpg",
fileType: "image/jpeg",
allowedFileExtensions: ["jpg"],
surveyId: "survey123",
questionId: "question123",
});
});
test("throws an error if file is invalid", async () => {
await expect(() => client.uploadFile({ base64: "", name: "", type: "" } as any)).rejects.toThrow(
"Invalid file object"
+2 -1
View File
@@ -116,7 +116,7 @@ export class ApiClient {
name: string;
base64: string;
},
{ allowedFileExtensions, surveyId }: TUploadFileConfig | undefined = {}
{ allowedFileExtensions, surveyId, questionId }: TUploadFileConfig | undefined = {}
): Promise<string> {
if (!file.name || !file.type || !file.base64) {
throw new Error(`Invalid file object`);
@@ -127,6 +127,7 @@ export class ApiClient {
fileType: file.type,
allowedFileExtensions,
surveyId,
questionId,
};
const response = await fetch(`${this.appUrl}/api/v1/client/${this.workspaceId}/storage`, {
+9
View File
@@ -114,6 +114,7 @@ export const ZDeleteFileRequest = ZDownloadFileRequest;
export const ZUploadFileConfig = z.object({
allowedFileExtensions: z.array(z.string()).optional(),
surveyId: z.string().optional(),
questionId: z.string().optional(),
});
export type TUploadFileConfig = z.infer<typeof ZUploadFileConfig>;
@@ -124,6 +125,14 @@ export const ZUploadPrivateFileRequest = z
fileType: z.string().trim().min(1),
allowedFileExtensions: z.array(ZAllowedFileExtension).optional(),
surveyId: z.cuid2(),
questionId: z
.string()
.trim()
.min(1)
.regex(
/^[a-zA-Z0-9_-]+$/,
"Question id must contain only alphanumeric characters, hyphens, or underscores"
),
workspaceId: z.cuid2(),
})
.superRefine((data, ctx) => {