From 1806a6a9dcf42d6f93e2f9dec3fae7e5ecefd95c Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:30:03 +0000 Subject: [PATCH 1/4] fix(surveys): enforce link-survey PIN on the client response API [ENG-1579] (#8506) --- .../lib/put-response-handler.test.ts | 53 +++++++++ .../[responseId]/lib/put-response-handler.ts | 12 +++ .../client/[workspaceId]/responses/route.ts | 7 ++ .../[workspaceId]/responses/lib/utils.ts | 5 + apps/web/lib/constants.ts | 1 + apps/web/modules/survey/link/actions.ts | 3 +- .../survey/link/components/pin-screen.tsx | 3 + .../link/components/survey-client-wrapper.tsx | 3 + .../modules/survey/link/lib/pin-token.test.ts | 102 ++++++++++++++++++ apps/web/modules/survey/link/lib/pin-token.ts | 46 ++++++++ .../surveys/src/components/general/survey.tsx | 17 ++- packages/surveys/src/lib/api-client.ts | 2 + packages/surveys/src/lib/response-queue.ts | 2 + .../surveys/src/lib/response.queue.test.ts | 1 + packages/surveys/src/lib/survey-state.ts | 8 +- packages/types/formbricks-surveys.ts | 1 + packages/types/responses.ts | 2 + 17 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 apps/web/modules/survey/link/lib/pin-token.test.ts create mode 100644 apps/web/modules/survey/link/lib/pin-token.ts diff --git a/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.test.ts b/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.test.ts index dde1e74d8046..258259143286 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.test.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ validateClientFileUploads: vi.fn(), validateOtherOptionLengthForMultipleChoice: vi.fn(), validateResponseData: vi.fn(), + verifyLinkSurveyPinToken: vi.fn(), })); vi.mock("@formbricks/logger", () => ({ @@ -65,6 +66,10 @@ vi.mock("./validated-response-update-input", () => ({ getValidatedResponseUpdateInput: mocks.getValidatedResponseUpdateInput, })); +vi.mock("@/modules/survey/link/lib/pin-token", () => ({ + verifyLinkSurveyPinToken: mocks.verifyLinkSurveyPinToken, +})); + const workspaceId = "workspace_a"; const responseId = "response_123"; const surveyId = "survey_123"; @@ -139,6 +144,7 @@ describe("putResponseHandler", () => { mocks.validateClientFileUploads.mockReturnValue(true); mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null); mocks.validateResponseData.mockReturnValue(null); + mocks.verifyLinkSurveyPinToken.mockReturnValue(true); }); test("returns a bad request response when the response id is missing", async () => { @@ -335,6 +341,53 @@ describe("putResponseHandler", () => { expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled(); }); + test("rejects updates to a PIN-protected survey without a valid PIN token", async () => { + mocks.getSurvey.mockResolvedValue({ + ...getBaseSurvey(), + pin: "1234", + }); + mocks.getValidatedResponseUpdateInput.mockResolvedValue({ + responseUpdateInput: { ...getBaseResponseUpdateInput(), pinAuthToken: "invalid" }, + }); + mocks.verifyLinkSurveyPinToken.mockReturnValue(false); + + const result = await putResponseHandler(createHandlerParams()); + + expect(result.response.status).toBe(403); + await expect(result.response.json()).resolves.toEqual({ + code: "forbidden", + message: "Survey is protected by a PIN", + details: { surveyId }, + }); + expect(mocks.verifyLinkSurveyPinToken).toHaveBeenCalledWith("invalid", surveyId); + expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled(); + }); + + test("allows updates to a PIN-protected survey with a valid PIN token", async () => { + mocks.getSurvey.mockResolvedValue({ + ...getBaseSurvey(), + pin: "1234", + }); + mocks.getValidatedResponseUpdateInput.mockResolvedValue({ + responseUpdateInput: { ...getBaseResponseUpdateInput(), pinAuthToken: "valid-token" }, + }); + mocks.verifyLinkSurveyPinToken.mockReturnValue(true); + + const result = await putResponseHandler(createHandlerParams()); + + expect(result.response.status).toBe(200); + expect(mocks.verifyLinkSurveyPinToken).toHaveBeenCalledWith("valid-token", surveyId); + expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalled(); + }); + + test("does not require a PIN token when the survey has no PIN", async () => { + const result = await putResponseHandler(createHandlerParams()); + + expect(result.response.status).toBe(200); + expect(mocks.verifyLinkSurveyPinToken).not.toHaveBeenCalled(); + expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalled(); + }); + test("rejects invalid file upload updates", async () => { mocks.validateClientFileUploads.mockReturnValue(false); diff --git a/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts b/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts index de40714ffe4b..2ae43e64e11d 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts @@ -18,6 +18,7 @@ import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/ import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element"; import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers"; import { validateClientFileUploads } from "@/modules/storage/utils"; +import { verifyLinkSurveyPinToken } from "@/modules/survey/link/lib/pin-token"; import { updateResponseWithQuotaEvaluation } from "./response"; import { getValidatedResponseUpdateInput } from "./validated-response-update-input"; @@ -278,6 +279,17 @@ export const putResponseHandler = async ({ }; } + // Mirror the POST endpoint: PIN-protected link surveys require a server-verifiable PIN token. + // Without this, an unfinished response of a PIN survey could be updated/finalized without the PIN + // (CWE-602, ENG-1579). + if (survey.pin && !verifyLinkSurveyPinToken(responseUpdateInput.pinAuthToken, survey.id)) { + return { + response: responses.forbiddenResponse("Survey is protected by a PIN", true, { + surveyId: survey.id, + }), + }; + } + const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput, workspaceId); if (validationResult) { return validationResult; diff --git a/apps/web/app/api/v1/client/[workspaceId]/responses/route.ts b/apps/web/app/api/v1/client/[workspaceId]/responses/route.ts index ce528d7d0407..846d457ee277 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/responses/route.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/responses/route.ts @@ -19,6 +19,7 @@ import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/ import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers"; import { validateClientFileUploads } from "@/modules/storage/utils"; +import { verifyLinkSurveyPinToken } from "@/modules/survey/link/lib/pin-token"; import { createResponseWithQuotaEvaluation } from "./lib/response"; export const OPTIONS = async (): Promise => { @@ -146,6 +147,12 @@ export const POST = withV1ApiWrapper({ }; } + if (survey.pin && !verifyLinkSurveyPinToken(responseInputData.pinAuthToken, survey.id)) { + return { + response: responses.forbiddenResponse("Survey is protected by a PIN", true, { surveyId: survey.id }), + }; + } + const singleUseValidationResult = validateSingleUseResponseInput(survey, workspaceId, responseInputData); if (singleUseValidationResult) { if ("response" in singleUseValidationResult) { diff --git a/apps/web/app/api/v2/client/[workspaceId]/responses/lib/utils.ts b/apps/web/app/api/v2/client/[workspaceId]/responses/lib/utils.ts index 7f0ea5bb54df..d6e3e1f881af 100644 --- a/apps/web/app/api/v2/client/[workspaceId]/responses/lib/utils.ts +++ b/apps/web/app/api/v2/client/[workspaceId]/responses/lib/utils.ts @@ -9,6 +9,7 @@ import { symmetricDecrypt } from "@/lib/crypto"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; import { validateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys"; import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; +import { verifyLinkSurveyPinToken } from "@/modules/survey/link/lib/pin-token"; export const RECAPTCHA_VERIFICATION_ERROR_CODE = "recaptcha_verification_failed"; @@ -33,6 +34,10 @@ export const checkSurveyValidity = async ( }); } + if (survey.pin && !verifyLinkSurveyPinToken(responseInput.pinAuthToken, survey.id)) { + return responses.forbiddenResponse("Survey is protected by a PIN", true, { surveyId: survey.id }); + } + if (survey.type === "link" && survey.singleUse?.enabled) { if (!responseInput.singleUseId) { return responses.badRequestResponse("Missing single use id", { diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index ef722aef092d..7cc7a7596a06 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -94,6 +94,7 @@ export const MAIL_FROM = env.MAIL_FROM; export const MAIL_FROM_NAME = env.MAIL_FROM_NAME; export const NEXTAUTH_SECRET = env.NEXTAUTH_SECRET; +export const BETTER_AUTH_SECRET = env.BETTER_AUTH_SECRET; export const ITEMS_PER_PAGE = 30; export const SURVEYS_PER_PAGE = 12; export const RESPONSES_PER_PAGE = 25; diff --git a/apps/web/modules/survey/link/actions.ts b/apps/web/modules/survey/link/actions.ts index a2e416dd2176..ac685732e074 100644 --- a/apps/web/modules/survey/link/actions.ts +++ b/apps/web/modules/survey/link/actions.ts @@ -10,6 +10,7 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization"; import { sendLinkSurveyToVerifiedEmail } from "@/modules/email"; import { getSurveyWithMetadata, isSurveyResponsePresent } from "@/modules/survey/link/lib/data"; +import { createLinkSurveyPinToken } from "@/modules/survey/link/lib/pin-token"; export const sendLinkSurveyEmailAction = actionClient .inputSchema(ZLinkSurveyEmailData) @@ -53,7 +54,7 @@ export const validateSurveyPinAction = actionClient throw new InvalidInputError("INVALID_PIN"); } - return { survey }; + return { survey, pinAuthToken: createLinkSurveyPinToken(survey.id) }; }); const ZIsSurveyResponsePresentAction = z.object({ diff --git a/apps/web/modules/survey/link/components/pin-screen.tsx b/apps/web/modules/survey/link/components/pin-screen.tsx index 961c85d8dc64..023d84e9b519 100644 --- a/apps/web/modules/survey/link/components/pin-screen.tsx +++ b/apps/web/modules/survey/link/components/pin-screen.tsx @@ -62,6 +62,7 @@ export const PinScreen = (props: PinScreenProps) => { const { t } = useTranslation(); const [error, setError] = useState(""); const [survey, setSurvey] = useState(); + const [pinAuthToken, setPinAuthToken] = useState(); const isCardless = styling.cardArrangement?.linkSurveys === "cardless"; const linkSurveyCardMaxWidth = getLinkSurveyCardMaxWidth(styling.linkSurveyCardWidth); @@ -91,6 +92,7 @@ export const PinScreen = (props: PinScreenProps) => { const response = await validateSurveyPinAction({ surveyId, pin: localPinEntry }); if (response?.data?.survey) { setSurvey(response.data.survey); + setPinAuthToken(response.data.pinAuthToken ?? undefined); } else { const errorMessage = getFormattedErrorMessage(response); setError(errorMessage); @@ -149,6 +151,7 @@ export const PinScreen = (props: PinScreenProps) => { PRIVACY_URL={PRIVACY_URL} TERMS_URL={TERMS_URL} IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD} + pinAuthToken={pinAuthToken} /> ); }; diff --git a/apps/web/modules/survey/link/components/survey-client-wrapper.tsx b/apps/web/modules/survey/link/components/survey-client-wrapper.tsx index e6c6304ecd87..49cddea0628d 100644 --- a/apps/web/modules/survey/link/components/survey-client-wrapper.tsx +++ b/apps/web/modules/survey/link/components/survey-client-wrapper.tsx @@ -37,6 +37,7 @@ interface SurveyClientWrapperProps { PRIVACY_URL?: string; TERMS_URL?: string; IS_FORMBRICKS_CLOUD: boolean; + pinAuthToken?: string; } let setBlockId = (_: string) => {}; @@ -62,6 +63,7 @@ export const SurveyClientWrapper = ({ PRIVACY_URL, TERMS_URL, IS_FORMBRICKS_CLOUD, + pinAuthToken, }: SurveyClientWrapperProps) => { const searchParams = useSearchParams(); const { i18n } = useTranslation(); @@ -244,6 +246,7 @@ export const SurveyClientWrapper = ({ }} singleUseId={singleUseId} singleUseResponseId={singleUseResponseId} + pinAuthToken={pinAuthToken} getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}} contactId={contactId} userId={userId} diff --git a/apps/web/modules/survey/link/lib/pin-token.test.ts b/apps/web/modules/survey/link/lib/pin-token.test.ts new file mode 100644 index 000000000000..217747ac75ad --- /dev/null +++ b/apps/web/modules/survey/link/lib/pin-token.test.ts @@ -0,0 +1,102 @@ +import jwt from "jsonwebtoken"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +vi.mock("@formbricks/logger", () => ({ + logger: { warn: vi.fn() }, +})); + +const TEST_SECRET = "test-secret-at-least-32-chars-long!!"; + +vi.mock("@/lib/constants", () => ({ + BETTER_AUTH_SECRET: undefined, + NEXTAUTH_SECRET: TEST_SECRET, +})); + +// Import after mocks are set up +const { createLinkSurveyPinToken, verifyLinkSurveyPinToken } = await import("./pin-token"); + +const SURVEY_ID = "clxsurvey0000abcdef123456"; + +describe("createLinkSurveyPinToken", () => { + test("returns a signed JWT string", () => { + const token = createLinkSurveyPinToken(SURVEY_ID); + expect(typeof token).toBe("string"); + expect(token.split(".")).toHaveLength(3); + }); +}); + +describe("verifyLinkSurveyPinToken", () => { + test("valid token for correct surveyId returns true", () => { + const token = createLinkSurveyPinToken(SURVEY_ID); + expect(verifyLinkSurveyPinToken(token, SURVEY_ID)).toBe(true); + }); + + test("valid token with wrong surveyId returns false", () => { + const token = createLinkSurveyPinToken(SURVEY_ID); + expect(verifyLinkSurveyPinToken(token, "clxsurveyXXXXabcdef000000")).toBe(false); + }); + + test("tampered token returns false", () => { + const token = createLinkSurveyPinToken(SURVEY_ID); + const parts = token.split("."); + // Flip a character in the signature + const tampered = `${parts[0]}.${parts[1]}.${parts[2].slice(0, -1)}X`; + expect(verifyLinkSurveyPinToken(tampered, SURVEY_ID)).toBe(false); + }); + + test("null token returns false", () => { + expect(verifyLinkSurveyPinToken(null, SURVEY_ID)).toBe(false); + }); + + test("undefined token returns false", () => { + expect(verifyLinkSurveyPinToken(undefined, SURVEY_ID)).toBe(false); + }); + + test("token signed with a different purpose returns false", () => { + const wrongPurposeToken = jwt.sign({ surveyId: SURVEY_ID, purpose: "some_other_purpose" }, TEST_SECRET, { + algorithm: "HS256", + }); + expect(verifyLinkSurveyPinToken(wrongPurposeToken, SURVEY_ID)).toBe(false); + }); + + test("expired token returns false", () => { + const expiredToken = jwt.sign({ surveyId: SURVEY_ID, purpose: "link_survey_pin" }, TEST_SECRET, { + algorithm: "HS256", + expiresIn: -1, + }); + expect(verifyLinkSurveyPinToken(expiredToken, SURVEY_ID)).toBe(false); + }); +}); + +describe("secret resolution", () => { + test("prefers BETTER_AUTH_SECRET over NEXTAUTH_SECRET", async () => { + vi.resetModules(); + const betterAuthSecret = "better-auth-secret-at-least-32-chars!!"; + vi.doMock("@/lib/constants", () => ({ + BETTER_AUTH_SECRET: betterAuthSecret, + NEXTAUTH_SECRET: TEST_SECRET, + })); + const mod = await import("./pin-token"); + + const token = mod.createLinkSurveyPinToken(SURVEY_ID); + // Verifies against BETTER_AUTH_SECRET, not the NextAuth fallback. + expect(() => jwt.verify(token, betterAuthSecret)).not.toThrow(); + expect(() => jwt.verify(token, TEST_SECRET)).toThrow(); + expect(mod.verifyLinkSurveyPinToken(token, SURVEY_ID)).toBe(true); + }); + + test("falls back to NEXTAUTH_SECRET when BETTER_AUTH_SECRET is unset", async () => { + vi.resetModules(); + vi.doMock("@/lib/constants", () => ({ + BETTER_AUTH_SECRET: undefined, + NEXTAUTH_SECRET: TEST_SECRET, + })); + const mod = await import("./pin-token"); + + const token = mod.createLinkSurveyPinToken(SURVEY_ID); + expect(() => jwt.verify(token, TEST_SECRET)).not.toThrow(); + expect(mod.verifyLinkSurveyPinToken(token, SURVEY_ID)).toBe(true); + }); +}); diff --git a/apps/web/modules/survey/link/lib/pin-token.ts b/apps/web/modules/survey/link/lib/pin-token.ts new file mode 100644 index 000000000000..de514c5d5bff --- /dev/null +++ b/apps/web/modules/survey/link/lib/pin-token.ts @@ -0,0 +1,46 @@ +import "server-only"; +import jwt from "jsonwebtoken"; +import { logger } from "@formbricks/logger"; +import { BETTER_AUTH_SECRET, NEXTAUTH_SECRET } from "@/lib/constants"; + +const PIN_TOKEN_PURPOSE = "link_survey_pin"; +const PIN_TOKEN_TTL_SECONDS = 60 * 60; // 1 hour + +// Mirror the auth session secret resolution (auth.ts / session-cookie.ts): prefer +// BETTER_AUTH_SECRET, fall back to NEXTAUTH_SECRET. A better-auth-only deployment sets only +// BETTER_AUTH_SECRET, so keying PIN tokens off NEXTAUTH_SECRET alone would throw and break PIN +// enforcement even though auth itself works. +// +// Resolved lazily inside the functions rather than at module scope: reading the constants at +// import time makes every unrelated test that mocks "@/lib/constants" (and transitively imports +// this module) fail Vitest's strict missing-export check. Deferring the access keeps import +// side-effect-free, matching the pre-existing pattern. +const resolvePinTokenSecret = (): string | undefined => BETTER_AUTH_SECRET ?? NEXTAUTH_SECRET; + +export const createLinkSurveyPinToken = (surveyId: string): string => { + const secret = resolvePinTokenSecret(); + if (!secret) { + throw new Error("No auth secret set (BETTER_AUTH_SECRET or NEXTAUTH_SECRET)"); + } + return jwt.sign({ surveyId, purpose: PIN_TOKEN_PURPOSE }, secret, { + algorithm: "HS256", + expiresIn: PIN_TOKEN_TTL_SECONDS, + }); +}; + +export const verifyLinkSurveyPinToken = (token: string | null | undefined, surveyId: string): boolean => { + const secret = resolvePinTokenSecret(); + if (!token || !secret) { + return false; + } + try { + const payload = jwt.verify(token, secret, { algorithms: ["HS256"] }) as jwt.JwtPayload & { + surveyId?: string; + purpose?: string; + }; + return payload.purpose === PIN_TOKEN_PURPOSE && payload.surveyId === surveyId; + } catch (error) { + logger.warn({ error, surveyId }, "Invalid link survey PIN token"); + return false; + } +}; diff --git a/packages/surveys/src/components/general/survey.tsx b/packages/surveys/src/components/general/survey.tsx index fa635acca352..1f7c49cb90aa 100644 --- a/packages/surveys/src/components/general/survey.tsx +++ b/packages/surveys/src/components/general/survey.tsx @@ -108,6 +108,7 @@ export function Survey({ action, singleUseId, singleUseResponseId, + pinAuthToken, isWebEnvironment = true, getRecaptchaToken, isSpamProtectionEnabled, @@ -132,13 +133,23 @@ export function Survey({ const surveyState = useMemo(() => { if (appUrl && workspaceId) { if (mode === "inline") { - return new SurveyState(survey.id, singleUseId, singleUseResponseId, userId, contactId); + return new SurveyState(survey.id, singleUseId, singleUseResponseId, userId, contactId, pinAuthToken); } - return new SurveyState(survey.id, null, null, userId, contactId); + return new SurveyState(survey.id, null, null, userId, contactId, pinAuthToken); } return null; - }, [appUrl, workspaceId, mode, survey.id, userId, singleUseId, singleUseResponseId, contactId]); + }, [ + appUrl, + workspaceId, + mode, + survey.id, + userId, + singleUseId, + singleUseResponseId, + contactId, + pinAuthToken, + ]); // Update the responseQueue to use the stored responseId diff --git a/packages/surveys/src/lib/api-client.ts b/packages/surveys/src/lib/api-client.ts index ad2b4bccb2ea..5375a22c80f6 100644 --- a/packages/surveys/src/lib/api-client.ts +++ b/packages/surveys/src/lib/api-client.ts @@ -97,6 +97,7 @@ export class ApiClient { ttc, variables, language, + pinAuthToken, }: TResponseUpdateInput & { responseId: string }): Promise< Result > { @@ -107,6 +108,7 @@ export class ApiClient { ttc, variables, language, + pinAuthToken, }); } diff --git a/packages/surveys/src/lib/response-queue.ts b/packages/surveys/src/lib/response-queue.ts index cc26cca15b6f..2738d25f707c 100644 --- a/packages/surveys/src/lib/response-queue.ts +++ b/packages/surveys/src/lib/response-queue.ts @@ -648,6 +648,7 @@ export class ResponseQueue { response = await this.api.updateResponse({ ...responseUpdate, responseId: this.surveyState.responseId, + pinAuthToken: this.surveyState.pinAuthToken || null, }); if (!response.ok) { @@ -662,6 +663,7 @@ export class ResponseQueue { userId: this.surveyState.userId || null, singleUseId: this.surveyState.singleUseId || null, displayId: this.surveyState.displayId, + pinAuthToken: this.surveyState.pinAuthToken || null, recaptchaToken: this.responseRecaptchaToken ?? undefined, }); diff --git a/packages/surveys/src/lib/response.queue.test.ts b/packages/surveys/src/lib/response.queue.test.ts index 7ec9c9a54fce..52056c864d1f 100644 --- a/packages/surveys/src/lib/response.queue.test.ts +++ b/packages/surveys/src/lib/response.queue.test.ts @@ -39,6 +39,7 @@ const getSurveyState: () => SurveyState = () => ({ contactId: "contact1", surveyId: "survey1", singleUseId: "single1", + pinAuthToken: null, shouldCreateResponseFromState: false, responseAcc: { finished: false, data: {}, ttc: {}, variables: {} }, updateResponseId: vi.fn(), diff --git a/packages/surveys/src/lib/survey-state.ts b/packages/surveys/src/lib/survey-state.ts index f22475fa84ed..eb4fdcd55ad4 100644 --- a/packages/surveys/src/lib/survey-state.ts +++ b/packages/surveys/src/lib/survey-state.ts @@ -9,19 +9,22 @@ export class SurveyState { shouldCreateResponseFromState = false; responseAcc: TResponseUpdate = { finished: false, data: {}, ttc: {}, variables: {} }; singleUseId: string | null; + pinAuthToken: string | null; constructor( surveyId: string, singleUseId?: string | null, responseId?: string | null, userId?: string | null, - contactId?: string | null + contactId?: string | null, + pinAuthToken?: string | null ) { this.surveyId = surveyId; this.userId = userId ?? null; this.singleUseId = singleUseId ?? null; this.responseId = responseId ?? null; this.contactId = contactId ?? null; + this.pinAuthToken = pinAuthToken ?? null; } /** @@ -41,7 +44,8 @@ export class SurveyState { this.singleUseId ?? undefined, this.responseId ?? undefined, this.userId ?? undefined, - this.contactId ?? undefined + this.contactId ?? undefined, + this.pinAuthToken ?? undefined ); copyInstance.responseId = this.responseId; copyInstance.responseAcc = this.responseAcc; diff --git a/packages/types/formbricks-surveys.ts b/packages/types/formbricks-surveys.ts index 3aa17bd007bb..5ada6e85a28b 100644 --- a/packages/types/formbricks-surveys.ts +++ b/packages/types/formbricks-surveys.ts @@ -67,6 +67,7 @@ export interface SurveyContainerProps extends Omit; From 3d93676293e1a98df86b72e7707e10aba8ba1392 Mon Sep 17 00:00:00 2001 From: Matti Nannt <675065+mattinannt@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:31:10 +0000 Subject: [PATCH 2/4] fix(web): unoptimize external next/image srcs instead of a host wildcard (#8501) Co-authored-by: Claude Fable 5 Co-authored-by: pandeymangg --- .../components/PictureChoiceSummary.tsx | 2 + apps/web/lib/image-hosts.test.ts | 43 +++++++++++++++ apps/web/lib/image-hosts.ts | 34 ++++++++++++ apps/web/lib/optimizable-image-hosts.mjs | 28 ++++++++++ .../email-customization-settings.tsx | 3 ++ .../favicon-customization-settings.tsx | 2 + .../editor/components/logo-settings-card.tsx | 2 + .../survey/editor/components/option-ids.tsx | 2 + .../editor/components/unsplash-images.tsx | 2 + .../ui/components/client-logo/index.tsx | 2 + .../components/connect-integration/index.tsx | 8 ++- .../ui/components/file-input/index.tsx | 11 ++-- .../ui/components/input-combo-box/index.tsx | 31 +++++++++-- .../ui/components/media-background/index.tsx | 17 +++--- .../picture-selection-response/index.tsx | 2 + .../settings/look/components/edit-logo.tsx | 2 + apps/web/next.config.mjs | 53 +++++-------------- 17 files changed, 188 insertions(+), 56 deletions(-) create mode 100644 apps/web/lib/image-hosts.test.ts create mode 100644 apps/web/lib/image-hosts.ts create mode 100644 apps/web/lib/optimizable-image-hosts.mjs diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx index 22e03ed355c0..dd7c22f05258 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { type TI18nString } from "@formbricks/types/i18n"; import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurvey, TSurveyElementSummaryPictureSelection } from "@formbricks/types/surveys/types"; +import { isExternalImageSrc } from "@/lib/image-hosts"; import { getChoiceIdByValue } from "@/lib/response/utils"; import { IdBadge } from "@/modules/ui/components/id-badge"; import { ProgressBar } from "@/modules/ui/components/progress-bar"; @@ -68,6 +69,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict layout="fill" objectFit="cover" className="rounded-md" + unoptimized={isExternalImageSrc(result.imageUrl)} />
{choiceId && }
diff --git a/apps/web/lib/image-hosts.test.ts b/apps/web/lib/image-hosts.test.ts new file mode 100644 index 000000000000..ef92a42c72cd --- /dev/null +++ b/apps/web/lib/image-hosts.test.ts @@ -0,0 +1,43 @@ +import { type StaticImageData } from "next/image"; +import { describe, expect, test } from "vitest"; +import { OPTIMIZABLE_IMAGE_HOSTS, isExternalImageSrc } from "./image-hosts"; + +describe("isExternalImageSrc", () => { + test("treats relative first-party paths as optimizable (not external)", () => { + expect(isExternalImageSrc("/storage/ws/public/logo.png")).toBe(false); + expect(isExternalImageSrc("/images/hero.png")).toBe(false); + }); + + test("treats data URIs as optimizable (not external)", () => { + expect(isExternalImageSrc("data:image/png;base64,iVBORw0KGgo=")).toBe(false); + }); + + test("treats nullish / non-string (StaticImageData) srcs as not external", () => { + expect(isExternalImageSrc(undefined)).toBe(false); + expect(isExternalImageSrc(null)).toBe(false); + expect(isExternalImageSrc("")).toBe(false); + expect(isExternalImageSrc({ src: "/x.png", height: 1, width: 1 } as StaticImageData)).toBe(false); + }); + + test("treats absolute URLs on allowlisted provider hosts as optimizable", () => { + expect(isExternalImageSrc("https://images.unsplash.com/photo-1.jpg")).toBe(false); + expect(isExternalImageSrc("https://avatars.githubusercontent.com/u/1")).toBe(false); + }); + + test("treats arbitrary external URLs as external (must bypass the optimizer)", () => { + expect(isExternalImageSrc("https://evil.example.com/x.png")).toBe(true); + expect(isExternalImageSrc("http://random-host.test/logo.svg")).toBe(true); + // Cloud-specific infra is not in the universal allowlist — treated as external. + expect(isExternalImageSrc("https://formbricks-cdn.s3.eu-central-1.amazonaws.com/x.png")).toBe(true); + }); + + test("treats an absolute URL to the deployment's own domain as external (relative is the supported first-party form)", () => { + // The running domain is intentionally not in the allowlist; first-party images must be relative. + expect(isExternalImageSrc("https://app.formbricks.com/storage/ws/public/logo.png")).toBe(true); + expect(isExternalImageSrc("https://survey.company.com/storage/ws/public/logo.png")).toBe(true); + }); + + test("does not include the deployment domain in the optimizable host allowlist", () => { + expect(OPTIMIZABLE_IMAGE_HOSTS).not.toContain("app.formbricks.com"); + }); +}); diff --git a/apps/web/lib/image-hosts.ts b/apps/web/lib/image-hosts.ts new file mode 100644 index 000000000000..dc256674ce92 --- /dev/null +++ b/apps/web/lib/image-hosts.ts @@ -0,0 +1,34 @@ +import { type StaticImageData } from "next/image"; +import { OPTIMIZABLE_IMAGE_HOSTS } from "./optimizable-image-hosts.mjs"; + +// Re-exported from the plain `.mjs` source of truth (also imported by next.config.mjs) so +// remotePatterns and the runtime check below share one list. See ./optimizable-image-hosts.mjs. +export { OPTIMIZABLE_IMAGE_HOSTS }; + +const OPTIMIZABLE_IMAGE_HOSTS_SET: ReadonlySet = new Set(OPTIMIZABLE_IMAGE_HOSTS); + +/** + * Whether an `` src must be rendered with `unoptimized` because the Next.js image optimizer + * would not (and should not) serve it. + * + * Returns `false` (i.e. optimize) for: + * - relative paths (`/storage/...`, `/images/...`) — local images, optimized via `localPatterns`; + * - `data:` URIs, `StaticImageData` imports, or empty/nullish values; + * - absolute URLs whose host is in {@link OPTIMIZABLE_IMAGE_HOSTS}. + * + * Returns `true` (i.e. bypass the optimizer, serve directly) for any other absolute `http(s)` URL — + * i.e. arbitrary user-provided external images. This keeps the optimizer from acting as an open + * proxy for hosts we don't control, without breaking rendering of those images. + * + * The decision depends only on the src string, so it is identical on the server and client (no + * hydration mismatch). + */ +export const isExternalImageSrc = (src: string | StaticImageData | null | undefined): boolean => { + if (!src || typeof src !== "string") return false; + if (!/^https?:\/\//i.test(src)) return false; // relative path or data: URI → local/optimizable + try { + return !OPTIMIZABLE_IMAGE_HOSTS_SET.has(new URL(src).hostname); + } catch { + return false; + } +}; diff --git a/apps/web/lib/optimizable-image-hosts.mjs b/apps/web/lib/optimizable-image-hosts.mjs new file mode 100644 index 000000000000..0649dd913dff --- /dev/null +++ b/apps/web/lib/optimizable-image-hosts.mjs @@ -0,0 +1,28 @@ +/** + * Hosts whose images are safe to run through the Next.js image optimizer (ENG-1678). + * + * Plain `.mjs` so `next.config.mjs` can statically import it at config-eval time (no jiti/TS needed) + * while `lib/image-hosts.ts` re-exports it for the runtime `isExternalImageSrc` check — one source of + * truth, so `images.remotePatterns` and the per-`` `unoptimized` decision can never drift. + * + * Two hard constraints shape this list: + * - `next.config` (and therefore `remotePatterns`) is frozen into the build. The same Docker image + * serves multiple domains (app.formbricks.com, ksa.formbricks.com, and every self-hoster), so the + * deployment's own domain can NOT be baked in here and is intentionally absent. + * - First-party uploads are served from same-origin `/storage/...` (relative) paths, which Next + * treats as local images (default: optimize all) and never checks against `remotePatterns`. + * + * It therefore contains only *universal* provider hosts that real features rely on and that are + * identical on every deployment. + */ +export const OPTIMIZABLE_IMAGE_HOSTS = [ + // OAuth profile avatars + "avatars.githubusercontent.com", + "avatars.slack-edge.com", + "lh3.googleusercontent.com", + // survey editor's Unsplash background picker + "images.unsplash.com", + // local development + "localhost", + "127.0.0.1", +]; diff --git a/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx b/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx index 18c77e3d9ea9..4a8521e6d430 100644 --- a/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx +++ b/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx @@ -11,6 +11,7 @@ import { TAllowedFileExtension } from "@formbricks/types/storage"; import { TUser } from "@formbricks/types/user"; import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard"; import { cn } from "@/lib/cn"; +import { isExternalImageSrc } from "@/lib/image-hosts"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { removeOrganizationEmailLogoUrlAction, @@ -219,6 +220,7 @@ export const EmailCustomizationSettings = ({ className="max-h-24 max-w-full object-contain" width={192} height={192} + unoptimized={isExternalImageSrc(logoUrl)} /> @@ -287,6 +289,7 @@ export const EmailCustomizationSettings = ({ className="mx-auto max-h-[100px] max-w-full object-contain" width={192} height={192} + unoptimized={isExternalImageSrc(logoUrl || fbLogoUrl)} />

{t("workspace.settings.general.email_customization_preview_email_heading", { diff --git a/apps/web/modules/ee/whitelabel/favicon-customization/components/favicon-customization-settings.tsx b/apps/web/modules/ee/whitelabel/favicon-customization/components/favicon-customization-settings.tsx index a1b966fcd555..e0c7faed71fc 100644 --- a/apps/web/modules/ee/whitelabel/favicon-customization/components/favicon-customization-settings.tsx +++ b/apps/web/modules/ee/whitelabel/favicon-customization/components/favicon-customization-settings.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; import { TOrganization } from "@formbricks/types/organizations"; import { TAllowedFileExtension } from "@formbricks/types/storage"; import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard"; +import { isExternalImageSrc } from "@/lib/image-hosts"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { removeOrganizationFaviconUrlAction, @@ -172,6 +173,7 @@ export const FaviconCustomizationSettings = ({ width={64} height={64} className="-mb-2 size-16 rounded-lg border object-contain p-1" + unoptimized={isExternalImageSrc(faviconUrl)} /> ) : ( diff --git a/apps/web/modules/survey/editor/components/option-ids.tsx b/apps/web/modules/survey/editor/components/option-ids.tsx index c1d7fc8c3578..2d9cea2d2d26 100644 --- a/apps/web/modules/survey/editor/components/option-ids.tsx +++ b/apps/web/modules/survey/editor/components/option-ids.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurveyVariable } from "@formbricks/types/surveys/types"; import { getLocalizedValue } from "@/lib/i18n/utils"; +import { isExternalImageSrc } from "@/lib/image-hosts"; import { IdBadge } from "@/modules/ui/components/id-badge"; import { Label } from "@/modules/ui/components/label"; @@ -54,6 +55,7 @@ export const OptionIds = (props: OptionIdsProps) => { style={{ objectFit: "cover" }} quality={75} className="rounded-lg transition-opacity duration-200" + unoptimized={isExternalImageSrc(imageUrl)} /> diff --git a/apps/web/modules/survey/editor/components/unsplash-images.tsx b/apps/web/modules/survey/editor/components/unsplash-images.tsx index d1559ae9cdf0..0c8773c163de 100644 --- a/apps/web/modules/survey/editor/components/unsplash-images.tsx +++ b/apps/web/modules/survey/editor/components/unsplash-images.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { TSurveyBackgroundBgType } from "@formbricks/types/surveys/types"; +import { isExternalImageSrc } from "@/lib/image-hosts"; import { debounce } from "@/lib/utils/debounce"; import { Button } from "@/modules/ui/components/button"; import { Input } from "@/modules/ui/components/input"; @@ -213,6 +214,7 @@ export const ImageFromUnsplashSurveyBg = ({ handleBgChange }: ImageFromUnsplashS alt={image.alt_description} onClick={() => handleImageSelected(image.urls.regularWithAttribution, image.urls.download)} className="h-full cursor-pointer rounded-lg object-cover" + unoptimized={isExternalImageSrc(image.urls.regularWithAttribution)} /> {image.authorName && ( diff --git a/apps/web/modules/ui/components/client-logo/index.tsx b/apps/web/modules/ui/components/client-logo/index.tsx index 54740e541b12..a05c0c280520 100644 --- a/apps/web/modules/ui/components/client-logo/index.tsx +++ b/apps/web/modules/ui/components/client-logo/index.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import { Workspace } from "@formbricks/database/prisma-browser"; import { TLogo } from "@formbricks/types/styling"; import { cn } from "@/lib/cn"; +import { isExternalImageSrc } from "@/lib/image-hosts"; interface ClientLogoProps { workspaceLogo: Workspace["logo"] | null; @@ -73,6 +74,7 @@ export const ClientLogo = ({ width={256} height={64} alt={t("workspace.surveys.edit.company_logo")} + unoptimized={isExternalImageSrc(logoToUse?.url)} /> ) : disableLinks ? ( diff --git a/apps/web/modules/ui/components/connect-integration/index.tsx b/apps/web/modules/ui/components/connect-integration/index.tsx index 92ac5aed3e40..8cefedcd604c 100644 --- a/apps/web/modules/ui/components/connect-integration/index.tsx +++ b/apps/web/modules/ui/components/connect-integration/index.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { Trans, useTranslation } from "react-i18next"; import { TIntegrationType } from "@formbricks/types/integration"; +import { isExternalImageSrc } from "@/lib/image-hosts"; import { Button } from "@/modules/ui/components/button"; import { FormbricksLogo } from "@/modules/ui/components/formbricks-logo"; import { getIntegrationDetails } from "./lib/utils"; @@ -54,7 +55,12 @@ export const ConnectIntegration = ({

- logo + logo

{integrationDetails?.text}

diff --git a/apps/web/modules/ui/components/file-input/index.tsx b/apps/web/modules/ui/components/file-input/index.tsx index 90d96411a6c0..35de40efe134 100644 --- a/apps/web/modules/ui/components/file-input/index.tsx +++ b/apps/web/modules/ui/components/file-input/index.tsx @@ -7,6 +7,7 @@ import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { TAllowedFileExtension } from "@formbricks/types/storage"; import { cn } from "@/lib/cn"; +import { isExternalImageSrc } from "@/lib/image-hosts"; import { handleFileUpload } from "@/modules/storage/file-upload"; import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error"; import { LoadingSpinner } from "@/modules/ui/components/loading-spinner"; @@ -261,11 +262,12 @@ export const FileInput = ({ style={{ objectFit: "cover" }} quality={100} className={!file.uploaded ? "opacity-50" : ""} + unoptimized={isExternalImageSrc(file.url)} /> {file.uploaded ? (