Compare commits

...

1 Commits

Author SHA1 Message Date
Matti Nannt 65c234776c fix: validate displayId ownership on response creation (ENG-825)
Adds workspace and survey ownership checks for the optional `displayId`
parameter in POST /api/v1/client/{workspaceId}/responses and
POST /api/v2/client/{workspaceId}/responses. Without this, an attacker
could link a display from their own workspace to a response in another
workspace, polluting display statistics and circumventing frequency controls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:24:29 +02:00
3 changed files with 67 additions and 2 deletions
@@ -2,7 +2,12 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -11,6 +16,7 @@ import {
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { getDisplayForResponseValidation } from "@/lib/display/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -104,6 +110,19 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
if (responseInput.displayId) {
const display = await getDisplayForResponseValidation(responseInput.displayId, tx);
if (!display) throw new InvalidInputError(`Display ${responseInput.displayId} not found`);
if (display.workspaceId !== workspaceId)
throw new InvalidInputError(`Display ${responseInput.displayId} belongs to a different workspace`);
if (display.surveyId !== responseInput.surveyId)
throw new InvalidInputError(
`Display ${responseInput.displayId} is associated with a different survey`
);
if (display.responseId)
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
}
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaClient = tx ?? prisma;
@@ -2,7 +2,12 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -12,6 +17,7 @@ import {
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { responseSelection } from "@/app/api/v1/client/[workspaceId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/types/response";
import { getDisplayForResponseValidation } from "@/lib/display/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -112,6 +118,19 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
if (responseInput.displayId) {
const display = await getDisplayForResponseValidation(responseInput.displayId, tx);
if (!display) throw new InvalidInputError(`Display ${responseInput.displayId} not found`);
if (display.workspaceId !== workspaceId)
throw new InvalidInputError(`Display ${responseInput.displayId} belongs to a different workspace`);
if (display.surveyId !== responseInput.surveyId)
throw new InvalidInputError(
`Display ${responseInput.displayId} is associated with a different survey`
);
if (display.responseId)
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
}
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaClient = tx ?? prisma;
+27
View File
@@ -146,6 +146,33 @@ export const getDisplaysBySurveyIdWithContact = reactCache(
}
);
export const getDisplayForResponseValidation = async (
displayId: string,
tx?: Prisma.TransactionClient
): Promise<{ surveyId: string; workspaceId: string; responseId: string | null } | null> => {
validateInputs([displayId, ZId]);
const client = tx ?? prisma;
try {
const display = await client.display.findUnique({
where: { id: displayId },
select: {
surveyId: true,
response: { select: { id: true } },
survey: { select: { workspaceId: true } },
},
});
if (!display) return null;
return {
surveyId: display.surveyId,
workspaceId: display.survey.workspaceId,
responseId: display.response?.id ?? null,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) throw new DatabaseError(error.message);
throw error;
}
};
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {