fix: prevent bypass of single-use survey restriction via v1 API

This commit is contained in:
Aryan
2026-04-15 04:09:32 +05:30
parent 439dd0b44e
commit 65fedfa9d9
3 changed files with 264 additions and 0 deletions
@@ -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<typeof import("@/modules/api/lib/validation")>();
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();
});
});
@@ -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"),
+3
View File
@@ -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",
}));