mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-16 11:30:48 -05:00
Compare commits
2 Commits
epic/v5
...
feat/v5-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd275198d4 | ||
|
|
87db001f41 |
135
apps/web/app/api/v1/management/me/route.test.ts
Normal file
135
apps/web/app/api/v1/management/me/route.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
headers: vi.fn(),
|
||||
apiKeyFindFirst: vi.fn(),
|
||||
apiKeyFindUnique: vi.fn(),
|
||||
apiKeyUpdate: vi.fn(),
|
||||
userFindUnique: vi.fn(),
|
||||
getSessionUser: vi.fn(),
|
||||
parseApiKeyV2: vi.fn(),
|
||||
hashSha256: vi.fn(),
|
||||
verifySecret: vi.fn(),
|
||||
applyRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: mocks.headers,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
apiKey: {
|
||||
findFirst: mocks.apiKeyFindFirst,
|
||||
findUnique: mocks.apiKeyFindUnique,
|
||||
update: mocks.apiKeyUpdate,
|
||||
},
|
||||
user: {
|
||||
findUnique: mocks.userFindUnique,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v1/management/me/lib/utils", () => ({
|
||||
getSessionUser: mocks.getSessionUser,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
parseApiKeyV2: mocks.parseApiKeyV2,
|
||||
hashSha256: mocks.hashSha256,
|
||||
verifySecret: mocks.verifySecret,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyRateLimit: mocks.applyRateLimit,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
api: {
|
||||
v1: { interval: 60, allowedPerInterval: 100, namespace: "api:v1" },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
CONTROL_HASH: "control-hash",
|
||||
};
|
||||
});
|
||||
|
||||
describe("api/v1/management/me route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("skips app rate limiting for the api-key branch because Envoy covers the route", async () => {
|
||||
mocks.headers.mockResolvedValue({
|
||||
get: (key: string) => (key === "x-api-key" ? "fbk_test" : null),
|
||||
});
|
||||
mocks.parseApiKeyV2.mockReturnValue(null);
|
||||
mocks.hashSha256.mockReturnValue("hashed-api-key");
|
||||
mocks.apiKeyFindFirst.mockResolvedValue({
|
||||
id: "api-key-1",
|
||||
hashedKey: "hashed-api-key",
|
||||
organizationId: "org-1",
|
||||
lastUsedAt: new Date(),
|
||||
apiKeyEnvironments: [
|
||||
{
|
||||
permission: "manage",
|
||||
environment: {
|
||||
id: "env-1",
|
||||
type: "production",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
projectId: "project-1",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "project-1",
|
||||
name: "Project 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.applyRateLimit.mockRejectedValue(new Error("should not be called"));
|
||||
|
||||
const { GET } = await import("./route");
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.applyRateLimit).not.toHaveBeenCalled();
|
||||
expect(await response.json()).toEqual({
|
||||
id: "env-1",
|
||||
type: "production",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "project-1",
|
||||
name: "Project 1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps app rate limiting for the session branch", async () => {
|
||||
mocks.headers.mockResolvedValue({
|
||||
get: () => null,
|
||||
});
|
||||
mocks.getSessionUser.mockResolvedValue({ id: "user-1" });
|
||||
mocks.userFindUnique.mockResolvedValue({ id: "user-1", email: "user@test.com" });
|
||||
mocks.applyRateLimit.mockResolvedValue({ allowed: true });
|
||||
|
||||
const { GET } = await import("./route");
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.applyRateLimit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ namespace: "api:v1" }),
|
||||
"user-1"
|
||||
);
|
||||
expect(await response.json()).toEqual({ id: "user-1", email: "user@test.com" });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { CONTROL_HASH } from "@/lib/constants";
|
||||
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
||||
import { isRouteRateLimitedByEnvoy } from "@/modules/core/rate-limit/envoy-rate-limit-coverage";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
|
||||
@@ -157,8 +158,16 @@ const handleApiKeyAuthentication = async (apiKey: string) => {
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitError = await checkRateLimit(apiKeyData.id);
|
||||
if (rateLimitError) return rateLimitError;
|
||||
if (
|
||||
!isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v1/management/me",
|
||||
method: "GET",
|
||||
authType: "apiKey",
|
||||
})
|
||||
) {
|
||||
const rateLimitError = await checkRateLimit(apiKeyData.id);
|
||||
if (rateLimitError) return rateLimitError;
|
||||
}
|
||||
|
||||
if (!isValidApiKeyEnvironment(apiKeyData)) {
|
||||
return responses.badRequestResponse("You can't use this method with this API key");
|
||||
|
||||
@@ -3,9 +3,16 @@ import { NextRequest } from "next/server";
|
||||
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
|
||||
import type { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
|
||||
import { responses } from "./response";
|
||||
|
||||
const AuthMethod = {
|
||||
ApiKey: "apiKey" as AuthenticationMethod,
|
||||
Session: "session" as AuthenticationMethod,
|
||||
Both: "both" as AuthenticationMethod,
|
||||
None: "none" as AuthenticationMethod,
|
||||
} as const;
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
__esModule: true,
|
||||
queueAuditEvent: vi.fn(),
|
||||
@@ -122,7 +129,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
@@ -198,7 +205,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
@@ -244,7 +251,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
@@ -318,7 +325,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
@@ -370,7 +377,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
@@ -425,7 +432,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
@@ -449,7 +456,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.None,
|
||||
authenticationMethod: AuthMethod.None,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
@@ -473,6 +480,62 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("skips app rate limiting for Envoy-covered client routes", async () => {
|
||||
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");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthMethod.None,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
});
|
||||
|
||||
const req = createMockRequest({ method: "POST", url: "/api/v1/client/env_123/storage" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(applyIPRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("keeps app rate limiting for uncovered client routes", async () => {
|
||||
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");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthMethod.None,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
});
|
||||
|
||||
const req = createMockRequest({ method: "GET", url: "/api/v2/client/env_123/environment" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns authentication error for non-client routes without auth", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
@@ -481,7 +544,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
@@ -504,7 +567,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.Session,
|
||||
authenticationMethod: AuthMethod.Session,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
@@ -528,7 +591,36 @@ describe("withV1ApiWrapper", () => {
|
||||
expect(mockContextualLoggerError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles rate limiting errors", async () => {
|
||||
test("keeps app rate limiting for uncovered session-authenticated management routes", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { getServerSession } = await import("next-auth");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthMethod.Both,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-1" } } as any);
|
||||
const rateLimitError = new Error("Rate limit exceeded");
|
||||
rateLimitError.message = "Rate limit exceeded";
|
||||
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ method: "POST", url: "https://api.test/api/v1/management/storage" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const customRateLimitConfig = { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" };
|
||||
const wrapped = withV1ApiWrapper({ handler, customRateLimitConfig });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(applyRateLimit).toHaveBeenCalledWith(customRateLimitConfig, "user-1");
|
||||
});
|
||||
|
||||
test("skips app rate limiting for Envoy-covered API-key management routes", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
@@ -538,21 +630,22 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
const rateLimitError = new Error("Rate limit exceeded");
|
||||
rateLimitError.message = "Rate limit exceeded";
|
||||
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
|
||||
vi.mocked(applyRateLimit).mockResolvedValue({ allowed: true });
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
expect(applyRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips audit log creation when no action/targetType provided", async () => {
|
||||
@@ -566,7 +659,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import {
|
||||
TEnvoyRateLimitAuthType,
|
||||
isRouteRateLimitedByEnvoy,
|
||||
} from "@/modules/core/rate-limit/envoy-rate-limit-coverage";
|
||||
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
|
||||
@@ -61,29 +65,58 @@ const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): P
|
||||
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
|
||||
};
|
||||
|
||||
const getEnvoyRateLimitAuthType = (
|
||||
authentication: TApiV1Authentication
|
||||
): TEnvoyRateLimitAuthType | "unknown" => {
|
||||
if (!authentication) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
if ("user" in authentication) {
|
||||
return "session";
|
||||
}
|
||||
|
||||
if ("apiKeyId" in authentication) {
|
||||
return "apiKey";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle rate limiting based on authentication and API type
|
||||
*/
|
||||
const handleRateLimiting = async (
|
||||
req: NextRequest,
|
||||
authentication: TApiV1Authentication,
|
||||
routeType: ApiV1RouteTypeEnum,
|
||||
customRateLimitConfig?: TRateLimitConfig
|
||||
): Promise<Response | null> => {
|
||||
const authType = getEnvoyRateLimitAuthType(authentication);
|
||||
|
||||
if (authType === "unknown") {
|
||||
logger.error({ authentication }, "Unknown authentication type");
|
||||
return responses.internalServerErrorResponse("Invalid authentication configuration");
|
||||
}
|
||||
|
||||
const isEnvoyManagedRateLimit = isRouteRateLimitedByEnvoy({
|
||||
pathname: req.nextUrl.pathname,
|
||||
method: req.method,
|
||||
authType,
|
||||
});
|
||||
|
||||
try {
|
||||
if (authentication) {
|
||||
if (authentication && !isEnvoyManagedRateLimit) {
|
||||
if ("user" in authentication) {
|
||||
// Session-based authentication for integration routes
|
||||
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.user.id);
|
||||
} else if ("apiKeyId" in authentication) {
|
||||
// API key authentication for general routes
|
||||
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.apiKeyId);
|
||||
} else {
|
||||
logger.error({ authentication }, "Unknown authentication type");
|
||||
return responses.internalServerErrorResponse("Invalid authentication configuration");
|
||||
}
|
||||
}
|
||||
|
||||
if (routeType === ApiV1RouteTypeEnum.Client) {
|
||||
if (routeType === ApiV1RouteTypeEnum.Client && !isEnvoyManagedRateLimit) {
|
||||
await applyClientRateLimit(customRateLimitConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -286,7 +319,12 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
|
||||
|
||||
// === Rate Limiting ===
|
||||
if (isRateLimited) {
|
||||
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
|
||||
const rateLimitResponse = await handleRateLimiting(
|
||||
req,
|
||||
authentication,
|
||||
routeType,
|
||||
customRateLimitConfig
|
||||
);
|
||||
if (rateLimitResponse) return rateLimitResponse;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getServerSession: vi.fn(),
|
||||
authorizePrivateDownload: vi.fn(),
|
||||
applyRateLimit: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
getFileStreamForDownload: vi.fn(),
|
||||
getErrorResponseFromStorageError: vi.fn(),
|
||||
logFileDeletion: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: mocks.getServerSession,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/storage/[environmentId]/[accessType]/[fileName]/lib/auth", () => ({
|
||||
authorizePrivateDownload: mocks.authorizePrivateDownload,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyRateLimit: mocks.applyRateLimit,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
storage: {
|
||||
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/storage/service", () => ({
|
||||
deleteFile: mocks.deleteFile,
|
||||
getFileStreamForDownload: mocks.getFileStreamForDownload,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/storage/utils", () => ({
|
||||
getErrorResponseFromStorageError: mocks.getErrorResponseFromStorageError,
|
||||
}));
|
||||
|
||||
vi.mock("./lib/audit-logs", () => ({
|
||||
logFileDeletion: mocks.logFileDeletion,
|
||||
}));
|
||||
|
||||
const createMockRequest = (pathname: string): NextRequest =>
|
||||
({
|
||||
method: "DELETE",
|
||||
url: `https://api.test${pathname}`,
|
||||
nextUrl: {
|
||||
pathname,
|
||||
},
|
||||
}) as unknown as NextRequest;
|
||||
|
||||
const ENVIRONMENT_ID = "cmfntxc7j0009ad01etyah1ys";
|
||||
|
||||
describe("storage delete route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getServerSession.mockResolvedValue({ user: { id: "session-user-1" } });
|
||||
mocks.deleteFile.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
test("skips app rate limiting for api-key deletes because Envoy covers the route", async () => {
|
||||
mocks.authorizePrivateDownload.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { authType: "apiKey", apiKeyId: "api-key-1" },
|
||||
});
|
||||
mocks.applyRateLimit.mockRejectedValue(new Error("should not be called"));
|
||||
|
||||
const request = createMockRequest(`/storage/${ENVIRONMENT_ID}/private/file.pdf`);
|
||||
const { DELETE } = await import("./route");
|
||||
const response = await DELETE(request, {
|
||||
params: Promise.resolve({
|
||||
environmentId: ENVIRONMENT_ID,
|
||||
accessType: "private",
|
||||
fileName: "file.pdf",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.applyRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("keeps app rate limiting for session-authenticated deletes", async () => {
|
||||
mocks.authorizePrivateDownload.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { authType: "session", userId: "user-1" },
|
||||
});
|
||||
mocks.applyRateLimit.mockResolvedValue({ allowed: true });
|
||||
|
||||
const request = createMockRequest(`/storage/${ENVIRONMENT_ID}/private/file.pdf`);
|
||||
const { DELETE } = await import("./route");
|
||||
const response = await DELETE(request, {
|
||||
params: Promise.resolve({
|
||||
environmentId: ENVIRONMENT_ID,
|
||||
accessType: "private",
|
||||
fileName: "file.pdf",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.applyRateLimit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ namespace: "storage:delete" }),
|
||||
"user-1"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { authorizePrivateDownload } from "@/app/storage/[workspaceId]/[accessType]/[fileName]/lib/auth";
|
||||
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { isRouteRateLimitedByEnvoy } from "@/modules/core/rate-limit/envoy-rate-limit-coverage";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { deleteFile, getFileStreamForDownload } from "@/modules/storage/service";
|
||||
@@ -124,7 +125,15 @@ export const DELETE = async (
|
||||
if (authResult.ok) {
|
||||
try {
|
||||
if (authResult.data.authType === "apiKey") {
|
||||
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.apiKeyId);
|
||||
if (
|
||||
!isRouteRateLimitedByEnvoy({
|
||||
pathname: request.nextUrl.pathname,
|
||||
method: request.method,
|
||||
authType: "apiKey",
|
||||
})
|
||||
) {
|
||||
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.apiKeyId);
|
||||
}
|
||||
} else {
|
||||
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
// Import mocked rate limiting functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { authOptions } from "./authOptions";
|
||||
import { mockUser } from "./mock-data";
|
||||
import { hashPassword } from "./utils";
|
||||
@@ -220,8 +219,8 @@ describe("authOptions", () => {
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should apply rate limiting before credential validation", async () => {
|
||||
describe("Envoy-managed callback behavior", () => {
|
||||
test("should not apply in-app rate limiting before credential validation", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUserId,
|
||||
@@ -235,27 +234,14 @@ describe("authOptions", () => {
|
||||
|
||||
await credentialsProvider.options.authorize(credentials, {});
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.login);
|
||||
expect(applyIPRateLimit).toHaveBeenCalledBefore(prisma.user.findUnique as any);
|
||||
expect(applyIPRateLimit).not.toHaveBeenCalled();
|
||||
expect(prisma.user.findUnique).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should block login when rate limit exceeded", async () => {
|
||||
test("should ignore app limiter errors because login is Envoy-managed", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockRejectedValue(
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
const findUniqueSpy = vi.spyOn(prisma.user, "findUnique");
|
||||
|
||||
const credentials = { email: mockUser.email, password: mockPassword };
|
||||
|
||||
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
|
||||
expect(findUniqueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should use correct rate limit configuration", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
@@ -266,13 +252,14 @@ describe("authOptions", () => {
|
||||
|
||||
const credentials = { email: mockUser.email, password: mockPassword };
|
||||
|
||||
await credentialsProvider.options.authorize(credentials, {});
|
||||
const result = await credentialsProvider.options.authorize(credentials, {});
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith({
|
||||
interval: 900,
|
||||
allowedPerInterval: 30,
|
||||
namespace: "auth:login",
|
||||
expect(result).toEqual({
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
emailVerified: expect.any(Date),
|
||||
});
|
||||
expect(applyIPRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -315,30 +302,28 @@ describe("authOptions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should apply rate limiting before token verification", async () => {
|
||||
describe("Envoy-managed callback behavior", () => {
|
||||
test("should not apply in-app rate limiting before token verification", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
|
||||
const credentials = { token: "sometoken" };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow();
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
|
||||
expect(applyIPRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should block verification when rate limit exceeded", async () => {
|
||||
test("should ignore app limiter errors because token verification is Envoy-managed", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockRejectedValue(
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
const findUniqueSpy = vi.spyOn(prisma.user, "findUnique");
|
||||
|
||||
const credentials = { token: "sometoken" };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
"Either a user does not match the provided token or the token is invalid"
|
||||
);
|
||||
|
||||
expect(findUniqueSpy).not.toHaveBeenCalled();
|
||||
expect(applyIPRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,8 +29,6 @@ import {
|
||||
shouldLogAuthFailure,
|
||||
verifyPassword,
|
||||
} from "@/modules/auth/lib/utils";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
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";
|
||||
import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
|
||||
@@ -62,8 +60,6 @@ export const authOptions: NextAuthOptions = {
|
||||
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
|
||||
},
|
||||
async authorize(credentials, _req) {
|
||||
await applyIPRateLimit(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
|
||||
|
||||
@@ -252,8 +248,6 @@ export const authOptions: NextAuthOptions = {
|
||||
},
|
||||
},
|
||||
async authorize(credentials, _req) {
|
||||
await applyIPRateLimit(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
|
||||
const identifier = "email_verification_attempts";
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isRouteRateLimitedByEnvoy } from "./envoy-rate-limit-coverage";
|
||||
|
||||
describe("isRouteRateLimitedByEnvoy", () => {
|
||||
test("matches covered auth callback routes", () => {
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/auth/callback/credentials",
|
||||
method: "POST",
|
||||
authType: "none",
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/auth/callback/token",
|
||||
method: "POST",
|
||||
authType: "none",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("matches covered api-key management and webhook routes", () => {
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v1/management/surveys",
|
||||
method: "GET",
|
||||
authType: "apiKey",
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v1/management/storage",
|
||||
method: "POST",
|
||||
authType: "apiKey",
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v1/webhooks/webhook-id",
|
||||
method: "DELETE",
|
||||
authType: "apiKey",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("matches covered client routes", () => {
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v1/client/env_123/environment",
|
||||
method: "GET",
|
||||
authType: "none",
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v1/client/env_123/responses/response_123",
|
||||
method: "PUT",
|
||||
authType: "none",
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v1/client/env_123/storage",
|
||||
method: "POST",
|
||||
authType: "none",
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v2/client/env_123/responses/response_123",
|
||||
method: "PUT",
|
||||
authType: "none",
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v2/client/env_123/displays",
|
||||
method: "POST",
|
||||
authType: "none",
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v2/client/env_123/storage",
|
||||
method: "POST",
|
||||
authType: "none",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("matches covered api-key storage delete route", () => {
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/storage/env_123/private/file.pdf",
|
||||
method: "DELETE",
|
||||
authType: "apiKey",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("does not match excluded or uncovered routes", () => {
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v1/client/og",
|
||||
method: "GET",
|
||||
authType: "none",
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v2/health",
|
||||
method: "GET",
|
||||
authType: "none",
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v2/client/env_123/environment",
|
||||
method: "GET",
|
||||
authType: "none",
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v2/client/env_123/user",
|
||||
method: "POST",
|
||||
authType: "none",
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v1/management/me",
|
||||
method: "GET",
|
||||
authType: "session",
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v1/management/storage",
|
||||
method: "POST",
|
||||
authType: "session",
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/storage/env_123/private/file.pdf",
|
||||
method: "DELETE",
|
||||
authType: "session",
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v1/webhooks",
|
||||
method: "GET",
|
||||
authType: "apiKey",
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isRouteRateLimitedByEnvoy({
|
||||
pathname: "/api/v1/client/env_123/environment",
|
||||
method: "OPTIONS",
|
||||
authType: "none",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
export type TEnvoyRateLimitAuthType = "none" | "apiKey" | "session";
|
||||
|
||||
type TEnvoyRateLimitRequest = {
|
||||
pathname: string;
|
||||
method: string;
|
||||
authType: TEnvoyRateLimitAuthType;
|
||||
};
|
||||
|
||||
const V1_CLIENT_STORAGE_PATTERN = /^\/api\/v1\/client\/[^/]+\/storage$/;
|
||||
const V1_CLIENT_PATTERN = /^\/api\/v1\/client\/[^/]+\/(environment|responses(?:\/[^/]+)?|displays|user)$/;
|
||||
const V2_CLIENT_RESPONSES_PATTERN = /^\/api\/v2\/client\/[^/]+\/responses(?:\/[^/]+)?$/;
|
||||
const V2_CLIENT_DISPLAYS_PATTERN = /^\/api\/v2\/client\/[^/]+\/displays$/;
|
||||
const V2_CLIENT_STORAGE_PATTERN = /^\/api\/v2\/client\/[^/]+\/storage$/;
|
||||
const STORAGE_DELETE_PATTERN = /^\/storage\/[^/]+\/(public|private)\/.+$/;
|
||||
|
||||
const V1_MANAGEMENT_PREFIX = "/api/v1/management/";
|
||||
const V1_WEBHOOKS_PREFIX = "/api/v1/webhooks/";
|
||||
|
||||
const V1_GENERAL_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
||||
const V2_RESPONSES_METHODS = new Set(["POST", "PUT"]);
|
||||
|
||||
const normalizeMethod = (method: string): string => method.toUpperCase();
|
||||
|
||||
const matchesPrefixedPath = (pathname: string, prefix: string): boolean => pathname.startsWith(prefix);
|
||||
|
||||
/**
|
||||
* Mirrors the live Envoy rate-limit policy set.
|
||||
* Keep this matcher aligned with the Gateway policies when coverage changes.
|
||||
*/
|
||||
export const isRouteRateLimitedByEnvoy = ({
|
||||
pathname,
|
||||
method,
|
||||
authType,
|
||||
}: TEnvoyRateLimitRequest): boolean => {
|
||||
const normalizedMethod = normalizeMethod(method);
|
||||
|
||||
if (normalizedMethod === "OPTIONS") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authType === "none" && normalizedMethod === "POST" && pathname === "/api/auth/callback/credentials") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (authType === "none" && normalizedMethod === "POST" && pathname === "/api/auth/callback/token") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
authType === "apiKey" &&
|
||||
V1_GENERAL_METHODS.has(normalizedMethod) &&
|
||||
matchesPrefixedPath(pathname, V1_MANAGEMENT_PREFIX)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
authType === "apiKey" &&
|
||||
V1_GENERAL_METHODS.has(normalizedMethod) &&
|
||||
matchesPrefixedPath(pathname, V1_WEBHOOKS_PREFIX)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (authType === "apiKey" && normalizedMethod === "DELETE" && STORAGE_DELETE_PATTERN.test(pathname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (authType !== "none") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedMethod === "POST" && V1_CLIENT_STORAGE_PATTERN.test(pathname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (V1_GENERAL_METHODS.has(normalizedMethod) && V1_CLIENT_PATTERN.test(pathname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (V2_RESPONSES_METHODS.has(normalizedMethod) && V2_CLIENT_RESPONSES_PATTERN.test(pathname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedMethod === "POST" && V2_CLIENT_DISPLAYS_PATTERN.test(pathname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedMethod === "POST" && V2_CLIENT_STORAGE_PATTERN.test(pathname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -4,6 +4,23 @@ description: "Formbricks Self-hosted version migration"
|
||||
icon: "arrow-right"
|
||||
---
|
||||
|
||||
## v5
|
||||
|
||||
Formbricks v5 changes how rate limiting is enforced:
|
||||
|
||||
- several public and API-key routes are no longer rate-limited inside the application server
|
||||
- those routes are now expected to be protected by Envoy Gateway or an equivalent edge rate limiter
|
||||
- the remaining session-based routes, server actions, and uncovered APIs still use the in-app limiter
|
||||
|
||||
<Warning>
|
||||
If you self-host Formbricks without Envoy or another equivalent edge rate limiter, upgrade planning for v5
|
||||
must include new edge protection for the covered routes. Otherwise those routes will no longer be throttled
|
||||
by the application server after the upgrade.
|
||||
</Warning>
|
||||
|
||||
See the [rate limiting guide](/self-hosting/advanced/rate-limiting) for the exact covered route groups, thresholds,
|
||||
and the remaining app-enforced limits.
|
||||
|
||||
## v4.7
|
||||
|
||||
Formbricks v4.7 introduces **typed contact attributes** with native `number` and `date` data types. This enables comparison-based segment filters (e.g. "signup date before 2025-01-01") that were previously not possible with string-only attribute values.
|
||||
@@ -39,6 +56,7 @@ When Formbricks v4.7 starts for the first time, the data migration will:
|
||||
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
|
||||
`formbricks_postgres_1`.
|
||||
</Info>
|
||||
|
||||
</Tab>
|
||||
<Tab title="Kubernetes">
|
||||
If you are using the **in-cluster PostgreSQL** deployed by the Helm chart:
|
||||
@@ -52,6 +70,7 @@ When Formbricks v4.7 starts for the first time, the data migration will:
|
||||
</Info>
|
||||
|
||||
If you are using a **managed PostgreSQL** service (e.g. AWS RDS, Cloud SQL), use your provider's backup/snapshot feature or run `pg_dump` directly against the external host.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -69,6 +88,7 @@ When Formbricks v4.7 starts for the first time, the data migration will:
|
||||
# Start with Formbricks v4.7
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Kubernetes">
|
||||
```bash
|
||||
@@ -81,6 +101,7 @@ When Formbricks v4.7 starts for the first time, the data migration will:
|
||||
The Helm chart includes a migration Job that automatically runs Prisma schema migrations as a
|
||||
PreSync hook before the new pods start. No manual migration step is needed.
|
||||
</Info>
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -105,6 +126,7 @@ After Formbricks starts, check the logs to see whether the value backfill was co
|
||||
```bash
|
||||
kubectl logs -n formbricks job/formbricks-migration
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -121,6 +143,7 @@ If the migration skipped the value backfill, run the standalone backfill script
|
||||
```
|
||||
|
||||
<Info>Replace `formbricks` with your actual container name if it differs. Use `docker ps` to find it.</Info>
|
||||
|
||||
</Tab>
|
||||
<Tab title="Kubernetes">
|
||||
```bash
|
||||
@@ -130,6 +153,7 @@ If the migration skipped the value backfill, run the standalone backfill script
|
||||
<Info>
|
||||
If your Formbricks deployment has a different name, run `kubectl get deploy -n formbricks` to find it.
|
||||
</Info>
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -6,65 +6,106 @@ icon: "timer"
|
||||
|
||||
Formbricks applies request rate limits to protect against abuse and keep API usage fair.
|
||||
|
||||
Rate limits are scoped by identifier, depending on the endpoint:
|
||||
Starting with Formbricks v5, rate limiting is split across two layers:
|
||||
|
||||
- Envoy Gateway for public and API-key routes that can be enforced at ingress
|
||||
- The application server for remaining session-authenticated routes, server actions, and other flows Envoy does
|
||||
not currently cover
|
||||
|
||||
<Warning>
|
||||
Formbricks v5 removes application-level rate limiting for several routes that are now expected to be
|
||||
protected by Envoy Gateway. If you self-host Formbricks without Envoy or an equivalent edge rate limiter,
|
||||
those routes will no longer be throttled by the application server after upgrading.
|
||||
</Warning>
|
||||
|
||||
Rate limits are scoped by identifier, depending on the endpoint and enforcement layer:
|
||||
|
||||
- IP hash (for unauthenticated/client-side routes and public actions)
|
||||
- API key ID (for authenticated API calls)
|
||||
- API key ID (for Envoy-managed and app-managed authenticated API calls)
|
||||
- User ID (for authenticated session-based calls and server actions)
|
||||
- Organization ID (for follow-up email dispatch)
|
||||
|
||||
When a limit is exceeded, the API returns `429 Too Many Requests`.
|
||||
|
||||
## Management API Rate Limits
|
||||
## v5 Migration Note
|
||||
|
||||
These are the current limits for Management APIs:
|
||||
Before upgrading to Formbricks v5:
|
||||
|
||||
| **Route Group** | **Limit** | **Window** | **Identifier** |
|
||||
| --- | --- | --- | --- |
|
||||
| `/api/v1/management/*` (except `/api/v1/management/storage`), `/api/v1/webhooks/*`, `/api/v1/integrations/*`, `/api/v1/management/me` | 100 requests | 1 minute | API key ID or session user ID |
|
||||
| `/api/v2/management/*` (and other v2 authenticated routes that use `authenticatedApiClient`) | 100 requests | 1 minute | API key ID |
|
||||
| `POST /api/v1/management/storage` | 5 requests | 1 minute | API key ID or session user ID |
|
||||
- deploy Envoy Gateway or an equivalent edge rate limiter for the covered routes below
|
||||
- keep application Redis/Valkey enabled for the remaining app-enforced limits
|
||||
- expect covered routes to emit gateway `429`s instead of the legacy app JSON `429`s
|
||||
|
||||
## All Enforced Limits
|
||||
For the current source of truth on covered routes and thresholds, use this page together with your deployment
|
||||
configuration.
|
||||
|
||||
| **Config** | **Limit** | **Window** | **Identifier** | **Used For** |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `auth.login` | 10 requests | 15 minutes | IP hash | Email/password login flow (`/api/auth/callback/credentials`) |
|
||||
| `auth.signup` | 30 requests | 60 minutes | IP hash | Signup server action |
|
||||
| `auth.forgotPassword` | 5 requests | 60 minutes | IP hash | Forgot password server action |
|
||||
| `auth.verifyEmail` | 10 requests | 60 minutes | IP hash | Email verification callback + resend verification action |
|
||||
| `api.v1` | 100 requests | 1 minute | API key ID or session user ID | v1 management, webhooks, integrations, and `/api/v1/management/me` |
|
||||
| `api.v2` | 100 requests | 1 minute | API key ID | v2 authenticated API wrapper (`authenticatedApiClient`) |
|
||||
| `api.client` | 100 requests | 1 minute | IP hash | v1 client API routes (except `/api/v1/client/og` and storage upload override), plus v2 routes that re-use those v1 handlers |
|
||||
| `storage.upload` | 5 requests | 1 minute | IP hash or authenticated ID | Client storage upload and management storage upload |
|
||||
| `storage.delete` | 5 requests | 1 minute | API key ID or session user ID | `DELETE /storage/[environmentId]/[accessType]/[fileName]` |
|
||||
| `actions.emailUpdate` | 3 requests | 60 minutes | User ID | Profile email update action |
|
||||
| `actions.surveyFollowUp` | 50 requests | 60 minutes | Organization ID | Survey follow-up email processing |
|
||||
| `actions.sendLinkSurveyEmail` | 10 requests | 60 minutes | IP hash | Link survey email send action |
|
||||
| `actions.licenseRecheck` | 5 requests | 1 minute | User ID | Enterprise license recheck action |
|
||||
## Envoy-Managed Limits
|
||||
|
||||
## Current Endpoint Exceptions
|
||||
These limits are expected to be enforced at the gateway layer in Formbricks v5 and later:
|
||||
|
||||
The following routes are currently not rate-limited by the server-side limiter:
|
||||
| **Route Group** | **Limit** | **Window** | **Identifier** |
|
||||
| ------------------------------------------------------------------------ | ------------ | ---------- | -------------- |
|
||||
| `POST /api/auth/callback/credentials` | 40 requests | 1 hour | IP hash |
|
||||
| `POST /api/auth/callback/token` | 10 requests | 1 hour | IP hash |
|
||||
| `GET, POST, PUT, PATCH, DELETE /api/v1/management/*` (API key auth only) | 100 requests | 1 minute | API key ID |
|
||||
| `POST /api/v1/management/storage` (API key auth only) | 5 requests | 1 minute | API key ID |
|
||||
| `GET, POST, PUT, PATCH, DELETE /api/v1/webhooks/*` (API key auth only) | 100 requests | 1 minute | API key ID |
|
||||
| `GET /api/v1/client/[environmentId]/environment` | 100 requests | 1 minute | IP hash |
|
||||
| `POST /api/v1/client/[environmentId]/responses` | 100 requests | 1 minute | IP hash |
|
||||
| `PUT /api/v1/client/[environmentId]/responses/[responseId]` | 100 requests | 1 minute | IP hash |
|
||||
| `POST /api/v1/client/[environmentId]/displays` | 100 requests | 1 minute | IP hash |
|
||||
| `POST /api/v1/client/[environmentId]/user` | 100 requests | 1 minute | IP hash |
|
||||
| `POST /api/v1/client/[environmentId]/storage` | 5 requests | 1 minute | IP hash |
|
||||
| `POST /api/v2/client/[environmentId]/responses` | 100 requests | 1 minute | IP hash |
|
||||
| `PUT /api/v2/client/[environmentId]/responses/[responseId]` | 100 requests | 1 minute | IP hash |
|
||||
| `POST /api/v2/client/[environmentId]/displays` | 100 requests | 1 minute | IP hash |
|
||||
| `POST /api/v2/client/[environmentId]/storage` | 5 requests | 1 minute | IP hash |
|
||||
| `DELETE /storage/[environmentId]/public/[fileName]` (API key auth only) | 5 requests | 1 minute | API key ID |
|
||||
| `DELETE /storage/[environmentId]/private/[fileName]` (API key auth only) | 5 requests | 1 minute | API key ID |
|
||||
|
||||
- `GET /api/v1/client/og` (explicitly excluded)
|
||||
- `POST /api/v2/client/[environmentId]/responses`
|
||||
- `POST /api/v2/client/[environmentId]/displays`
|
||||
- `GET /api/v2/health`
|
||||
Session-authenticated `/api/v1/management/*`, `/api/v1/management/me`, `/api/v1/management/storage`, and
|
||||
`DELETE /storage/...` requests are **not** covered by the current Envoy policies and remain app-enforced.
|
||||
|
||||
## App-Enforced Limits
|
||||
|
||||
These are the limits that still run inside the Formbricks application server:
|
||||
|
||||
| **Config** | **Limit** | **Window** | **Identifier** | **Used For** |
|
||||
| ----------------------------- | ------------ | ---------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `auth.signup` | 30 requests | 60 minutes | IP hash | Signup server action |
|
||||
| `auth.forgotPassword` | 5 requests | 60 minutes | IP hash | Forgot password server action |
|
||||
| `auth.verifyEmail` | 10 requests | 60 minutes | IP hash | Resend verification server action |
|
||||
| `api.v1` | 100 requests | 1 minute | Session user ID | Session-authenticated v1 management routes, `/api/v1/management/me`, and `/api/v1/integrations/*` |
|
||||
| `api.v2` | 100 requests | 1 minute | API key ID | Authenticated v2 API wrapper outside the Envoy-managed `/api/v2/client/*` subset |
|
||||
| `api.v3` | 100 requests | 1 minute | API key ID or session user ID | v3 API wrapper |
|
||||
| `api.client` | 100 requests | 1 minute | IP hash | Uncovered client routes such as `GET /api/v1/client/og`, `GET /api/v2/client/[environmentId]/environment`, and `POST /api/v2/client/[environmentId]/user` |
|
||||
| `storage.upload` | 5 requests | 1 minute | Session user ID | Session-authenticated `POST /api/v1/management/storage` |
|
||||
| `storage.delete` | 5 requests | 1 minute | Session user ID | Session-authenticated `DELETE /storage/[environmentId]/[accessType]/[fileName]` |
|
||||
| `actions.emailUpdate` | 3 requests | 60 minutes | User ID | Profile email update action |
|
||||
| `actions.surveyFollowUp` | 50 requests | 60 minutes | Organization ID | Survey follow-up email processing |
|
||||
| `actions.sendLinkSurveyEmail` | 10 requests | 60 minutes | IP hash | Link survey email send action |
|
||||
| `actions.licenseRecheck` | 5 requests | 1 minute | User ID | Enterprise license recheck action |
|
||||
|
||||
## Explicit Envoy Exclusions
|
||||
|
||||
The current Envoy policy set explicitly excludes these routes:
|
||||
|
||||
- `GET /api/v1/client/og` (still covered by the app-level `api.client` limiter)
|
||||
- `GET /api/v2/health` (not rate-limited)
|
||||
- `OPTIONS` requests (not rate-limited)
|
||||
|
||||
## 429 Response Shape
|
||||
|
||||
v1-style endpoints return:
|
||||
Application-generated v1 `429`s return:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "too_many_requests",
|
||||
"message": "Maximum number of requests reached. Please try again later.",
|
||||
"details": {}
|
||||
"details": {},
|
||||
"message": "Maximum number of requests reached. Please try again later."
|
||||
}
|
||||
```
|
||||
|
||||
v2-style endpoints return:
|
||||
Application-generated v2/v3 `429`s return:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -75,6 +116,9 @@ v2-style endpoints return:
|
||||
}
|
||||
```
|
||||
|
||||
Envoy-generated `429`s are gateway responses and should include an `x-envoy-ratelimited` header. Their exact body
|
||||
shape is not the same stability contract as the in-app JSON responses above.
|
||||
|
||||
## Disabling Rate Limiting
|
||||
|
||||
For self-hosters, rate limiting can be disabled if necessary. We strongly recommend keeping it enabled in production.
|
||||
@@ -87,8 +131,11 @@ RATE_LIMITING_DISABLED=1
|
||||
|
||||
After changing this value, restart the server.
|
||||
|
||||
This setting disables only the **application-level** limiter. It does **not** disable Envoy rate-limit policies.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Redis/Valkey is required for robust rate limiting (`REDIS_URL`).
|
||||
- If Redis is unavailable at runtime, rate-limiter checks currently fail open (requests are allowed through without enforcement).
|
||||
- Redis/Valkey is required for the application-level limiter (`REDIS_URL`).
|
||||
- If you deploy Envoy rate limiting, use a dedicated Redis/Valkey backend for Envoy instead of sharing the app cache.
|
||||
- If application Redis is unavailable at runtime, app rate-limiter checks currently fail open (requests are allowed through without enforcement).
|
||||
- Authentication failure audit logging uses a separate throttle (`shouldLogAuthFailure()`) and is intentionally **fail-closed**: when Redis is unavailable or errors occur, audit log entries are **skipped entirely** rather than written without throttle control. This prevents spam while preserving the hash-integrity chain required for compliance. In other words, if Redis is down, no authentication-failure audit logs will be recorded—requests themselves are still allowed (fail-open rate limiting above), but the audit trail for those failures will not be written.
|
||||
|
||||
Reference in New Issue
Block a user