mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-14 11:30:11 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad55f6f470 |
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user