From ec54e40a8b65f704327134c81a48384e02351c98 Mon Sep 17 00:00:00 2001 From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Date: Mon, 9 Sep 2024 16:16:07 +0530 Subject: [PATCH 1/3] fix: SSRF vulnerability in unsplash image fetching (#3111) Co-authored-by: pandeymangg Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> --- .../surveys/[surveyId]/edit/actions.ts | 17 +++++++++++++++-- packages/lib/constants.ts | 1 + 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts index 47ed02dc8d..4e26ebd800 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { createActionClass } from "@formbricks/lib/actionClass/service"; import { actionClient, authenticatedActionClient } from "@formbricks/lib/actionClient"; import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; -import { UNSPLASH_ACCESS_KEY } from "@formbricks/lib/constants"; +import { UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@formbricks/lib/constants"; import { getOrganizationIdFromEnvironmentId, getOrganizationIdFromProductId, @@ -227,13 +227,26 @@ export const getImagesFromUnsplashAction = actionClient }); }); +const isValidUnsplashUrl = (url: string): boolean => { + try { + const parsedUrl = new URL(url); + return parsedUrl.protocol === "https:" && UNSPLASH_ALLOWED_DOMAINS.includes(parsedUrl.hostname); + } catch { + return false; + } +}; + const ZTriggerDownloadUnsplashImageAction = z.object({ - downloadUrl: z.string(), + downloadUrl: z.string().url(), }); export const triggerDownloadUnsplashImageAction = actionClient .schema(ZTriggerDownloadUnsplashImageAction) .action(async ({ parsedInput }) => { + if (!isValidUnsplashUrl(parsedInput.downloadUrl)) { + throw new Error("Invalid Unsplash URL"); + } + const response = await fetch(`${parsedInput.downloadUrl}/?client_id=${UNSPLASH_ACCESS_KEY}`, { method: "GET", headers: { "Content-Type": "application/json" }, diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index fc0829fbb7..5d84da7bc4 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -168,6 +168,7 @@ export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1"; export const CUSTOMER_IO_SITE_ID = env.CUSTOMER_IO_SITE_ID; export const CUSTOMER_IO_API_KEY = env.CUSTOMER_IO_API_KEY; export const UNSPLASH_ACCESS_KEY = env.UNSPLASH_ACCESS_KEY; +export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"]; export const STRIPE_API_VERSION = "2024-06-20"; From 43ea26a33a147612a6831b80a7f2ea57f7f3d1ff Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:17:38 +0530 Subject: [PATCH 2/3] fix: segment update (#3115) --- packages/lib/survey/service.ts | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/lib/survey/service.ts b/packages/lib/survey/service.ts index 57e17e40af..61c73abf38 100644 --- a/packages/lib/survey/service.ts +++ b/packages/lib/survey/service.ts @@ -571,7 +571,42 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => id: segment.id, environmentId: segment.environmentId, }); + } else if (type === "app") { + if (!currentSurvey.segment) { + await prisma.survey.update({ + where: { + id: surveyId, + }, + data: { + segment: { + connectOrCreate: { + where: { + environmentId_title: { + environmentId, + title: surveyId, + }, + }, + create: { + title: surveyId, + isPrivate: true, + filters: [], + environment: { + connect: { + id: environmentId, + }, + }, + }, + }, + }, + }, + }); + + segmentCache.revalidate({ + environmentId, + }); + } } + data.questions = questions.map((question) => { const { isDraft, ...rest } = question; return rest; From 0532f2744b2716d54645aab69392c3ce80af6680 Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:54:25 +0530 Subject: [PATCH 3/3] feat: delete uploaded files with response deletion (#3114) --- packages/lib/response/service.ts | 43 ++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/lib/response/service.ts b/packages/lib/response/service.ts index ce7297c5dd..2ccfe3b524 100644 --- a/packages/lib/response/service.ts +++ b/packages/lib/response/service.ts @@ -16,7 +16,7 @@ import { ZResponseInput, ZResponseUpdateInput, } from "@formbricks/types/responses"; -import { TSurveySummary } from "@formbricks/types/surveys/types"; +import { TSurvey, TSurveyQuestionTypeEnum, TSurveySummary } from "@formbricks/types/surveys/types"; import { TTag } from "@formbricks/types/tags"; import { getAttributes } from "../attribute/service"; import { cache } from "../cache"; @@ -28,7 +28,7 @@ import { createPerson, getPersonByUserId } from "../person/service"; import { sendPlanLimitsReachedEventToPosthogWeekly } from "../posthogServer"; import { responseNoteCache } from "../responseNote/cache"; import { getResponseNotes } from "../responseNote/service"; -import { putFile } from "../storage/service"; +import { deleteFile, putFile } from "../storage/service"; import { getSurvey } from "../survey/service"; import { captureTelemetry } from "../telemetry"; import { convertToCsv, convertToXlsxBuffer } from "../utils/fileConversion"; @@ -697,6 +697,35 @@ export const updateResponse = async ( } }; +const findAndDeleteUploadedFilesInResponse = async (response: TResponse, survey: TSurvey): Promise => { + const fileUploadQuestions = new Set( + survey.questions + .filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload) + .map((q) => q.id) + ); + + const fileUrls = Object.entries(response.data) + .filter(([questionId]) => fileUploadQuestions.has(questionId)) + .flatMap(([, questionResponse]) => questionResponse as string[]); + + const deletionPromises = fileUrls.map(async (fileUrl) => { + try { + const { pathname } = new URL(fileUrl); + const [, environmentId, accessType, fileName] = pathname.split("/").filter(Boolean); + + if (!environmentId || !accessType || !fileName) { + throw new Error(`Invalid file path: ${pathname}`); + } + + return deleteFile(environmentId, accessType as "private" | "public", fileName); + } catch (error) { + console.error(`Failed to delete file ${fileUrl}:`, error); + } + }); + + await Promise.all(deletionPromises); +}; + export const deleteResponse = async (responseId: string): Promise => { validateInputs([responseId, ZId]); try { @@ -718,6 +747,16 @@ export const deleteResponse = async (responseId: string): Promise => const survey = await getSurvey(response.surveyId); + if (survey) { + await findAndDeleteUploadedFilesInResponse( + { + ...responsePrisma, + tags: responsePrisma.tags.map((tag) => tag.tag), + }, + survey + ); + } + responseCache.revalidate({ environmentId: survey?.environmentId, id: response.id,