Compare commits

..

1 Commits

Author SHA1 Message Date
Bhagya Amarasinghe
f737c8b76f feat: add provider-aware public rate limit routing 2026-03-24 15:04:36 +05:30
35 changed files with 518 additions and 304 deletions

View File

@@ -185,6 +185,10 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1
# Public unauthenticated IP-based rate limits can be handled by an edge provider.
# Supported values: none, cloudflare, cloudarmor, envoy
# EDGE_RATE_LIMIT_PROVIDER=none
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf

View File

@@ -1,7 +1,6 @@
"use client";
import clsx from "clsx";
import { TFunction } from "i18next";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -55,25 +54,6 @@ export enum OptionsType {
QUOTAS = "Quotas",
}
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
switch (type) {
case OptionsType.ELEMENTS:
return t("common.elements");
case OptionsType.TAGS:
return t("common.tags");
case OptionsType.ATTRIBUTES:
return t("common.attributes");
case OptionsType.OTHERS:
return t("common.other_filters");
case OptionsType.META:
return t("common.meta");
case OptionsType.HIDDEN_FIELDS:
return t("common.hidden_fields");
case OptionsType.QUOTAS:
return t("common.quotas");
}
};
export type ElementOption = {
label: string;
elementType?: TSurveyElementTypeEnum;
@@ -238,12 +218,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
{options?.map((data) => (
<Fragment key={data.header}>
{data?.option.length > 0 && (
<CommandGroup
heading={
<p className="text-sm font-medium text-slate-600">
{getOptionsTypeTranslationKey(data.header, t)}
</p>
}>
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
{data?.option?.map((o) => (
<CommandItem
key={o.id}

View File

@@ -4,6 +4,11 @@ import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displ
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import {
applyPublicIpRateLimit,
publicEdgeRateLimitPolicies,
} from "@/modules/core/rate-limit/public-edge-rate-limit";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -24,6 +29,15 @@ export const OPTIONS = async (): Promise<Response> => {
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
try {
await applyPublicIpRateLimit(publicEdgeRateLimitPolicies.v2ClientDisplays, rateLimitConfigs.api.client);
} catch (error) {
return responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Rate limit exceeded",
true
);
}
const params = await context.params;
const jsonInput = await request.json();
const inputValidation = ZDisplayCreateInputV2.safeParse({

View File

@@ -14,6 +14,11 @@ import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import {
applyPublicIpRateLimit,
publicEdgeRateLimitPolicies,
} from "@/modules/core/rate-limit/public-edge-rate-limit";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { createResponseWithQuotaEvaluation } from "./lib/response";
@@ -36,6 +41,15 @@ export const OPTIONS = async (): Promise<Response> => {
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
try {
await applyPublicIpRateLimit(publicEdgeRateLimitPolicies.v2ClientResponses, rateLimitConfigs.api.client);
} catch (error) {
return responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Rate limit exceeded",
true
);
}
const params = await context.params;
const requestHeaders = await headers();
let responseInput;

View File

@@ -12,6 +12,10 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/types/audit-log", () => ({
UNKNOWN_DATA: "unknown",
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn((callback) => {
@@ -72,10 +76,13 @@ vi.mock("@/app/middleware/endpoint-validator", async () => {
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
applyRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/public-edge-rate-limit", () => ({
applyPublicIpRateLimitForRoute: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
@@ -115,6 +122,7 @@ describe("withV1ApiWrapper", () => {
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: true,
EDGE_RATE_LIMIT_PROVIDER: "none",
IS_PRODUCTION: true,
SENTRY_DSN: "dsn",
ENCRYPTION_KEY: "test-key",
@@ -131,11 +139,13 @@ describe("withV1ApiWrapper", () => {
});
test("logs and audits on error response with API key authentication", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -183,11 +193,13 @@ describe("withV1ApiWrapper", () => {
});
test("does not log Sentry if not 500", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -229,11 +241,13 @@ describe("withV1ApiWrapper", () => {
});
test("logs and audits on thrown error", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -285,11 +299,13 @@ describe("withV1ApiWrapper", () => {
});
test("does not log on success response but still audits", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -333,17 +349,20 @@ describe("withV1ApiWrapper", () => {
test("does not call audit if AUDIT_LOG_ENABLED is false", async () => {
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: false,
EDGE_RATE_LIMIT_PROVIDER: "none",
IS_PRODUCTION: true,
SENTRY_DSN: "dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
}));
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { withV1ApiWrapper } = await import("./with-api-logging");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
@@ -366,10 +385,13 @@ describe("withV1ApiWrapper", () => {
});
test("handles client-side API routes without authentication", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { applyPublicIpRateLimitForRoute } = await import(
"@/modules/core/rate-limit/public-edge-rate-limit"
);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
@@ -378,7 +400,7 @@ describe("withV1ApiWrapper", () => {
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(applyPublicIpRateLimitForRoute).mockResolvedValue("app");
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
@@ -396,11 +418,17 @@ describe("withV1ApiWrapper", () => {
auditLog: undefined,
authentication: null,
});
expect(applyPublicIpRateLimitForRoute).toHaveBeenCalledWith(
"/api/v1/client/displays",
"GET",
expect.objectContaining({ max: 100 })
);
});
test("returns authentication error for non-client routes without auth", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -422,8 +450,9 @@ describe("withV1ApiWrapper", () => {
});
test("uses unauthenticatedResponse when provided instead of default 401", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { getServerSession } = await import("next-auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -455,8 +484,9 @@ describe("withV1ApiWrapper", () => {
test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
@@ -481,11 +511,13 @@ describe("withV1ApiWrapper", () => {
});
test("skips audit log creation when no action/targetType provided", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });

View File

@@ -13,7 +13,8 @@ import {
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { applyPublicIpRateLimitForRoute } from "@/modules/core/rate-limit/public-edge-rate-limit";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
@@ -54,14 +55,22 @@ enum ApiV1RouteTypeEnum {
/**
* Apply client-side API rate limiting (IP-based)
*/
const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
const applyClientRateLimit = async (
req: NextRequest,
customRateLimitConfig?: TRateLimitConfig
): Promise<void> => {
await applyPublicIpRateLimitForRoute(
req.nextUrl.pathname,
req.method,
customRateLimitConfig ?? rateLimitConfigs.api.client
);
};
/**
* Handle rate limiting based on authentication and API type
*/
const handleRateLimiting = async (
req: NextRequest,
authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum,
customRateLimitConfig?: TRateLimitConfig
@@ -81,7 +90,7 @@ const handleRateLimiting = async (
}
if (routeType === ApiV1RouteTypeEnum.Client) {
await applyClientRateLimit(customRateLimitConfig);
await applyClientRateLimit(req, customRateLimitConfig);
}
} catch (error) {
return responses.tooManyRequestsResponse(error instanceof Error ? error.message : "Rate limit exceeded");
@@ -305,7 +314,12 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
// === Rate Limiting ===
if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
const rateLimitResponse = await handleRateLimiting(
req,
authentication,
routeType,
customRateLimitConfig
);
if (rateLimitResponse) return rateLimitResponse;
}

View File

@@ -48,6 +48,10 @@ describe("endpoint-validator", () => {
isClientSideApi: true,
isRateLimited: false,
});
expect(isClientSideApiRoute("/api/v1/client/og-image")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
});
test("should return false for non-client-side API routes", () => {

View File

@@ -13,7 +13,7 @@ export enum AuthenticationMethod {
export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; isRateLimited: boolean } => {
// Open Graph image generation route is a client side API route but it should not be rate limited
if (url.includes("/api/v1/client/og")) return { isClientSideApi: true, isRateLimited: false };
if (/^\/api\/v1\/client\/og(?:\/.*)?$/.test(url)) return { isClientSideApi: true, isRateLimited: false };
const regex = /^\/api\/v\d+\/client\//;
return { isClientSideApi: regex.test(url), isRateLimited: true };

View File

@@ -188,7 +188,6 @@ checksums:
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
common/edit: eee7f39ff90b18852afc1671f21fbaa9
common/elements: 8cb054d952b341e5965284860d532bc7
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
@@ -259,7 +258,6 @@ checksums:
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership: 83c856bbc2af99d8c3d860959d1d2a85
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/meta: 842eac888f134f3525f8ea613d933687
common/metadata: 695d4f7da261ba76e3be4de495491028
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
@@ -301,7 +299,6 @@ checksums:
common/organization_id: ef09b71c84a25b5da02a23c77e68a335
common/organization_settings: 11528aa89ae9935e55dcb54478058775
common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5

View File

@@ -3,6 +3,7 @@ import { TUserLocale } from "@formbricks/types/user";
import { env } from "./env";
export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1";
export const EDGE_RATE_LIMIT_PROVIDER = env.EDGE_RATE_LIMIT_PROVIDER ?? "none";
export const IS_PRODUCTION = env.NODE_ENV === "production";

View File

@@ -21,6 +21,7 @@ export const env = createEnv({
E2E_TESTING: z.enum(["1", "0"]).optional(),
EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(),
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
EDGE_RATE_LIMIT_PROVIDER: z.enum(["none", "cloudflare", "cloudarmor", "envoy"]).optional(),
ENCRYPTION_KEY: z.string(),
ENTERPRISE_LICENSE_KEY: z.string().optional(),
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
@@ -147,6 +148,7 @@ export const env = createEnv({
E2E_TESTING: process.env.E2E_TESTING,
EMAIL_AUTH_DISABLED: process.env.EMAIL_AUTH_DISABLED,
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
EDGE_RATE_LIMIT_PROVIDER: process.env.EDGE_RATE_LIMIT_PROVIDER,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
ENVIRONMENT: process.env.ENVIRONMENT,

View File

@@ -215,7 +215,6 @@
"duplicate_copy_number": "(Kopie {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Bearbeiten",
"elements": "Elemente",
"email": "E-Mail",
"ending_card": "Abschluss-Karte",
"enter_url": "URL eingeben",
@@ -286,7 +285,6 @@
"members_and_teams": "Mitglieder & Teams",
"membership": "Mitgliedschaft",
"membership_not_found": "Mitgliedschaft nicht gefunden",
"meta": "Meta",
"metadata": "Metadaten",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
"mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
@@ -328,7 +326,6 @@
"organization_id": "Organisations-ID",
"organization_settings": "Organisationseinstellungen",
"other": "Andere",
"other_filters": "Weitere Filter",
"others": "Andere",
"overlay_color": "Overlay-Farbe",
"overview": "Überblick",

View File

@@ -215,7 +215,6 @@
"duplicate_copy_number": "(copy {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Edit",
"elements": "Elements",
"email": "Email",
"ending_card": "Ending card",
"enter_url": "Enter URL",
@@ -286,7 +285,6 @@
"members_and_teams": "Members & Teams",
"membership": "Membership",
"membership_not_found": "Membership not found",
"meta": "Meta",
"metadata": "Metadata",
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
"mobile_overlay_surveys_look_good": "Do not worry your surveys look great on every device and screen size!",
@@ -328,7 +326,6 @@
"organization_id": "Organization ID",
"organization_settings": "Organization settings",
"other": "Other",
"other_filters": "Other Filters",
"others": "Others",
"overlay_color": "Overlay color",
"overview": "Overview",

View File

@@ -215,7 +215,6 @@
"duplicate_copy_number": "(copia {copyNumber})",
"e_commerce": "Comercio electrónico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Tarjeta final",
"enter_url": "Introducir URL",
@@ -286,7 +285,6 @@
"members_and_teams": "Miembros y equipos",
"membership": "Membresía",
"membership_not_found": "Membresía no encontrada",
"meta": "Meta",
"metadata": "Metadatos",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.",
"mobile_overlay_surveys_look_good": "No te preocupes ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
@@ -328,7 +326,6 @@
"organization_id": "ID de organización",
"organization_settings": "Ajustes de la organización",
"other": "Otro",
"other_filters": "Otros Filtros",
"others": "Otros",
"overlay_color": "Color de superposición",
"overview": "Resumen",

View File

@@ -215,7 +215,6 @@
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Modifier",
"elements": "Éléments",
"email": "Email",
"ending_card": "Carte de fin",
"enter_url": "Saisir l'URL",
@@ -286,7 +285,6 @@
"members_and_teams": "Membres & Équipes",
"membership": "Adhésion",
"membership_not_found": "Abonnement non trouvé",
"meta": "Méta",
"metadata": "Métadonnées",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
@@ -328,7 +326,6 @@
"organization_id": "Identifiant de l'organisation",
"organization_settings": "Paramètres de l'organisation",
"other": "Autre",
"other_filters": "Autres filtres",
"others": "Autres",
"overlay_color": "Couleur de superposition",
"overview": "Aperçu",

View File

@@ -215,7 +215,6 @@
"duplicate_copy_number": "({copyNumber}. másolat)",
"e_commerce": "E-kereskedelem",
"edit": "Szerkesztés",
"elements": "Elemek",
"email": "E-mail",
"ending_card": "Befejező kártya",
"enter_url": "URL megadása",
@@ -286,7 +285,6 @@
"members_and_teams": "Tagok és csapatok",
"membership": "Tagság",
"membership_not_found": "A tagság nem található",
"meta": "Meta",
"metadata": "Metaadatok",
"mobile_overlay_app_works_best_on_desktop": "A Formbricks nagyobb képernyőn működik a legjobban. A kérdőívek kezeléséhez vagy összeállításához váltson másik eszközre.",
"mobile_overlay_surveys_look_good": "Ne aggódjon a kérdőívei minden eszközön és képernyőméretnél remekül néznek ki!",
@@ -328,7 +326,6 @@
"organization_id": "Szervezetazonosító",
"organization_settings": "Szervezet beállításai",
"other": "Egyéb",
"other_filters": "Egyéb szűrők",
"others": "Mások",
"overlay_color": "Rávetítés színe",
"overview": "Áttekintés",

View File

@@ -215,7 +215,6 @@
"duplicate_copy_number": "(コピー {copyNumber})",
"e_commerce": "Eコマース",
"edit": "編集",
"elements": "要素",
"email": "メールアドレス",
"ending_card": "終了カード",
"enter_url": "URLを入力",
@@ -286,7 +285,6 @@
"members_and_teams": "メンバー&チーム",
"membership": "メンバーシップ",
"membership_not_found": "メンバーシップが見つかりません",
"meta": "メタ",
"metadata": "メタデータ",
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
@@ -328,7 +326,6 @@
"organization_id": "組織ID",
"organization_settings": "組織設定",
"other": "その他",
"other_filters": "その他のフィルター",
"others": "その他",
"overlay_color": "オーバーレイの色",
"overview": "概要",

View File

@@ -215,7 +215,6 @@
"duplicate_copy_number": "(kopie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Bewerking",
"elements": "Elementen",
"email": "E-mail",
"ending_card": "Einde kaart",
"enter_url": "URL invoeren",
@@ -286,7 +285,6 @@
"members_and_teams": "Leden & teams",
"membership": "Lidmaatschap",
"membership_not_found": "Lidmaatschap niet gevonden",
"meta": "Meta",
"metadata": "Metagegevens",
"mobile_overlay_app_works_best_on_desktop": "Formbricks werkt het beste op een groter scherm. Schakel over naar een ander apparaat om enquêtes te beheren of samen te stellen.",
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
@@ -328,7 +326,6 @@
"organization_id": "Organisatie-ID",
"organization_settings": "Organisatie-instellingen",
"other": "Ander",
"other_filters": "Overige filters",
"others": "Anderen",
"overlay_color": "Overlaykleur",
"overview": "Overzicht",

View File

@@ -215,7 +215,6 @@
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "comércio eletrônico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Cartão de encerramento",
"enter_url": "Inserir URL",
@@ -286,7 +285,6 @@
"members_and_teams": "Membros e equipes",
"membership": "Associação",
"membership_not_found": "Assinatura não encontrada",
"meta": "Meta",
"metadata": "metadados",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
@@ -328,7 +326,6 @@
"organization_id": "ID da Organização",
"organization_settings": "Configurações da Organização",
"other": "outro",
"other_filters": "Outros Filtros",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão Geral",

View File

@@ -215,7 +215,6 @@
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "Comércio Eletrónico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Cartão de encerramento",
"enter_url": "Introduzir URL",
@@ -286,7 +285,6 @@
"members_and_teams": "Membros e equipas",
"membership": "Subscrição",
"membership_not_found": "Associação não encontrada",
"meta": "Meta",
"metadata": "Metadados",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
@@ -328,7 +326,6 @@
"organization_id": "ID da Organização",
"organization_settings": "Configurações da Organização",
"other": "Outro",
"other_filters": "Outros Filtros",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão geral",

View File

@@ -215,7 +215,6 @@
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "Comerț electronic",
"edit": "Editare",
"elements": "Elemente",
"email": "Email",
"ending_card": "Cardul de finalizare",
"enter_url": "Introduceți URL-ul",
@@ -286,7 +285,6 @@
"members_and_teams": "Membri și echipe",
"membership": "Abonament",
"membership_not_found": "Apartenența nu a fost găsită",
"meta": "Meta",
"metadata": "Metadate",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
"mobile_overlay_surveys_look_good": "Nu vă faceți griji chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
@@ -328,7 +326,6 @@
"organization_id": "ID Organizație",
"organization_settings": "Setări Organizație",
"other": "Altele",
"other_filters": "Alte Filtre",
"others": "Altele",
"overlay_color": "Culoare overlay",
"overview": "Prezentare generală",

View File

@@ -215,7 +215,6 @@
"duplicate_copy_number": "(копия {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Редактировать",
"elements": "Элементы",
"email": "Email",
"ending_card": "Завершающая карточка",
"enter_url": "Введите URL",
@@ -286,7 +285,6 @@
"members_and_teams": "Участники и команды",
"membership": "Членство",
"membership_not_found": "Участие не найдено",
"meta": "Мета",
"metadata": "Метаданные",
"mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.",
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
@@ -328,7 +326,6 @@
"organization_id": "ID организации",
"organization_settings": "Настройки организации",
"other": "Другое",
"other_filters": "Другие фильтры",
"others": "Другие",
"overlay_color": "Цвет наложения",
"overview": "Обзор",

View File

@@ -215,7 +215,6 @@
"duplicate_copy_number": "(kopia {copyNumber})",
"e_commerce": "E-handel",
"edit": "Redigera",
"elements": "Element",
"email": "E-post",
"ending_card": "Avslutningskort",
"enter_url": "Ange URL",
@@ -286,7 +285,6 @@
"members_and_teams": "Medlemmar och team",
"membership": "Medlemskap",
"membership_not_found": "Medlemskap hittades inte",
"meta": "Meta",
"metadata": "Metadata",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fungerar bäst på en större skärm. Byt till en annan enhet för att hantera eller bygga enkäter.",
"mobile_overlay_surveys_look_good": "Oroa dig inte dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
@@ -328,7 +326,6 @@
"organization_id": "Organisations-ID",
"organization_settings": "Organisationsinställningar",
"other": "Annat",
"other_filters": "Andra filter",
"others": "Andra",
"overlay_color": "Overlay-färg",
"overview": "Översikt",

View File

@@ -215,7 +215,6 @@
"duplicate_copy_number": "(副本 {copyNumber}",
"e_commerce": "电子商务",
"edit": "编辑",
"elements": "元素",
"email": "邮箱",
"ending_card": "结尾卡片",
"enter_url": "输入 URL",
@@ -286,7 +285,6 @@
"members_and_teams": "成员和团队",
"membership": "会员资格",
"membership_not_found": "未找到会员资格",
"meta": "元数据",
"metadata": "元数据",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
"mobile_overlay_surveys_look_good": "别 担心 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
@@ -328,7 +326,6 @@
"organization_id": "组织 ID",
"organization_settings": "组织 设置",
"other": "其他",
"other_filters": "其他筛选条件",
"others": "其他",
"overlay_color": "覆盖层颜色",
"overview": "概览",

View File

@@ -215,7 +215,6 @@
"duplicate_copy_number": "(複製 {copyNumber}",
"e_commerce": "電子商務",
"edit": "編輯",
"elements": "元素",
"email": "電子郵件",
"ending_card": "結尾卡片",
"enter_url": "輸入 URL",
@@ -286,7 +285,6 @@
"members_and_teams": "成員與團隊",
"membership": "會員資格",
"membership_not_found": "找不到成員資格",
"meta": "Meta",
"metadata": "元數據",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
@@ -328,7 +326,6 @@
"organization_id": "組織 ID",
"organization_settings": "組織設定",
"other": "其他",
"other_filters": "其他篩選條件",
"others": "其他",
"overlay_color": "覆蓋層顏色",
"overview": "概覽",

View File

@@ -3,11 +3,14 @@ import { Provider } from "next-auth/providers/index";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
// Import mocked rate limiting functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import {
applyPublicIpRateLimit,
publicEdgeRateLimitPolicies,
} from "@/modules/core/rate-limit/public-edge-rate-limit";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { getUserByEmail } from "./user";
import { hashPassword } from "./utils";
// Mock encryption utilities
@@ -19,11 +22,48 @@ vi.mock("@/lib/encryption", () => ({
// Mock JWT
vi.mock("@/lib/jwt");
// Mock rate limiting dependencies
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
vi.mock("@/modules/core/rate-limit/public-edge-rate-limit", () => ({
applyPublicIpRateLimit: vi.fn(),
publicEdgeRateLimitPolicies: {
authLogin: "auth.login",
authVerifyEmail: "auth.verify_email",
},
}));
vi.mock("./user", () => ({
getUserByEmail: vi.fn(),
updateUser: vi.fn(),
updateUserLastLoginAt: vi.fn(),
}));
vi.mock("./brevo", () => ({
createBrevoCustomer: vi.fn(),
}));
vi.mock("@/modules/ee/sso/lib/providers", () => ({
getSSOProviders: vi.fn(() => []),
}));
vi.mock("@/modules/ee/sso/lib/sso-handlers", () => ({
handleSsoCallback: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/types/audit-log", () => ({
UNKNOWN_DATA: "unknown",
}));
vi.mock("./utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("./utils")>();
return {
...actual,
shouldLogAuthFailure: vi.fn().mockResolvedValue(false),
};
});
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
auth: {
@@ -33,26 +73,22 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
},
}));
// Mock constants that this test needs while preserving untouched exports.
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: undefined,
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
};
});
vi.mock("@/lib/constants", () => ({
EMAIL_VERIFICATION_DISABLED: false,
EDGE_RATE_LIMIT_PROVIDER: "none",
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: undefined,
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
}));
// Mock next/headers
vi.mock("next/headers", () => ({
@@ -114,7 +150,7 @@ describe("authOptions", () => {
});
test("should throw error if user not found", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(null);
const credentials = { email: mockUser.email, password: mockPassword };
@@ -125,7 +161,7 @@ describe("authOptions", () => {
});
test("should throw error if user has no password stored", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
@@ -140,7 +176,7 @@ describe("authOptions", () => {
});
test("should throw error if password verification fails", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
@@ -155,7 +191,7 @@ describe("authOptions", () => {
});
test("should successfully login when credentials are valid", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
const fakeUser = {
id: mockUserId,
email: mockUser.email,
@@ -178,7 +214,7 @@ describe("authOptions", () => {
describe("Rate Limiting", () => {
test("should apply rate limiting before credential validation", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
@@ -191,12 +227,15 @@ describe("authOptions", () => {
await credentialsProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.login);
expect(applyIPRateLimit).toHaveBeenCalledBefore(prisma.user.findUnique as any);
expect(applyPublicIpRateLimit).toHaveBeenCalledWith(
publicEdgeRateLimitPolicies.authLogin,
rateLimitConfigs.auth.login
);
expect(applyPublicIpRateLimit).toHaveBeenCalledBefore(prisma.user.findUnique as any);
});
test("should block login when rate limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
vi.mocked(applyPublicIpRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
const findUniqueSpy = vi.spyOn(prisma.user, "findUnique");
@@ -211,7 +250,7 @@ describe("authOptions", () => {
});
test("should use correct rate limit configuration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
@@ -224,7 +263,7 @@ describe("authOptions", () => {
await credentialsProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith({
expect(applyPublicIpRateLimit).toHaveBeenCalledWith(publicEdgeRateLimitPolicies.authLogin, {
interval: 900,
allowedPerInterval: 30,
namespace: "auth:login",
@@ -234,7 +273,7 @@ describe("authOptions", () => {
describe("Two-Factor Backup Code login", () => {
test("should throw error if backup codes are missing", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
@@ -263,7 +302,7 @@ describe("authOptions", () => {
});
test("should throw error if token is invalid or user not found", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
const credentials = { token: "badtoken" };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
@@ -273,17 +312,20 @@ describe("authOptions", () => {
describe("Rate Limiting", () => {
test("should apply rate limiting before token verification", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
const credentials = { token: "sometoken" };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow();
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
expect(applyPublicIpRateLimit).toHaveBeenCalledWith(
publicEdgeRateLimitPolicies.authVerifyEmail,
rateLimitConfigs.auth.verifyEmail
);
});
test("should block verification when rate limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
vi.mocked(applyPublicIpRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
const findUniqueSpy = vi.spyOn(prisma.user, "findUnique");
@@ -302,7 +344,7 @@ describe("authOptions", () => {
describe("Callbacks", () => {
describe("jwt callback", () => {
test("should add profile information to token if user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue({
vi.mocked(getUserByEmail).mockResolvedValue({
id: mockUser.id,
locale: mockUser.locale,
email: mockUser.email,
@@ -321,7 +363,7 @@ describe("authOptions", () => {
});
test("should return token unchanged if no existing user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const token = { email: "nonexistent@example.com" };
if (!authOptions.callbacks?.jwt) {
@@ -366,7 +408,7 @@ describe("authOptions", () => {
const credentialsProvider = getProviderById("credentials");
test("should throw error if TOTP code is missing when 2FA is enabled", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
@@ -384,7 +426,7 @@ describe("authOptions", () => {
});
test("should throw error if two factor secret is missing", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
const mockUser = {
id: mockUserId,
email: "2fa@example.com",

View File

@@ -23,7 +23,10 @@ import {
shouldLogAuthFailure,
verifyPassword,
} from "@/modules/auth/lib/utils";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import {
applyPublicIpRateLimit,
publicEdgeRateLimitPolicies,
} from "@/modules/core/rate-limit/public-edge-rate-limit";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
@@ -55,7 +58,7 @@ export const authOptions: NextAuthOptions = {
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
},
async authorize(credentials, _req) {
await applyIPRateLimit(rateLimitConfigs.auth.login);
await applyPublicIpRateLimit(publicEdgeRateLimitPolicies.authLogin, rateLimitConfigs.auth.login);
// Use email for rate limiting when available, fall back to "unknown_user" for credential validation
const identifier = credentials?.email || "unknown_user"; // NOSONAR // We want to check for empty strings
@@ -245,7 +248,10 @@ export const authOptions: NextAuthOptions = {
},
},
async authorize(credentials, _req) {
await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail);
await applyPublicIpRateLimit(
publicEdgeRateLimitPolicies.authVerifyEmail,
rateLimitConfigs.auth.verifyEmail
);
// For token verification, we can't rate limit effectively by token (single-use)
// So we use a generic identifier for token abuse attempts

View File

@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { applyIPRateLimit } from "./helpers";
import {
applyPublicIpRateLimit,
applyPublicIpRateLimitForRoute,
getEdgeRateLimitProvider,
getPublicEdgeRateLimitPolicyId,
isPublicEdgeRateLimitManaged,
publicEdgeRateLimitPolicies,
} from "./public-edge-rate-limit";
vi.mock("./helpers", () => ({
applyIPRateLimit: vi.fn(),
}));
const mockConfig = {
interval: 60,
allowedPerInterval: 100,
namespace: "api:client",
};
describe("public-edge-rate-limit", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getEdgeRateLimitProvider", () => {
test("falls back to none for unknown providers", () => {
expect(getEdgeRateLimitProvider(undefined)).toBe("none");
expect(getEdgeRateLimitProvider("unknown")).toBe("none");
});
test("accepts configured providers", () => {
expect(getEdgeRateLimitProvider("cloudflare")).toBe("cloudflare");
expect(getEdgeRateLimitProvider("cloudarmor")).toBe("cloudarmor");
expect(getEdgeRateLimitProvider("envoy")).toBe("envoy");
});
});
describe("getPublicEdgeRateLimitPolicyId", () => {
test("classifies auth callback routes", () => {
expect(getPublicEdgeRateLimitPolicyId("/api/auth/callback/credentials", "POST")).toBe(
publicEdgeRateLimitPolicies.authLogin
);
expect(getPublicEdgeRateLimitPolicyId("/api/auth/callback/token", "POST")).toBe(
publicEdgeRateLimitPolicies.authVerifyEmail
);
});
test("classifies v1 client routes", () => {
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/env_123/environment", "GET")).toBe(
publicEdgeRateLimitPolicies.v1ClientDefault
);
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/env_123/storage", "POST")).toBe(
publicEdgeRateLimitPolicies.v1ClientStorageUpload
);
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/og", "GET")).toBeNull();
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/og/image", "GET")).toBeNull();
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/og-image", "GET")).toBe(
publicEdgeRateLimitPolicies.v1ClientDefault
);
});
test("classifies v2 public write routes", () => {
expect(getPublicEdgeRateLimitPolicyId("/api/v2/client/env_123/responses", "POST")).toBe(
publicEdgeRateLimitPolicies.v2ClientResponses
);
expect(getPublicEdgeRateLimitPolicyId("/api/v2/client/env_123/responses/resp_123", "PUT")).toBe(
publicEdgeRateLimitPolicies.v2ClientResponses
);
expect(getPublicEdgeRateLimitPolicyId("/api/v2/client/env_123/displays", "POST")).toBe(
publicEdgeRateLimitPolicies.v2ClientDisplays
);
expect(getPublicEdgeRateLimitPolicyId("/api/v2/client/env_123/storage", "POST")).toBe(
publicEdgeRateLimitPolicies.v2ClientStorageUpload
);
});
});
describe("isPublicEdgeRateLimitManaged", () => {
test("manages public policies on cloudflare and cloudarmor only", () => {
expect(isPublicEdgeRateLimitManaged(publicEdgeRateLimitPolicies.authLogin, "cloudflare")).toBe(true);
expect(isPublicEdgeRateLimitManaged(publicEdgeRateLimitPolicies.authLogin, "cloudarmor")).toBe(true);
expect(isPublicEdgeRateLimitManaged(publicEdgeRateLimitPolicies.authLogin, "none")).toBe(false);
expect(isPublicEdgeRateLimitManaged(publicEdgeRateLimitPolicies.authLogin, "envoy")).toBe(false);
});
});
describe("applyPublicIpRateLimit", () => {
test("uses app rate limiting when no edge provider manages the policy", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const source = await applyPublicIpRateLimit(
publicEdgeRateLimitPolicies.v2ClientResponses,
mockConfig,
"none"
);
expect(source).toBe("app");
expect(applyIPRateLimit).toHaveBeenCalledWith(mockConfig);
});
test("skips app rate limiting when the edge provider manages the policy", async () => {
const source = await applyPublicIpRateLimit(
publicEdgeRateLimitPolicies.v2ClientResponses,
mockConfig,
"cloudflare"
);
expect(source).toBe("edge");
expect(applyIPRateLimit).not.toHaveBeenCalled();
});
});
describe("applyPublicIpRateLimitForRoute", () => {
test("uses the route classifier for managed public routes", async () => {
const source = await applyPublicIpRateLimitForRoute(
"/api/v2/client/env_123/displays",
"POST",
mockConfig,
"cloudarmor"
);
expect(source).toBe("edge");
expect(applyIPRateLimit).not.toHaveBeenCalled();
});
test("falls back to app rate limiting for unmanaged routes", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const source = await applyPublicIpRateLimitForRoute(
"/api/v1/client/env_123/environment",
"GET",
mockConfig,
"envoy"
);
expect(source).toBe("app");
expect(applyIPRateLimit).toHaveBeenCalledWith(mockConfig);
});
});
});

View File

@@ -0,0 +1,135 @@
import { EDGE_RATE_LIMIT_PROVIDER } from "@/lib/constants";
import { applyIPRateLimit } from "./helpers";
import { TRateLimitConfig } from "./types/rate-limit";
export const publicEdgeRateLimitPolicies = {
authLogin: "auth.login",
authVerifyEmail: "auth.verify_email",
v1ClientDefault: "client.v1.default",
v1ClientStorageUpload: "client.storage.upload.v1",
v2ClientResponses: "client.responses.v2",
v2ClientDisplays: "client.displays.v2",
v2ClientStorageUpload: "client.storage.upload.v2",
} as const;
export type TPublicEdgeRateLimitPolicyId =
(typeof publicEdgeRateLimitPolicies)[keyof typeof publicEdgeRateLimitPolicies];
export type TEdgeRateLimitProvider = "none" | "cloudflare" | "cloudarmor" | "envoy";
const managedPublicEdgePolicies = Object.values(
publicEdgeRateLimitPolicies
) as TPublicEdgeRateLimitPolicyId[];
const managedPublicEdgePoliciesByProvider: Record<
TEdgeRateLimitProvider,
readonly TPublicEdgeRateLimitPolicyId[]
> = {
none: [],
cloudflare: managedPublicEdgePolicies,
cloudarmor: managedPublicEdgePolicies,
envoy: [],
};
const normalizeEdgeRateLimitProvider = (provider: string | undefined): TEdgeRateLimitProvider => {
switch (provider) {
case "cloudflare":
case "cloudarmor":
case "envoy":
return provider;
default:
return "none";
}
};
const normalizePathname = (pathname: string): string => {
if (pathname.length > 1 && pathname.endsWith("/")) {
return pathname.slice(0, -1);
}
return pathname;
};
export const getEdgeRateLimitProvider = (
provider: string | undefined = EDGE_RATE_LIMIT_PROVIDER
): TEdgeRateLimitProvider => normalizeEdgeRateLimitProvider(provider);
export const getPublicEdgeRateLimitPolicyId = (
pathname: string,
method: string
): TPublicEdgeRateLimitPolicyId | null => {
const normalizedPathname = normalizePathname(pathname);
const normalizedMethod = method.toUpperCase();
if (normalizedMethod === "POST" && normalizedPathname === "/api/auth/callback/credentials") {
return publicEdgeRateLimitPolicies.authLogin;
}
if (normalizedMethod === "POST" && normalizedPathname === "/api/auth/callback/token") {
return publicEdgeRateLimitPolicies.authVerifyEmail;
}
if (/^\/api\/v1\/client\/og(?:\/.*)?$/.test(normalizedPathname)) {
return null;
}
if (/^\/api\/v1\/client\/[^/]+\/storage$/.test(normalizedPathname) && normalizedMethod === "POST") {
return publicEdgeRateLimitPolicies.v1ClientStorageUpload;
}
if (/^\/api\/v2\/client\/[^/]+\/storage$/.test(normalizedPathname) && normalizedMethod === "POST") {
return publicEdgeRateLimitPolicies.v2ClientStorageUpload;
}
if (
/^\/api\/v2\/client\/[^/]+\/responses(?:\/[^/]+)?$/.test(normalizedPathname) &&
(normalizedMethod === "POST" || normalizedMethod === "PUT")
) {
return publicEdgeRateLimitPolicies.v2ClientResponses;
}
if (/^\/api\/v2\/client\/[^/]+\/displays$/.test(normalizedPathname) && normalizedMethod === "POST") {
return publicEdgeRateLimitPolicies.v2ClientDisplays;
}
if (normalizedPathname.startsWith("/api/v1/client/")) {
return publicEdgeRateLimitPolicies.v1ClientDefault;
}
return null;
};
export const isPublicEdgeRateLimitManaged = (
policyId: TPublicEdgeRateLimitPolicyId,
provider: string | undefined = EDGE_RATE_LIMIT_PROVIDER
): boolean => managedPublicEdgePoliciesByProvider[getEdgeRateLimitProvider(provider)].includes(policyId);
export const applyPublicIpRateLimit = async (
policyId: TPublicEdgeRateLimitPolicyId,
config: TRateLimitConfig,
provider: string | undefined = EDGE_RATE_LIMIT_PROVIDER
): Promise<"app" | "edge"> => {
if (isPublicEdgeRateLimitManaged(policyId, provider)) {
return "edge";
}
await applyIPRateLimit(config);
return "app";
};
export const applyPublicIpRateLimitForRoute = async (
pathname: string,
method: string,
config: TRateLimitConfig,
provider: string | undefined = EDGE_RATE_LIMIT_PROVIDER
): Promise<"app" | "edge"> => {
const policyId = getPublicEdgeRateLimitPolicyId(pathname, method);
if (!policyId) {
await applyIPRateLimit(config);
return "app";
}
return await applyPublicIpRateLimit(policyId, config, provider);
};

View File

@@ -1313,26 +1313,11 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
);
await Promise.all(
hobbySubscriptions.map(async ({ subscription }) => {
try {
await client.subscriptions.cancel(subscription.id, {
prorate: false,
});
} catch (err) {
if (
err instanceof Stripe.errors.StripeInvalidRequestError &&
err.statusCode === 404 &&
err.code === "resource_missing"
) {
logger.warn(
{ subscriptionId: subscription.id, organizationId },
"Subscription already deleted, skipping cancel"
);
return;
}
throw err;
}
})
hobbySubscriptions.map(({ subscription }) =>
client.subscriptions.cancel(subscription.id, {
prorate: false,
})
)
);
return;
}

View File

@@ -186,6 +186,7 @@ export const testInputValidation = async (service: Function, ...args: any[]): Pr
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
EDGE_RATE_LIMIT_PROVIDER: "none",
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",

View File

@@ -1,109 +0,0 @@
import { describe, expect, it } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import type { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
/**
* Test to verify that the elementChoices useMemo properly filters out undefined values
* when shuffledChoicesIds contains IDs that don't exist in element.choices.
*
* This test simulates the bug scenario where:
* 1. shuffledChoicesIds contains a choice ID
* 2. element.choices.find() returns undefined for that ID
* 3. The undefined value should be filtered out to prevent TypeError
*/
describe("MultipleChoiceMultiElement - elementChoices filtering", () => {
it("should filter out undefined choices when shuffled IDs don't match", () => {
// Simulate the scenario where shuffledChoicesIds might contain IDs
// that are no longer in element.choices
const mockElement: TSurveyMultipleChoiceElement = {
id: "test-element",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Test Question" },
required: false,
shuffleOption: "all",
choices: [
{ id: "choice-1", label: { default: "Option 1" } },
{ id: "choice-2", label: { default: "Option 2" } },
{ id: "choice-3", label: { default: "Option 3" } },
],
};
// Simulate shuffledChoicesIds that includes an ID not in element.choices
const shuffledChoicesIds = ["choice-1", "invalid-id", "choice-2", "choice-3"];
// This simulates the logic in the elementChoices useMemo
const elementChoices = shuffledChoicesIds
.map((choiceId) => {
const choice = mockElement.choices.find((currentChoice) => {
return currentChoice.id === choiceId;
});
return choice;
})
.filter((choice): choice is NonNullable<typeof choice> => choice !== undefined);
// Verify that undefined values are filtered out
expect(elementChoices).toHaveLength(3);
expect(elementChoices.every((choice) => choice !== undefined)).toBe(true);
// Verify that we can safely create a Set from the filtered choices
const knownLabels = new Set(
elementChoices.filter((c) => c && c.id !== "other").map((c) => c!.label.default)
);
expect(knownLabels.size).toBe(3);
expect(() => knownLabels.has("Option 1")).not.toThrow();
});
it("should handle empty choices array", () => {
const mockElement: TSurveyMultipleChoiceElement = {
id: "test-element",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Test Question" },
required: false,
shuffleOption: "all",
choices: [],
};
const shuffledChoicesIds: string[] = [];
const elementChoices = shuffledChoicesIds
.map((choiceId) => {
const choice = mockElement.choices.find((currentChoice) => {
return currentChoice.id === choiceId;
});
return choice;
})
.filter((choice): choice is NonNullable<typeof choice> => choice !== undefined);
expect(elementChoices).toHaveLength(0);
});
it("should preserve all choices when all IDs are valid", () => {
const mockElement: TSurveyMultipleChoiceElement = {
id: "test-element",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Test Question" },
required: false,
shuffleOption: "all",
choices: [
{ id: "choice-1", label: { default: "Option 1" } },
{ id: "choice-2", label: { default: "Option 2" } },
],
};
const shuffledChoicesIds = ["choice-2", "choice-1"];
const elementChoices = shuffledChoicesIds
.map((choiceId) => {
const choice = mockElement.choices.find((currentChoice) => {
return currentChoice.id === choiceId;
});
return choice;
})
.filter((choice): choice is NonNullable<typeof choice> => choice !== undefined);
expect(elementChoices).toHaveLength(2);
expect(elementChoices[0].id).toBe("choice-2");
expect(elementChoices[1].id).toBe("choice-1");
});
});

View File

@@ -51,14 +51,12 @@ export function MultipleChoiceMultiElement({
return [];
}
if (element.shuffleOption === "none" || element.shuffleOption === undefined) return element.choices;
return shuffledChoicesIds
.map((choiceId) => {
const choice = element.choices.find((currentChoice) => {
return currentChoice.id === choiceId;
});
return choice;
})
.filter((choice): choice is NonNullable<typeof choice> => choice !== undefined);
return shuffledChoicesIds.map((choiceId) => {
const choice = element.choices.find((currentChoice) => {
return currentChoice.id === choiceId;
});
return choice;
});
}, [element.choices, element.shuffleOption, shuffledChoicesIds]);
const otherOption = useMemo(

View File

@@ -51,14 +51,12 @@ export function MultipleChoiceSingleElement({
return [];
}
if (element.shuffleOption === "none" || element.shuffleOption === undefined) return element.choices;
return shuffledChoicesIds
.map((choiceId) => {
const choice = element.choices.find((selectedChoice) => {
return selectedChoice.id === choiceId;
});
return choice;
})
.filter((choice): choice is NonNullable<typeof choice> => choice !== undefined);
return shuffledChoicesIds.map((choiceId) => {
const choice = element.choices.find((selectedChoice) => {
return selectedChoice.id === choiceId;
});
return choice;
});
}, [element.choices, element.shuffleOption, shuffledChoicesIds]);
const otherOption = useMemo(

View File

@@ -146,6 +146,7 @@
"E2E_TESTING",
"EMAIL_AUTH_DISABLED",
"EMAIL_VERIFICATION_DISABLED",
"EDGE_RATE_LIMIT_PROVIDER",
"ENCRYPTION_KEY",
"ENTERPRISE_LICENSE_KEY",
"ENVIRONMENT",