mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 11:28:58 -05:00
fix: prevent bypass of single-use survey restriction via v1 API
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user