mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-14 03:04:00 -05:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9dcc5dd4b | |||
| d7fbb439e5 |
+1
-1
@@ -70,7 +70,7 @@ SMTP_PASSWORD=smtpPassword
|
||||
# S3 STORAGE #
|
||||
##############
|
||||
|
||||
# S3 Storage is required for the file upload in serverless environments like Vercel
|
||||
# S3 Storage is required for the file upload in serverless environments
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_REGION=
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
apps/web/.env
|
||||
@@ -96,10 +96,7 @@ export const POST = withV1ApiWrapper({
|
||||
const agent = new UAParser(userAgent);
|
||||
|
||||
const country =
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
|
||||
|
||||
const responseInputData = responseInputValidation.data;
|
||||
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
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,7 +6,6 @@ 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";
|
||||
@@ -80,17 +79,6 @@ 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
|
||||
|
||||
@@ -35,10 +35,7 @@ type TValidatedResponseInputResult =
|
||||
| { response: Response };
|
||||
|
||||
const getCountry = (requestHeaders: Headers): string | undefined =>
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
|
||||
|
||||
const getUnexpectedPublicErrorResponse = (): Response =>
|
||||
responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
|
||||
|
||||
@@ -10,8 +10,7 @@ export const IS_DEVELOPMENT = env.NODE_ENV === "development";
|
||||
export const E2E_TESTING = env.E2E_TESTING === "1";
|
||||
|
||||
// URLs
|
||||
export const WEBAPP_URL =
|
||||
env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000";
|
||||
export const WEBAPP_URL = env.WEBAPP_URL || "http://localhost:3000";
|
||||
|
||||
// encryption keys
|
||||
export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
|
||||
|
||||
@@ -235,7 +235,6 @@ const parsedEnv = createEnv({
|
||||
TURNSTILE_SITE_KEY: z.string().optional(),
|
||||
RECAPTCHA_SITE_KEY: z.string().optional(),
|
||||
RECAPTCHA_SECRET_KEY: z.string().optional(),
|
||||
VERCEL_URL: z.string().optional(),
|
||||
WEBAPP_URL: z.url().optional(),
|
||||
UNSPLASH_ACCESS_KEY: z.string().optional(),
|
||||
|
||||
@@ -354,7 +353,6 @@ const parsedEnv = createEnv({
|
||||
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
|
||||
RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY,
|
||||
TERMS_URL: process.env.TERMS_URL,
|
||||
VERCEL_URL: process.env.VERCEL_URL,
|
||||
WEBAPP_URL: process.env.WEBAPP_URL,
|
||||
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const envMock = {
|
||||
WEBAPP_URL: undefined as string | undefined,
|
||||
VERCEL_URL: undefined as string | undefined,
|
||||
PUBLIC_URL: undefined as string | undefined,
|
||||
};
|
||||
|
||||
@@ -19,7 +18,6 @@ const loadGetPublicDomain = async () => {
|
||||
describe("getPublicDomain", () => {
|
||||
beforeEach(() => {
|
||||
envMock.WEBAPP_URL = undefined;
|
||||
envMock.VERCEL_URL = undefined;
|
||||
envMock.PUBLIC_URL = undefined;
|
||||
});
|
||||
|
||||
@@ -31,16 +29,7 @@ describe("getPublicDomain", () => {
|
||||
expect(getPublicDomain()).toBe("https://app.formbricks.com");
|
||||
});
|
||||
|
||||
test("falls back to VERCEL_URL when WEBAPP_URL is empty", async () => {
|
||||
envMock.WEBAPP_URL = " ";
|
||||
envMock.VERCEL_URL = "preview.formbricks.com";
|
||||
|
||||
const getPublicDomain = await loadGetPublicDomain();
|
||||
|
||||
expect(getPublicDomain()).toBe("https://preview.formbricks.com");
|
||||
});
|
||||
|
||||
test("falls back to localhost when WEBAPP_URL and VERCEL_URL are not set", async () => {
|
||||
test("falls back to localhost when WEBAPP_URL is not set", async () => {
|
||||
const getPublicDomain = await loadGetPublicDomain();
|
||||
|
||||
expect(getPublicDomain()).toBe("http://localhost:3000");
|
||||
|
||||
@@ -2,17 +2,7 @@ import "server-only";
|
||||
import { env } from "./env";
|
||||
|
||||
const configuredWebappUrl = env.WEBAPP_URL?.trim() ?? "";
|
||||
const WEBAPP_URL = (() => {
|
||||
if (configuredWebappUrl !== "") {
|
||||
return configuredWebappUrl;
|
||||
}
|
||||
|
||||
if (env.VERCEL_URL) {
|
||||
return `https://${env.VERCEL_URL}`;
|
||||
}
|
||||
|
||||
return "http://localhost:3000";
|
||||
})();
|
||||
const WEBAPP_URL = configuredWebappUrl !== "" ? configuredWebappUrl : "http://localhost:3000";
|
||||
|
||||
/**
|
||||
* Returns the public domain URL
|
||||
|
||||
@@ -59,7 +59,6 @@ describe("rateLimitConfigs", () => {
|
||||
expect(rateLimitConfigs).toHaveProperty("auth");
|
||||
expect(rateLimitConfigs).toHaveProperty("api");
|
||||
expect(rateLimitConfigs).toHaveProperty("actions");
|
||||
expect(rateLimitConfigs).toHaveProperty("storage");
|
||||
});
|
||||
|
||||
test("should have all auth configurations", () => {
|
||||
@@ -82,11 +81,6 @@ describe("rateLimitConfigs", () => {
|
||||
"licenseRecheck",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should have all storage configurations", () => {
|
||||
const storageConfigs = Object.keys(rateLimitConfigs.storage);
|
||||
expect(storageConfigs).toEqual(["upload", "uploadPerEnvironment", "delete"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zod Validation", () => {
|
||||
@@ -95,7 +89,6 @@ describe("rateLimitConfigs", () => {
|
||||
...Object.values(rateLimitConfigs.auth),
|
||||
...Object.values(rateLimitConfigs.api),
|
||||
...Object.values(rateLimitConfigs.actions),
|
||||
...Object.values(rateLimitConfigs.storage),
|
||||
];
|
||||
|
||||
for (const config of allConfigs) {
|
||||
@@ -112,7 +105,6 @@ 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);
|
||||
@@ -150,7 +142,6 @@ 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" },
|
||||
];
|
||||
|
||||
@@ -180,15 +171,6 @@ 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,11 +30,6 @@ 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;
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -378,7 +378,7 @@ describe("License Core Logic", () => {
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -410,7 +410,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -444,7 +444,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -475,7 +475,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -506,7 +506,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -571,7 +571,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -627,7 +627,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -683,7 +683,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -722,7 +722,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -748,7 +748,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -899,7 +899,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -946,7 +946,7 @@ describe("License Core Logic", () => {
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: undefined,
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -969,7 +969,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: testLicenseKey,
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
|
||||
@@ -46,13 +46,13 @@
|
||||
"@lexical/table": "0.41.0",
|
||||
"@next-auth/prisma-adapter": "1.0.7",
|
||||
"@opentelemetry/auto-instrumentations-node": "0.75.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "0.217.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "0.213.0",
|
||||
"@opentelemetry/exporter-prometheus": "0.217.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.217.0",
|
||||
"@opentelemetry/resources": "2.7.1",
|
||||
"@opentelemetry/sdk-metrics": "2.7.1",
|
||||
"@opentelemetry/sdk-node": "0.217.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.213.0",
|
||||
"@opentelemetry/resources": "2.6.1",
|
||||
"@opentelemetry/sdk-metrics": "2.6.1",
|
||||
"@opentelemetry/sdk-node": "0.213.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||
"@opentelemetry/semantic-conventions": "1.40.0",
|
||||
"@paralleldrive/cuid2": "2.3.1",
|
||||
"@prisma/client": "6.19.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
|
||||
// The config you add here will be used whenever one of the edge features is loaded.
|
||||
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||
// Note that this config is also required when running locally.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"functions": {
|
||||
"app/**/*.ts": {
|
||||
"maxDuration": 10,
|
||||
"memory": 512
|
||||
},
|
||||
"app/api/cron/**/*.ts": {
|
||||
"maxDuration": 180,
|
||||
"memory": 512
|
||||
},
|
||||
"app/api/v1/client/**/*.ts": {
|
||||
"maxDuration": 10,
|
||||
"memory": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ x-environment: &environment
|
||||
|
||||
################################################### OPTIONAL (STORAGE) ###################################################
|
||||
|
||||
# Set S3 Storage configuration (required for the file upload in serverless environments like Vercel)
|
||||
# Set S3 Storage configuration (required for the file upload in serverless environments)
|
||||
# S3_ACCESS_KEY:
|
||||
# S3_SECRET_KEY:
|
||||
# S3_REGION:
|
||||
|
||||
@@ -6,7 +6,7 @@ icon: code
|
||||
|
||||
## TypeScript
|
||||
|
||||
Our codebase follows the Vercel Engineering Style Guide conventions.
|
||||
Our codebase uses the `@vercel/style-guide` ESLint configurations for consistent code quality.
|
||||
|
||||
### ESLint Configuration
|
||||
|
||||
|
||||
@@ -1323,7 +1323,7 @@ Please note that their values and the logic remains exactly the same. Only the p
|
||||
|
||||
### Deprecated Environment Variables
|
||||
|
||||
- **`NEXT_PUBLIC_VERCEL_URL`**: Was used as Vercel URL (used instead of `WEBAPP_URL)`, but from v1.1, you can just set the `WEBAPP_URL` environment variable to your Vercel URL.
|
||||
- **`NEXT_PUBLIC_VERCEL_URL`**: Was used as deployment URL fallback (used instead of `WEBAPP_URL`), but from v1.1, you can just set the `WEBAPP_URL` environment variable.
|
||||
|
||||
- **`RAILWAY_STATIC_URL`**: Was used as Railway Static URL (used instead of `WEBAPP_URL`), but from v1.1, you can just set the `WEBAPP_URL` environment variable.
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ 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)
|
||||
@@ -20,30 +19,29 @@ 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.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 |
|
||||
| **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 |
|
||||
|
||||
## Current Endpoint Exceptions
|
||||
|
||||
@@ -61,8 +59,8 @@ v1-style endpoints return:
|
||||
```json
|
||||
{
|
||||
"code": "too_many_requests",
|
||||
"details": {},
|
||||
"message": "Maximum number of requests reached. Please try again later."
|
||||
"message": "Maximum number of requests reached. Please try again later.",
|
||||
"details": {}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -93,5 +91,4 @@ 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.
|
||||
|
||||
+5
-11
@@ -84,26 +84,20 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@hono/node-server": "1.19.13",
|
||||
"@protobufjs/utf8": "1.1.1",
|
||||
"@tootallnate/once": "3.0.1",
|
||||
"@xmldom/xmldom": "0.9.10",
|
||||
"ajv@6": "6.14.0",
|
||||
"axios": "1.15.2",
|
||||
"effect": "3.20.0",
|
||||
"fast-uri": "3.1.2",
|
||||
"fast-xml-parser": "5.7.0",
|
||||
"hono": "4.12.18",
|
||||
"ip-address": "10.1.1",
|
||||
"fast-xml-parser": "5.5.7",
|
||||
"hono": "4.12.14",
|
||||
"lodash": "4.18.1",
|
||||
"node-forge": "1.4.0",
|
||||
"postcss": "8.5.14",
|
||||
"protobufjs@7": "7.5.8",
|
||||
"protobufjs@8": "8.2.0",
|
||||
"tar": "7.5.15",
|
||||
"uuid@11": "11.1.1"
|
||||
"@opentelemetry/otlp-transformer>protobufjs": "8.0.1",
|
||||
"tar": "7.5.13"
|
||||
},
|
||||
"comments": {
|
||||
"overrides": "Security fixes for transitive dependencies that still fail a no-override audit. Remove each override when its upstream chain adopts a patched version: @hono/node-server/hono via Prisma dev tooling | @protobufjs/utf8 (CVE overlong UTF-8) - awaiting @opentelemetry/otlp-transformer update | @tootallnate/once and tar via sqlite3/node-gyp chain | @xmldom/xmldom (XML injection/DoS CVEs) - awaiting @boxyhq/saml20 to pin to >=0.9.10 | axios, lodash, and node-forge via @boxyhq/saml-jackson | ajv@6 via webpack/eslint | effect (GHSA-38f7-945m-qr2g) - awaiting @prisma/config update | fast-uri (CVE-2025-48944/48945) - awaiting ajv/schema-utils update | fast-xml-parser via AWS SDK XML builder | ip-address (XSS in Address6) - awaiting mongodb/socks update | postcss (CVE-2025-62695) - awaiting next.js to unpin postcss | protobufjs@7/8 (GHSA-xq3m-2v4x-88gg et al.) - awaiting @grpc/proto-loader/otlp-transformer update | uuid@11 (CVE-2025-61475) - awaiting typeorm update"
|
||||
"overrides": "Security fixes for transitive dependencies that still fail a no-override audit. Remove each override when its upstream chain adopts a patched version: @hono/node-server/hono/effect via Prisma dev tooling | @tootallnate/once and tar via sqlite3/BoxyHQ SAML Jackson database tooling | @xmldom/xmldom, axios, lodash, and node-forge via @boxyhq/saml-jackson | ajv via @vercel/style-guide/eslint-plugin-tsdoc | protobufjs via BoxyHQ/OpenTelemetry metrics | fast-xml-parser via AWS SDK XML builder."
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
|
||||
|
||||
Generated
+1471
-816
File diff suppressed because it is too large
Load Diff
@@ -289,8 +289,6 @@
|
||||
"RECAPTCHA_SECRET_KEY",
|
||||
"TELEMETRY_DISABLED",
|
||||
"TERMS_URL",
|
||||
"VERCEL",
|
||||
"VERCEL_URL",
|
||||
"VERSION",
|
||||
"WEBAPP_URL",
|
||||
"UNSPLASH_ACCESS_KEY",
|
||||
|
||||
Reference in New Issue
Block a user