From 71a85c7126b65639813c506d593463cd4bee88a5 Mon Sep 17 00:00:00 2001 From: Matti Nannt Date: Mon, 17 Nov 2025 08:33:52 +0100 Subject: [PATCH] feat: add CUID v1 validation for environment ID endpoints (#6827) Co-authored-by: Dhruwang --- .../[environmentId]/environment/route.ts | 28 +++++++++++++++-- .../client/[environmentId]/responses/route.ts | 4 +-- .../client/[environmentId]/responses/route.ts | 4 +-- .../v1/client/[environmentId]/user/route.ts | 30 ++++++++++++++++--- .../projects/settings/lib/project.test.ts | 6 ++-- packages/types/environment.ts | 12 ++++---- 6 files changed, 64 insertions(+), 20 deletions(-) diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/route.ts b/apps/web/app/api/v1/client/[environmentId]/environment/route.ts index 153316acd7..2eb121e96a 100644 --- a/apps/web/app/api/v1/client/[environmentId]/environment/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/environment/route.ts @@ -1,5 +1,6 @@ import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; +import { ZEnvironmentId } from "@formbricks/types/environment"; import { ResourceNotFoundError } from "@formbricks/types/errors"; import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState"; import { responses } from "@/app/lib/api/response"; @@ -28,15 +29,38 @@ export const GET = withV1ApiWrapper({ const params = await props.params; try { - // Simple validation for environmentId (faster than Zod for high-frequency endpoint) + // Basic type check for environmentId if (typeof params.environmentId !== "string") { return { response: responses.badRequestResponse("Environment ID is required", undefined, true), }; } + const environmentId = params.environmentId.trim(); + + // Validate CUID v1 format using Zod (matches Prisma schema @default(cuid())) + // This catches all invalid formats including: + // - null/undefined passed as string "null" or "undefined" + // - HTML-encoded placeholders like or %3C...%3E + // - Empty or whitespace-only IDs + // - Any other invalid CUID v1 format + const cuidValidation = ZEnvironmentId.safeParse(environmentId); + if (!cuidValidation.success) { + logger.warn( + { + environmentId: params.environmentId, + url: req.url, + validationError: cuidValidation.error.errors[0]?.message, + }, + "Invalid CUID v1 format detected" + ); + return { + response: responses.badRequestResponse("Invalid environment ID format", undefined, true), + }; + } + // Use optimized environment state fetcher with new caching approach - const environmentState = await getEnvironmentState(params.environmentId); + const environmentState = await getEnvironmentState(environmentId); const { data } = environmentState; return { 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 7d6f80a871..687269c29a 100644 --- a/apps/web/app/api/v1/client/[environmentId]/responses/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/responses/route.ts @@ -2,7 +2,7 @@ import { headers } from "next/headers"; import { NextRequest } from "next/server"; import { UAParser } from "ua-parser-js"; import { logger } from "@formbricks/logger"; -import { ZId } from "@formbricks/types/common"; +import { ZEnvironmentId } from "@formbricks/types/environment"; import { InvalidInputError } from "@formbricks/types/errors"; import { TResponseWithQuotaFull } from "@formbricks/types/quota"; import { TResponseInput, ZResponseInput } from "@formbricks/types/responses"; @@ -51,7 +51,7 @@ export const POST = withV1ApiWrapper({ } const { environmentId } = params; - const environmentIdValidation = ZId.safeParse(environmentId); + const environmentIdValidation = ZEnvironmentId.safeParse(environmentId); const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId }); if (!environmentIdValidation.success) { diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/route.ts b/apps/web/app/api/v2/client/[environmentId]/responses/route.ts index 56fb984ac7..9a920ece64 100644 --- a/apps/web/app/api/v2/client/[environmentId]/responses/route.ts +++ b/apps/web/app/api/v2/client/[environmentId]/responses/route.ts @@ -1,7 +1,7 @@ import { headers } from "next/headers"; import { UAParser } from "ua-parser-js"; import { logger } from "@formbricks/logger"; -import { ZId } from "@formbricks/types/common"; +import { ZEnvironmentId } from "@formbricks/types/environment"; import { InvalidInputError } from "@formbricks/types/errors"; import { TResponseWithQuotaFull } from "@formbricks/types/quota"; import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils"; @@ -43,7 +43,7 @@ export const POST = async (request: Request, context: Context): Promise or %3C...%3E + // - Empty or whitespace-only IDs + // - Any other invalid CUID v1 format + const cuidValidation = ZEnvironmentId.safeParse(environmentId); + if (!cuidValidation.success) { + logger.warn( + { + environmentId: params.environmentId, + url: req.url, + validationError: cuidValidation.error.errors[0]?.message, + }, + "Invalid CUID v1 format detected" + ); + return { + response: responses.badRequestResponse("Invalid environment ID format", undefined, true), + }; + } + const jsonInput = await req.json(); // Basic input validation without Zod overhead diff --git a/apps/web/modules/projects/settings/lib/project.test.ts b/apps/web/modules/projects/settings/lib/project.test.ts index e700bdd932..8ceb946010 100644 --- a/apps/web/modules/projects/settings/lib/project.test.ts +++ b/apps/web/modules/projects/settings/lib/project.test.ts @@ -26,7 +26,7 @@ const baseProject = { darkOverlay: false, environments: [ { - id: "prodenv", + id: "cmi2sra0j000004l73fvh7lhe", createdAt: new Date(), updatedAt: new Date(), type: "production" as TEnvironment["type"], @@ -34,7 +34,7 @@ const baseProject = { appSetupCompleted: false, }, { - id: "devenv", + id: "cmi2srt9q000104l7127e67v7", createdAt: new Date(), updatedAt: new Date(), type: "development" as TEnvironment["type"], @@ -155,7 +155,7 @@ describe("project lib", () => { vi.mocked(deleteFilesByEnvironmentId).mockResolvedValue({ ok: true, data: undefined }); const result = await deleteProject("p1"); expect(result).toEqual(baseProject); - expect(deleteFilesByEnvironmentId).toHaveBeenCalledWith("prodenv"); + expect(deleteFilesByEnvironmentId).toHaveBeenCalledWith("cmi2sra0j000004l73fvh7lhe"); }); test("logs error if file deletion fails", async () => { diff --git a/packages/types/environment.ts b/packages/types/environment.ts index 46fa63d027..9927201502 100644 --- a/packages/types/environment.ts +++ b/packages/types/environment.ts @@ -1,7 +1,11 @@ import { z } from "zod"; +export const ZEnvironmentId = z.string().cuid(); + +export type TEnvironmentId = z.infer; + export const ZEnvironment = z.object({ - id: z.string().cuid2(), + id: ZEnvironmentId, createdAt: z.date(), updatedAt: z.date(), type: z.enum(["development", "production"]), @@ -11,12 +15,6 @@ export const ZEnvironment = z.object({ export type TEnvironment = z.infer; -export const ZEnvironmentId = z.object({ - id: z.string(), -}); - -export type TEnvironmentId = z.infer; - export const ZEnvironmentUpdateInput = z.object({ type: z.enum(["development", "production"]), projectId: z.string(),