Compare commits

...

1 Commits

Author SHA1 Message Date
Bhagya Amarasinghe ad55f6f470 fix: rate limit storage uploads per environment 2026-05-14 01:05:11 +05:30
5 changed files with 261 additions and 22 deletions
@@ -0,0 +1,201 @@
import { type NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
const mocks = vi.hoisted(() => ({
applyIPRateLimit: vi.fn(),
applyRateLimit: vi.fn(),
getSurvey: vi.fn(),
getOrganizationByEnvironmentId: vi.fn(),
getBiggerUploadFileSizePermission: vi.fn(),
getSignedUrlForUpload: vi.fn(),
getErrorResponseFromStorageError: vi.fn(),
reportApiError: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: mocks.applyIPRateLimit,
applyRateLimit: mocks.applyRateLimit,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: mocks.getOrganizationByEnvironmentId,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getBiggerUploadFileSizePermission: mocks.getBiggerUploadFileSizePermission,
}));
vi.mock("@/modules/storage/service", () => ({
getSignedUrlForUpload: mocks.getSignedUrlForUpload,
}));
vi.mock("@/modules/storage/utils", () => ({
getErrorResponseFromStorageError: mocks.getErrorResponseFromStorageError,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: false,
MAX_FILE_UPLOAD_SIZES: {
standard: 1024 * 1024 * 10,
big: 1024 * 1024 * 1024,
},
SENTRY_DSN: undefined,
}));
const ENVIRONMENT_ID = "cm1ubebtj000614kqe4hs3c67";
const OTHER_ENVIRONMENT_ID = "cm1ubebtj000714kqe4hs3c68";
const SURVEY_ID = "cm1ubebtj000814kqe4hs3c69";
const ORGANIZATION_ID = "cm1ubebtj000914kqe4hs3c70";
const createMockRequest = ({
apiVersion = "v1",
body = {
fileName: "upload.png",
fileType: "image/png",
surveyId: SURVEY_ID,
},
environmentId = ENVIRONMENT_ID,
}: {
apiVersion?: "v1" | "v2";
body?: unknown;
environmentId?: string;
} = {}): NextRequest => {
const pathname = `/api/${apiVersion}/client/${environmentId}/storage`;
return {
method: "POST",
url: `https://api.test${pathname}`,
headers: {
get: vi.fn(() => null),
},
nextUrl: {
pathname,
},
json: vi.fn().mockResolvedValue(body),
} as unknown as NextRequest;
};
const createRouteProps = (environmentId = ENVIRONMENT_ID) => ({
params: Promise.resolve({ environmentId }),
});
describe("api/v1 client storage route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyIPRateLimit.mockResolvedValue({ allowed: true });
mocks.applyRateLimit.mockResolvedValue({ allowed: true });
mocks.getSurvey.mockResolvedValue({ id: SURVEY_ID, environmentId: ENVIRONMENT_ID });
mocks.getOrganizationByEnvironmentId.mockResolvedValue({ id: ORGANIZATION_ID });
mocks.getBiggerUploadFileSizePermission.mockResolvedValue(false);
mocks.getSignedUrlForUpload.mockResolvedValue({
ok: true,
data: {
signedUrl: "https://s3.example.com/upload",
presignedFields: { key: "value" },
fileUrl: `/storage/${ENVIRONMENT_ID}/private/upload--fid--uuid.png`,
},
});
});
test("applies IP and environment rate limits before signing the upload", async () => {
const { POST } = await import("./route");
const response = await POST(createMockRequest(), createRouteProps());
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
data: {
signedUrl: "https://s3.example.com/upload",
presignedFields: { key: "value" },
fileUrl: `/storage/${ENVIRONMENT_ID}/private/upload--fid--uuid.png`,
},
});
expect(mocks.applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.storage.upload);
expect(mocks.applyRateLimit).toHaveBeenCalledWith(
rateLimitConfigs.storage.uploadPerEnvironment,
ENVIRONMENT_ID
);
expect(mocks.getSignedUrlForUpload).toHaveBeenCalledWith(
"upload.png",
ENVIRONMENT_ID,
"image/png",
"private",
1024 * 1024 * 10
);
});
test("returns 429 with CORS when the environment rate limit is exceeded", async () => {
const { POST } = await import("./route");
mocks.applyRateLimit.mockRejectedValueOnce(
new Error("Maximum number of requests reached. Please try again later.")
);
const response = await POST(createMockRequest(), createRouteProps());
expect(response.status).toBe(429);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(await response.json()).toEqual({
code: "too_many_requests",
message: "Maximum number of requests reached. Please try again later.",
details: {},
});
expect(mocks.getSignedUrlForUpload).not.toHaveBeenCalled();
});
test("does not burn environment quota when the survey belongs to another environment", async () => {
const { POST } = await import("./route");
mocks.getSurvey.mockResolvedValueOnce({ id: SURVEY_ID, environmentId: OTHER_ENVIRONMENT_ID });
const response = await POST(createMockRequest(), createRouteProps());
expect(response.status).toBe(400);
expect(mocks.applyRateLimit).not.toHaveBeenCalled();
expect(mocks.getSignedUrlForUpload).not.toHaveBeenCalled();
});
test("applies the same environment rate limit through the v2 storage re-export", async () => {
const { POST } = await import("@/app/api/v2/client/[environmentId]/storage/route");
const response = await POST(createMockRequest({ apiVersion: "v2" }), createRouteProps());
expect(response.status).toBe(200);
expect(mocks.applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.storage.upload);
expect(mocks.applyRateLimit).toHaveBeenCalledWith(
rateLimitConfigs.storage.uploadPerEnvironment,
ENVIRONMENT_ID
);
});
});
@@ -6,6 +6,7 @@ import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
import { getSignedUrlForUpload } from "@/modules/storage/service";
@@ -79,6 +80,17 @@ export const POST = withV1ApiWrapper({
};
}
try {
await applyRateLimit(rateLimitConfigs.storage.uploadPerEnvironment, environmentId);
} catch (error) {
return {
response: responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Rate limit exceeded",
true
),
};
}
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.id);
const maxFileUploadSize = isBiggerFileUploadAllowed
? MAX_FILE_UPLOAD_SIZES.big
@@ -59,6 +59,7 @@ describe("rateLimitConfigs", () => {
expect(rateLimitConfigs).toHaveProperty("auth");
expect(rateLimitConfigs).toHaveProperty("api");
expect(rateLimitConfigs).toHaveProperty("actions");
expect(rateLimitConfigs).toHaveProperty("storage");
});
test("should have all auth configurations", () => {
@@ -81,6 +82,11 @@ describe("rateLimitConfigs", () => {
"licenseRecheck",
]);
});
test("should have all storage configurations", () => {
const storageConfigs = Object.keys(rateLimitConfigs.storage);
expect(storageConfigs).toEqual(["upload", "uploadPerEnvironment", "delete"]);
});
});
describe("Zod Validation", () => {
@@ -89,6 +95,7 @@ describe("rateLimitConfigs", () => {
...Object.values(rateLimitConfigs.auth),
...Object.values(rateLimitConfigs.api),
...Object.values(rateLimitConfigs.actions),
...Object.values(rateLimitConfigs.storage),
];
for (const config of allConfigs) {
@@ -105,6 +112,7 @@ describe("rateLimitConfigs", () => {
Object.values(rateLimitConfigs.auth).forEach((config) => allNamespaces.push(config.namespace));
Object.values(rateLimitConfigs.api).forEach((config) => allNamespaces.push(config.namespace));
Object.values(rateLimitConfigs.actions).forEach((config) => allNamespaces.push(config.namespace));
Object.values(rateLimitConfigs.storage).forEach((config) => allNamespaces.push(config.namespace));
const uniqueNamespaces = new Set(allNamespaces);
expect(uniqueNamespaces.size).toBe(allNamespaces.length);
@@ -142,6 +150,7 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.actions.accountDeletion, identifier: "user-account-delete" },
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
{ config: rateLimitConfigs.storage.uploadPerEnvironment, identifier: "storage-upload-env" },
{ config: rateLimitConfigs.storage.delete, identifier: "storage-delete" },
];
@@ -171,6 +180,15 @@ describe("rateLimitConfigs", () => {
expect(config.namespace).toBe("storage:upload");
});
test("should properly configure storage upload per environment rate limit", async () => {
const config = rateLimitConfigs.storage.uploadPerEnvironment;
// Verify configuration values
expect(config.interval).toBe(60); // 1 minute
expect(config.allowedPerInterval).toBe(100); // 100 requests per minute
expect(config.namespace).toBe("storage:upload:environment");
});
test("should properly configure storage delete rate limit", async () => {
const config = rateLimitConfigs.storage.delete;
@@ -30,6 +30,11 @@ export const rateLimitConfigs = {
storage: {
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
uploadPerEnvironment: {
interval: 60,
allowedPerInterval: 100,
namespace: "storage:upload:environment",
}, // 100 per minute per environment
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
},
} as const;
+25 -22
View File
@@ -9,6 +9,7 @@ Formbricks applies request rate limits to protect against abuse and keep API usa
Rate limits are scoped by identifier, depending on the endpoint:
- IP hash (for unauthenticated/client-side routes and public actions)
- Environment ID (for public client storage upload abuse protection)
- API key ID (for authenticated API calls)
- User ID (for authenticated session-based calls and server actions)
- Organization ID (for follow-up email dispatch)
@@ -19,29 +20,30 @@ When a limit is exceeded, the API returns `429 Too Many Requests`.
These are the current limits for Management APIs:
| **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 |
| **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 |
## All Enforced Limits
| **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 |
| **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.uploadPerEnvironment` | 100 requests | 1 minute | Environment ID | Client storage upload only (`/api/v1/client/[environmentId]/storage` and the v2 re-export) |
| `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 |
## Current Endpoint Exceptions
@@ -59,8 +61,8 @@ v1-style endpoints 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."
}
```
@@ -91,4 +93,5 @@ After changing this value, restart the server.
- 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).
- Client storage upload rate limits count signed upload URL issuance, not successful object creation in S3-compatible storage.
- 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.