Compare commits

...

2 Commits

Author SHA1 Message Date
pandeymangg
cd275198d4 chore: merge with epic/v5 2026-04-16 13:18:10 +05:30
Bhagya Amarasinghe
87db001f41 feat: remove app rate limits for Envoy-covered routes 2026-04-13 09:17:35 +05:30
12 changed files with 826 additions and 103 deletions

View 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" });
});
});

View File

@@ -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");

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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"
);
});
});

View File

@@ -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);
}

View File

@@ -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();
});
});
});

View File

@@ -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";

View File

@@ -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);
});
});

View File

@@ -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;
};

View File

@@ -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>

View File

@@ -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.