From 65fedfa9d9b58e8266db757f579db835e51462ad Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 15 Apr 2026 04:09:32 +0530 Subject: [PATCH] fix: prevent bypass of single-use survey restriction via v1 API --- .../[environmentId]/responses/route.test.ts | 151 ++++++++++++++++++ .../client/[environmentId]/responses/route.ts | 110 +++++++++++++ apps/web/vitestSetup.ts | 3 + 3 files changed, 264 insertions(+) create mode 100644 apps/web/app/api/v1/client/[environmentId]/responses/route.test.ts diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/route.test.ts b/apps/web/app/api/v1/client/[environmentId]/responses/route.test.ts new file mode 100644 index 0000000000..cef7f60a20 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/responses/route.test.ts @@ -0,0 +1,151 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getSurvey } from "@/lib/survey/service"; +import { createResponseWithQuotaEvaluation } from "./lib/response"; +import { POST } from "./route"; + +vi.mock("next/headers", () => ({ + headers: vi.fn(() => new Headers()), +})); + +vi.mock("@/modules/core/rate-limit/helpers", () => ({ + applyRateLimit: vi.fn().mockResolvedValue(undefined), + applyIPRateLimit: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/survey/service", () => ({ + getSurvey: vi.fn(), +})); + +vi.mock("@/app/lib/pipelines", () => ({ + sendToPipeline: vi.fn(), +})); + +vi.mock("@/modules/storage/utils", () => ({ + validateFileUploads: vi.fn(() => true), +})); + +vi.mock("@/modules/api/lib/validation", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + validateResponseData: vi.fn(() => undefined), + }; +}); + +vi.mock("./lib/response", () => ({ + createResponseWithQuotaEvaluation: vi.fn(), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + withContext: vi.fn(() => ({ + warn: vi.fn(), + error: vi.fn(), + })), + }, +})); + +describe("POST /api/v1/client/:environmentId/responses (single-use enforcement)", () => { + const environmentId = "clxx1234567890123456789012"; + const surveyId = "clzz9876543210987654321098"; + + beforeEach(() => { + vi.resetAllMocks(); + + vi.mocked(getSurvey).mockResolvedValue({ + id: surveyId, + environmentId, + type: "link", + singleUse: { + enabled: true, + isEncrypted: false, + }, + isCaptureIpEnabled: false, + questions: [], + blocks: [], + } as any); + }); + + test("returns 400 when singleUseId is missing", async () => { + const req = new NextRequest(`http://localhost/api/v1/client/${environmentId}/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + "user-agent": "vitest", + }, + body: JSON.stringify({ + surveyId, + finished: true, + data: {}, + meta: { + url: `https://example.com/s/${surveyId}?suId=abc`, + }, + }), + }); + + const res = await POST(req, { params: Promise.resolve({ environmentId }) } as any); + + expect(res.status).toBe(400); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + + const body = await res.json(); + expect(body.code).toBe("bad_request"); + expect(body.message).toBe("Missing single use id"); + expect(createResponseWithQuotaEvaluation).not.toHaveBeenCalled(); + }); + + test("returns 400 when singleUseId is null", async () => { + const req = new NextRequest(`http://localhost/api/v1/client/${environmentId}/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + "user-agent": "vitest", + }, + body: JSON.stringify({ + surveyId, + singleUseId: null, + finished: true, + data: {}, + meta: { + url: `https://example.com/s/${surveyId}?suId=abc`, + }, + }), + }); + + const res = await POST(req, { params: Promise.resolve({ environmentId }) } as any); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.message).toBe("Missing single use id"); + expect(createResponseWithQuotaEvaluation).not.toHaveBeenCalled(); + }); + + test("returns 400 when suId does not match singleUseId", async () => { + const req = new NextRequest(`http://localhost/api/v1/client/${environmentId}/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + "user-agent": "vitest", + }, + body: JSON.stringify({ + surveyId, + singleUseId: "abc", + finished: true, + data: {}, + meta: { + url: `https://example.com/s/${surveyId}?suId=def`, + }, + }), + }); + + const res = await POST(req, { params: Promise.resolve({ environmentId }) } as any); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.message).toBe("Invalid single use id"); + expect(createResponseWithQuotaEvaluation).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/route.ts b/apps/web/app/api/v1/client/[environmentId]/responses/route.ts index d88e4f8740..b3b5eed4c9 100644 --- a/apps/web/app/api/v1/client/[environmentId]/responses/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/responses/route.ts @@ -10,6 +10,8 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { sendToPipeline } from "@/app/lib/pipelines"; +import { ENCRYPTION_KEY } from "@/lib/constants"; +import { symmetricDecrypt } from "@/lib/crypto"; import { getSurvey } from "@/lib/survey/service"; import { getClientIpFromHeaders } from "@/lib/utils/client-ip"; import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper"; @@ -134,6 +136,114 @@ export const POST = withV1ApiWrapper({ }; } + if (survey.type === "link" && survey.singleUse?.enabled) { + if (!responseInputData.singleUseId) { + return { + response: responses.badRequestResponse( + "Missing single use id", + { + surveyId: survey.id, + environmentId, + }, + true + ), + }; + } + + if (!responseInputData.meta?.url) { + return { + response: responses.badRequestResponse( + "Missing or invalid URL in response metadata", + { + surveyId: survey.id, + environmentId, + }, + true + ), + }; + } + + let url: URL; + try { + url = new URL(responseInputData.meta.url); + } catch (error) { + return { + response: responses.badRequestResponse( + "Invalid URL in response metadata", + { + surveyId: survey.id, + environmentId, + error: error instanceof Error ? error.message : "Unknown error occurred", + }, + true + ), + }; + } + + const suId = url.searchParams.get("suId"); + if (!suId) { + return { + response: responses.badRequestResponse( + "Missing single use id", + { + surveyId: survey.id, + environmentId, + }, + true + ), + }; + } + + if (survey.singleUse.isEncrypted) { + if (!ENCRYPTION_KEY) { + logger.error({ url: req.url, surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set"); + return { + response: responses.internalServerErrorResponse("An unexpected error occurred.", true), + }; + } + + let decryptedSuId: string; + try { + decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY); + } catch { + return { + response: responses.badRequestResponse( + "Invalid single use id", + { + surveyId: survey.id, + environmentId, + }, + true + ), + }; + } + + if (decryptedSuId !== responseInputData.singleUseId) { + return { + response: responses.badRequestResponse( + "Invalid single use id", + { + surveyId: survey.id, + environmentId, + }, + true + ), + }; + } + } else if (responseInputData.singleUseId !== suId) { + return { + response: responses.badRequestResponse( + "Invalid single use id", + { + surveyId: survey.id, + environmentId, + }, + true + ), + }; + } + } + if (!validateFileUploads(responseInputData.data, survey.questions)) { return { response: responses.badRequestResponse("Invalid file upload response"), diff --git a/apps/web/vitestSetup.ts b/apps/web/vitestSetup.ts index dde2b62705..22dc510e67 100644 --- a/apps/web/vitestSetup.ts +++ b/apps/web/vitestSetup.ts @@ -242,5 +242,8 @@ vi.mock("@/lib/constants", () => ({ RATE_LIMITING_DISABLED: false, TELEMETRY_DISABLED: false, PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: 30, + POSTHOG_KEY: undefined, + AUDIT_LOG_ENABLED: false, + AUDIT_LOG_GET_USER_IP: false, CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q", }));