Compare commits

..

1 Commits

Author SHA1 Message Date
Bhagya Amarasinghe f737c8b76f feat: add provider-aware public rate limit routing 2026-03-24 15:04:36 +05:30
88 changed files with 10184 additions and 18430 deletions
+3 -4
View File
@@ -185,10 +185,9 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# Public unauthenticated IP-based rate limits can be handled by an edge provider.
# Supported values: none, cloudflare, cloudarmor, envoy
# EDGE_RATE_LIMIT_PROVIDER=none
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
@@ -1,7 +1,6 @@
"use client";
import clsx from "clsx";
import { TFunction } from "i18next";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -55,25 +54,6 @@ export enum OptionsType {
QUOTAS = "Quotas",
}
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
switch (type) {
case OptionsType.ELEMENTS:
return t("common.elements");
case OptionsType.TAGS:
return t("common.tags");
case OptionsType.ATTRIBUTES:
return t("common.attributes");
case OptionsType.OTHERS:
return t("common.other_filters");
case OptionsType.META:
return t("common.meta");
case OptionsType.HIDDEN_FIELDS:
return t("common.hidden_fields");
case OptionsType.QUOTAS:
return t("common.quotas");
}
};
export type ElementOption = {
label: string;
elementType?: TSurveyElementTypeEnum;
@@ -238,12 +218,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
{options?.map((data) => (
<Fragment key={data.header}>
{data?.option.length > 0 && (
<CommandGroup
heading={
<p className="text-sm font-medium text-slate-600">
{getOptionsTypeTranslationKey(data.header, t)}
</p>
}>
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
{data?.option?.map((o) => (
<CommandItem
key={o.id}
@@ -1,139 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { GET } from "./route";
const mocks = vi.hoisted(() => {
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
const nextAuth = vi.fn(() => nextAuthHandler);
return {
nextAuth,
nextAuthHandler,
baseSignIn: vi.fn(async () => true),
baseSession: vi.fn(async ({ session }: { session: unknown }) => session),
baseEventSignIn: vi.fn(),
queueAuditEventBackground: vi.fn(),
captureException: vi.fn(),
loggerError: vi.fn(),
};
});
vi.mock("next-auth", () => ({
default: mocks.nextAuth,
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: undefined,
}));
vi.mock("@sentry/nextjs", () => ({
captureException: mocks.captureException,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.loggerError,
})),
},
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {
callbacks: {
signIn: mocks.baseSignIn,
session: mocks.baseSession,
},
events: {
signIn: mocks.baseEventSignIn,
},
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: mocks.queueAuditEventBackground,
}));
const getWrappedAuthOptions = async (requestId: string = "req-123") => {
const request = new Request("http://localhost/api/auth/signin", {
headers: { "x-request-id": requestId },
});
await GET(request, {} as any);
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
return mocks.nextAuth.mock.calls[0][0];
};
describe("auth route audit logging", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("logs successful sign-in from the NextAuth signIn event after session creation", async () => {
const authOptions = await getWrappedAuthOptions();
const user = { id: "user_1", email: "user@example.com", name: "User Example" };
const account = { provider: "keycloak" };
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
await authOptions.events.signIn({ user, account, isNewUser: false });
expect(mocks.baseEventSignIn).toHaveBeenCalledWith({ user, account, isNewUser: false });
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_1",
targetId: "user_1",
organizationId: "unknown",
status: "success",
userType: "user",
newObject: expect.objectContaining({
email: "user@example.com",
authMethod: "sso",
provider: "keycloak",
sessionStrategy: "database",
isNewUser: false,
}),
})
);
});
test("logs failed sign-in attempts from the callback stage with the request event id", async () => {
const error = new Error("Access denied");
mocks.baseSignIn.mockRejectedValueOnce(error);
const authOptions = await getWrappedAuthOptions("req-failure");
const user = { id: "user_2", email: "user2@example.com" };
const account = { provider: "credentials" };
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("Access denied");
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_2",
targetId: "user_2",
organizationId: "unknown",
status: "failure",
userType: "user",
eventId: "req-failure",
newObject: expect.objectContaining({
email: "user2@example.com",
authMethod: "password",
provider: "credentials",
errorMessage: "Access denied",
}),
})
);
});
});
+64 -63
View File
@@ -6,26 +6,10 @@ import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const fetchCache = "force-no-store";
const getAuthMethod = (account: Account | null) => {
if (account?.provider === "credentials") {
return "password";
}
if (account?.provider === "token") {
return "email_verification";
}
if (account?.provider) {
return "sso";
}
return "unknown";
};
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -33,6 +17,44 @@ const handler = async (req: Request, ctx: any) => {
...baseAuthOptions,
callbacks: {
...baseAuthOptions.callbacks,
async jwt(params: any) {
let result: any = params.token;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.jwt) {
result = await baseAuthOptions.callbacks.jwt(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("JWT callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
// Audit JWT operations (token refresh, updates)
if (params.trigger && params.token?.profile?.id) {
const status: TAuditStatus = error ? "failure" : "success";
const auditLog = {
action: "jwtTokenCreated" as const,
targetType: "user" as const,
userId: params.token.profile.id,
targetId: params.token.profile.id,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: { trigger: params.trigger, tokenType: "jwt" },
...(error ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
}
if (error) throw error;
return result;
},
async session(params: any) {
let result: any = params.session;
let error: any = undefined;
@@ -68,7 +90,7 @@ const handler = async (req: Request, ctx: any) => {
}) {
let result: boolean | string = true;
let error: any = undefined;
const authMethod = getAuthMethod(account);
let authMethod = "unknown";
try {
if (baseAuthOptions.callbacks?.signIn) {
@@ -80,6 +102,15 @@ const handler = async (req: Request, ctx: any) => {
credentials,
});
}
// Determine authentication method for more detailed logging
if (account?.provider === "credentials") {
authMethod = "password";
} else if (account?.provider === "token") {
authMethod = "email_verification";
} else if (account?.provider && account.provider !== "credentials") {
authMethod = "sso";
}
} catch (err) {
error = err;
result = false;
@@ -91,58 +122,28 @@ const handler = async (req: Request, ctx: any) => {
}
}
if (result === false) {
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "failure",
userType: "user",
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error instanceof Error ? { errorMessage: error.message } : {}),
},
eventId,
});
}
if (error) throw error;
return result;
},
},
events: {
...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) {
try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) {
logger.withContext({ eventId, err }).error("Sign-in event callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
const status: TAuditStatus = result === false ? "failure" : "success";
const auditLog = {
action: "signedIn" as const,
targetType: "user" as const,
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "success",
userType: "user",
status,
userType: "user" as const,
newObject: {
...user,
authMethod: getAuthMethod(account),
authMethod,
provider: account?.provider,
sessionStrategy: "database",
isNewUser: isNewUser ?? false,
...(error ? { errorMessage: error.message } : {}),
},
});
...(status === "failure" ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
if (error) throw error;
return result;
},
},
};
@@ -4,6 +4,11 @@ import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displ
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import {
applyPublicIpRateLimit,
publicEdgeRateLimitPolicies,
} from "@/modules/core/rate-limit/public-edge-rate-limit";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -24,6 +29,15 @@ export const OPTIONS = async (): Promise<Response> => {
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
try {
await applyPublicIpRateLimit(publicEdgeRateLimitPolicies.v2ClientDisplays, rateLimitConfigs.api.client);
} catch (error) {
return responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Rate limit exceeded",
true
);
}
const params = await context.params;
const jsonInput = await request.json();
const inputValidation = ZDisplayCreateInputV2.safeParse({
@@ -14,6 +14,11 @@ import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import {
applyPublicIpRateLimit,
publicEdgeRateLimitPolicies,
} from "@/modules/core/rate-limit/public-edge-rate-limit";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { createResponseWithQuotaEvaluation } from "./lib/response";
@@ -36,6 +41,15 @@ export const OPTIONS = async (): Promise<Response> => {
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
try {
await applyPublicIpRateLimit(publicEdgeRateLimitPolicies.v2ClientResponses, rateLimitConfigs.api.client);
} catch (error) {
return responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Rate limit exceeded",
true
);
}
const params = await context.params;
const requestHeaders = await headers();
let responseInput;
+67 -35
View File
@@ -12,6 +12,10 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/types/audit-log", () => ({
UNKNOWN_DATA: "unknown",
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn((callback) => {
@@ -72,10 +76,13 @@ vi.mock("@/app/middleware/endpoint-validator", async () => {
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
applyRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/public-edge-rate-limit", () => ({
applyPublicIpRateLimitForRoute: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
@@ -115,6 +122,7 @@ describe("withV1ApiWrapper", () => {
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: true,
EDGE_RATE_LIMIT_PROVIDER: "none",
IS_PRODUCTION: true,
SENTRY_DSN: "dsn",
ENCRYPTION_KEY: "test-key",
@@ -131,11 +139,13 @@ describe("withV1ApiWrapper", () => {
});
test("logs and audits on error response with API key authentication", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -183,11 +193,13 @@ describe("withV1ApiWrapper", () => {
});
test("does not log Sentry if not 500", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -229,11 +241,13 @@ describe("withV1ApiWrapper", () => {
});
test("logs and audits on thrown error", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -285,11 +299,13 @@ describe("withV1ApiWrapper", () => {
});
test("does not log on success response but still audits", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -333,17 +349,20 @@ describe("withV1ApiWrapper", () => {
test("does not call audit if AUDIT_LOG_ENABLED is false", async () => {
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: false,
EDGE_RATE_LIMIT_PROVIDER: "none",
IS_PRODUCTION: true,
SENTRY_DSN: "dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
}));
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { withV1ApiWrapper } = await import("./with-api-logging");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
@@ -366,10 +385,13 @@ describe("withV1ApiWrapper", () => {
});
test("handles client-side API routes without authentication", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
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");
const { applyPublicIpRateLimitForRoute } = await import(
"@/modules/core/rate-limit/public-edge-rate-limit"
);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
@@ -378,7 +400,7 @@ describe("withV1ApiWrapper", () => {
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(applyPublicIpRateLimitForRoute).mockResolvedValue("app");
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
@@ -396,11 +418,17 @@ describe("withV1ApiWrapper", () => {
auditLog: undefined,
authentication: null,
});
expect(applyPublicIpRateLimitForRoute).toHaveBeenCalledWith(
"/api/v1/client/displays",
"GET",
expect.objectContaining({ max: 100 })
);
});
test("returns authentication error for non-client routes without auth", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -422,8 +450,9 @@ describe("withV1ApiWrapper", () => {
});
test("uses unauthenticatedResponse when provided instead of default 401", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { getServerSession } = await import("next-auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -455,8 +484,9 @@ describe("withV1ApiWrapper", () => {
test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
@@ -481,11 +511,13 @@ describe("withV1ApiWrapper", () => {
});
test("skips audit log creation when no action/targetType provided", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
+19 -5
View File
@@ -13,7 +13,8 @@ import {
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { applyPublicIpRateLimitForRoute } from "@/modules/core/rate-limit/public-edge-rate-limit";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
@@ -54,14 +55,22 @@ enum ApiV1RouteTypeEnum {
/**
* Apply client-side API rate limiting (IP-based)
*/
const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
const applyClientRateLimit = async (
req: NextRequest,
customRateLimitConfig?: TRateLimitConfig
): Promise<void> => {
await applyPublicIpRateLimitForRoute(
req.nextUrl.pathname,
req.method,
customRateLimitConfig ?? rateLimitConfigs.api.client
);
};
/**
* Handle rate limiting based on authentication and API type
*/
const handleRateLimiting = async (
req: NextRequest,
authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum,
customRateLimitConfig?: TRateLimitConfig
@@ -81,7 +90,7 @@ const handleRateLimiting = async (
}
if (routeType === ApiV1RouteTypeEnum.Client) {
await applyClientRateLimit(customRateLimitConfig);
await applyClientRateLimit(req, customRateLimitConfig);
}
} catch (error) {
return responses.tooManyRequestsResponse(error instanceof Error ? error.message : "Rate limit exceeded");
@@ -305,7 +314,12 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
// === Rate Limiting ===
if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
const rateLimitResponse = await handleRateLimiting(
req,
authentication,
routeType,
customRateLimitConfig
);
if (rateLimitResponse) return rateLimitResponse;
}
@@ -48,6 +48,10 @@ describe("endpoint-validator", () => {
isClientSideApi: true,
isRateLimited: false,
});
expect(isClientSideApiRoute("/api/v1/client/og-image")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
});
test("should return false for non-client-side API routes", () => {
@@ -13,7 +13,7 @@ export enum AuthenticationMethod {
export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; isRateLimited: boolean } => {
// Open Graph image generation route is a client side API route but it should not be rate limited
if (url.includes("/api/v1/client/og")) return { isClientSideApi: true, isRateLimited: false };
if (/^\/api\/v1\/client\/og(?:\/.*)?$/.test(url)) return { isClientSideApi: true, isRateLimited: false };
const regex = /^\/api\/v\d+\/client\//;
return { isClientSideApi: regex.test(url), isRateLimited: true };
-3
View File
@@ -188,7 +188,6 @@ checksums:
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
common/edit: eee7f39ff90b18852afc1671f21fbaa9
common/elements: 8cb054d952b341e5965284860d532bc7
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
@@ -259,7 +258,6 @@ checksums:
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership: 83c856bbc2af99d8c3d860959d1d2a85
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/meta: 842eac888f134f3525f8ea613d933687
common/metadata: 695d4f7da261ba76e3be4de495491028
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
@@ -301,7 +299,6 @@ checksums:
common/organization_id: ef09b71c84a25b5da02a23c77e68a335
common/organization_settings: 11528aa89ae9935e55dcb54478058775
common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
-97
View File
@@ -1,97 +0,0 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { upsertAccount } from "./service";
const { mockUpsert } = vi.hoisted(() => ({
mockUpsert: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
account: {
upsert: mockUpsert,
},
},
}));
describe("account service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("upsertAccount keeps user ownership immutable on update", async () => {
const accountData = {
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
};
mockUpsert.mockResolvedValue({
id: "account-1",
createdAt: new Date(),
updatedAt: new Date(),
...accountData,
});
await upsertAccount(accountData);
expect(mockUpsert).toHaveBeenCalledWith({
where: {
provider_providerAccountId: {
provider: "google",
providerAccountId: "provider-1",
},
},
create: accountData,
update: {
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
},
});
});
test("upsertAccount wraps Prisma known request errors", async () => {
const prismaError = Object.assign(Object.create(Prisma.PrismaClientKnownRequestError.prototype), {
message: "duplicate account",
});
mockUpsert.mockRejectedValue(prismaError);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toMatchObject({
name: "DatabaseError",
message: "duplicate account",
});
});
test("upsertAccount rethrows non-Prisma errors", async () => {
const error = new Error("unexpected failure");
mockUpsert.mockRejectedValue(error);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toThrow("unexpected failure");
});
});
-33
View File
@@ -20,36 +20,3 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
throw error;
}
};
export const upsertAccount = async (accountData: TAccountInput): Promise<TAccount> => {
const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
const updateAccountData: Omit<TAccountInput, "userId" | "type" | "provider" | "providerAccountId"> = {
access_token: validatedAccountData.access_token,
refresh_token: validatedAccountData.refresh_token,
expires_at: validatedAccountData.expires_at,
scope: validatedAccountData.scope,
token_type: validatedAccountData.token_type,
id_token: validatedAccountData.id_token,
};
try {
const account = await prisma.account.upsert({
where: {
provider_providerAccountId: {
provider: validatedAccountData.provider,
providerAccountId: validatedAccountData.providerAccountId,
},
},
create: validatedAccountData,
update: updateAccountData,
});
return account;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
+1 -1
View File
@@ -3,6 +3,7 @@ import { TUserLocale } from "@formbricks/types/user";
import { env } from "./env";
export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1";
export const EDGE_RATE_LIMIT_PROVIDER = env.EDGE_RATE_LIMIT_PROVIDER ?? "none";
export const IS_PRODUCTION = env.NODE_ENV === "production";
@@ -26,7 +27,6 @@ export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
+2 -2
View File
@@ -15,13 +15,13 @@ export const env = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
E2E_TESTING: z.enum(["1", "0"]).optional(),
EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(),
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
EDGE_RATE_LIMIT_PROVIDER: z.enum(["none", "cloudflare", "cloudarmor", "envoy"]).optional(),
ENCRYPTION_KEY: z.string(),
ENTERPRISE_LICENSE_KEY: z.string().optional(),
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
@@ -142,13 +142,13 @@ export const env = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
DEBUG: process.env.DEBUG,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
E2E_TESTING: process.env.E2E_TESTING,
EMAIL_AUTH_DISABLED: process.env.EMAIL_AUTH_DISABLED,
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
EDGE_RATE_LIMIT_PROVIDER: process.env.EDGE_RATE_LIMIT_PROVIDER,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
ENVIRONMENT: process.env.ENVIRONMENT,
@@ -9,10 +9,6 @@ vi.mock("node:dns", () => ({
},
}));
vi.mock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
}));
const mockResolve = vi.mocked(dns.resolve);
const mockResolve6 = vi.mocked(dns.resolve6);
@@ -298,78 +294,4 @@ describe("validateWebhookUrl", () => {
});
});
});
describe("DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS", () => {
test("allows private IP URLs when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://127.0.0.1/")).resolves.toBeUndefined();
await expect(validateWithFlag("http://192.168.1.1/test")).resolves.toBeUndefined();
await expect(validateWithFlag("http://10.0.0.1/webhook")).resolves.toBeUndefined();
});
test("allows localhost when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost/webhook")).resolves.toBeUndefined();
await expect(validateWithFlag("http://localhost:3333/webhook")).resolves.toBeUndefined();
});
test("allows localhost.localdomain when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost.localdomain/path")).resolves.toBeUndefined();
});
test("allows hostname resolving to private IP when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(["192.168.1.1"]);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://internal.company.com/webhook")).resolves.toBeUndefined();
});
test("still rejects unresolvable hostnames when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(null, null);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://typo-gibberish.invalid/hook")).rejects.toThrow(
"Could not resolve webhook URL hostname"
);
});
test("still rejects invalid URL format when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("not-a-url")).rejects.toThrow("Invalid webhook URL format");
});
test("still rejects non-HTTP protocols when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("ftp://192.168.1.1/")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
});
});
+6 -16
View File
@@ -1,7 +1,6 @@
import "server-only";
import dns from "node:dns";
import { InvalidInputError } from "@formbricks/types/errors";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
const BLOCKED_HOSTNAMES = new Set([
"localhost",
@@ -140,10 +139,8 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
const hostname = parsed.hostname;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
// Direct IP literal — validate without DNS resolution
@@ -152,17 +149,12 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
return;
}
// Skip DNS resolution for localhost-like hostnames when internal URLs are allowed since these are resolved via /etc/hosts and not DNS
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
return;
}
// Domain name — resolve DNS and validate every resolved IP
let resolvedIPs: string[];
try {
@@ -176,11 +168,9 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
);
}
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
}
};
-3
View File
@@ -215,7 +215,6 @@
"duplicate_copy_number": "(Kopie {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Bearbeiten",
"elements": "Elemente",
"email": "E-Mail",
"ending_card": "Abschluss-Karte",
"enter_url": "URL eingeben",
@@ -286,7 +285,6 @@
"members_and_teams": "Mitglieder & Teams",
"membership": "Mitgliedschaft",
"membership_not_found": "Mitgliedschaft nicht gefunden",
"meta": "Meta",
"metadata": "Metadaten",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
"mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
@@ -328,7 +326,6 @@
"organization_id": "Organisations-ID",
"organization_settings": "Organisationseinstellungen",
"other": "Andere",
"other_filters": "Weitere Filter",
"others": "Andere",
"overlay_color": "Overlay-Farbe",
"overview": "Überblick",
-3
View File
@@ -215,7 +215,6 @@
"duplicate_copy_number": "(copy {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Edit",
"elements": "Elements",
"email": "Email",
"ending_card": "Ending card",
"enter_url": "Enter URL",
@@ -286,7 +285,6 @@
"members_and_teams": "Members & Teams",
"membership": "Membership",
"membership_not_found": "Membership not found",
"meta": "Meta",
"metadata": "Metadata",
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
"mobile_overlay_surveys_look_good": "Do not worry your surveys look great on every device and screen size!",
@@ -328,7 +326,6 @@
"organization_id": "Organization ID",
"organization_settings": "Organization settings",
"other": "Other",
"other_filters": "Other Filters",
"others": "Others",
"overlay_color": "Overlay color",
"overview": "Overview",
-3
View File
@@ -215,7 +215,6 @@
"duplicate_copy_number": "(copia {copyNumber})",
"e_commerce": "Comercio electrónico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Tarjeta final",
"enter_url": "Introducir URL",
@@ -286,7 +285,6 @@
"members_and_teams": "Miembros y equipos",
"membership": "Membresía",
"membership_not_found": "Membresía no encontrada",
"meta": "Meta",
"metadata": "Metadatos",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.",
"mobile_overlay_surveys_look_good": "No te preocupes ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
@@ -328,7 +326,6 @@
"organization_id": "ID de organización",
"organization_settings": "Ajustes de la organización",
"other": "Otro",
"other_filters": "Otros Filtros",
"others": "Otros",
"overlay_color": "Color de superposición",
"overview": "Resumen",
-3
View File
@@ -215,7 +215,6 @@
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Modifier",
"elements": "Éléments",
"email": "Email",
"ending_card": "Carte de fin",
"enter_url": "Saisir l'URL",
@@ -286,7 +285,6 @@
"members_and_teams": "Membres & Équipes",
"membership": "Adhésion",
"membership_not_found": "Abonnement non trouvé",
"meta": "Méta",
"metadata": "Métadonnées",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
@@ -328,7 +326,6 @@
"organization_id": "Identifiant de l'organisation",
"organization_settings": "Paramètres de l'organisation",
"other": "Autre",
"other_filters": "Autres filtres",
"others": "Autres",
"overlay_color": "Couleur de superposition",
"overview": "Aperçu",
-3
View File
@@ -215,7 +215,6 @@
"duplicate_copy_number": "({copyNumber}. másolat)",
"e_commerce": "E-kereskedelem",
"edit": "Szerkesztés",
"elements": "Elemek",
"email": "E-mail",
"ending_card": "Befejező kártya",
"enter_url": "URL megadása",
@@ -286,7 +285,6 @@
"members_and_teams": "Tagok és csapatok",
"membership": "Tagság",
"membership_not_found": "A tagság nem található",
"meta": "Meta",
"metadata": "Metaadatok",
"mobile_overlay_app_works_best_on_desktop": "A Formbricks nagyobb képernyőn működik a legjobban. A kérdőívek kezeléséhez vagy összeállításához váltson másik eszközre.",
"mobile_overlay_surveys_look_good": "Ne aggódjon a kérdőívei minden eszközön és képernyőméretnél remekül néznek ki!",
@@ -328,7 +326,6 @@
"organization_id": "Szervezetazonosító",
"organization_settings": "Szervezet beállításai",
"other": "Egyéb",
"other_filters": "Egyéb szűrők",
"others": "Mások",
"overlay_color": "Rávetítés színe",
"overview": "Áttekintés",
-3
View File
@@ -215,7 +215,6 @@
"duplicate_copy_number": "(コピー {copyNumber})",
"e_commerce": "Eコマース",
"edit": "編集",
"elements": "要素",
"email": "メールアドレス",
"ending_card": "終了カード",
"enter_url": "URLを入力",
@@ -286,7 +285,6 @@
"members_and_teams": "メンバー&チーム",
"membership": "メンバーシップ",
"membership_not_found": "メンバーシップが見つかりません",
"meta": "メタ",
"metadata": "メタデータ",
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
@@ -328,7 +326,6 @@
"organization_id": "組織ID",
"organization_settings": "組織設定",
"other": "その他",
"other_filters": "その他のフィルター",
"others": "その他",
"overlay_color": "オーバーレイの色",
"overview": "概要",
-3
View File
@@ -215,7 +215,6 @@
"duplicate_copy_number": "(kopie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Bewerking",
"elements": "Elementen",
"email": "E-mail",
"ending_card": "Einde kaart",
"enter_url": "URL invoeren",
@@ -286,7 +285,6 @@
"members_and_teams": "Leden & teams",
"membership": "Lidmaatschap",
"membership_not_found": "Lidmaatschap niet gevonden",
"meta": "Meta",
"metadata": "Metagegevens",
"mobile_overlay_app_works_best_on_desktop": "Formbricks werkt het beste op een groter scherm. Schakel over naar een ander apparaat om enquêtes te beheren of samen te stellen.",
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
@@ -328,7 +326,6 @@
"organization_id": "Organisatie-ID",
"organization_settings": "Organisatie-instellingen",
"other": "Ander",
"other_filters": "Overige filters",
"others": "Anderen",
"overlay_color": "Overlaykleur",
"overview": "Overzicht",
-3
View File
@@ -215,7 +215,6 @@
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "comércio eletrônico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Cartão de encerramento",
"enter_url": "Inserir URL",
@@ -286,7 +285,6 @@
"members_and_teams": "Membros e equipes",
"membership": "Associação",
"membership_not_found": "Assinatura não encontrada",
"meta": "Meta",
"metadata": "metadados",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
@@ -328,7 +326,6 @@
"organization_id": "ID da Organização",
"organization_settings": "Configurações da Organização",
"other": "outro",
"other_filters": "Outros Filtros",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão Geral",
-3
View File
@@ -215,7 +215,6 @@
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "Comércio Eletrónico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Cartão de encerramento",
"enter_url": "Introduzir URL",
@@ -286,7 +285,6 @@
"members_and_teams": "Membros e equipas",
"membership": "Subscrição",
"membership_not_found": "Associação não encontrada",
"meta": "Meta",
"metadata": "Metadados",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
@@ -328,7 +326,6 @@
"organization_id": "ID da Organização",
"organization_settings": "Configurações da Organização",
"other": "Outro",
"other_filters": "Outros Filtros",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão geral",
-3
View File
@@ -215,7 +215,6 @@
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "Comerț electronic",
"edit": "Editare",
"elements": "Elemente",
"email": "Email",
"ending_card": "Cardul de finalizare",
"enter_url": "Introduceți URL-ul",
@@ -286,7 +285,6 @@
"members_and_teams": "Membri și echipe",
"membership": "Abonament",
"membership_not_found": "Apartenența nu a fost găsită",
"meta": "Meta",
"metadata": "Metadate",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
"mobile_overlay_surveys_look_good": "Nu vă faceți griji chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
@@ -328,7 +326,6 @@
"organization_id": "ID Organizație",
"organization_settings": "Setări Organizație",
"other": "Altele",
"other_filters": "Alte Filtre",
"others": "Altele",
"overlay_color": "Culoare overlay",
"overview": "Prezentare generală",
-3
View File
@@ -215,7 +215,6 @@
"duplicate_copy_number": "(копия {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Редактировать",
"elements": "Элементы",
"email": "Email",
"ending_card": "Завершающая карточка",
"enter_url": "Введите URL",
@@ -286,7 +285,6 @@
"members_and_teams": "Участники и команды",
"membership": "Членство",
"membership_not_found": "Участие не найдено",
"meta": "Мета",
"metadata": "Метаданные",
"mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.",
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
@@ -328,7 +326,6 @@
"organization_id": "ID организации",
"organization_settings": "Настройки организации",
"other": "Другое",
"other_filters": "Другие фильтры",
"others": "Другие",
"overlay_color": "Цвет наложения",
"overview": "Обзор",
-3
View File
@@ -215,7 +215,6 @@
"duplicate_copy_number": "(kopia {copyNumber})",
"e_commerce": "E-handel",
"edit": "Redigera",
"elements": "Element",
"email": "E-post",
"ending_card": "Avslutningskort",
"enter_url": "Ange URL",
@@ -286,7 +285,6 @@
"members_and_teams": "Medlemmar och team",
"membership": "Medlemskap",
"membership_not_found": "Medlemskap hittades inte",
"meta": "Meta",
"metadata": "Metadata",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fungerar bäst på en större skärm. Byt till en annan enhet för att hantera eller bygga enkäter.",
"mobile_overlay_surveys_look_good": "Oroa dig inte dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
@@ -328,7 +326,6 @@
"organization_id": "Organisations-ID",
"organization_settings": "Organisationsinställningar",
"other": "Annat",
"other_filters": "Andra filter",
"others": "Andra",
"overlay_color": "Overlay-färg",
"overview": "Översikt",
-3
View File
@@ -215,7 +215,6 @@
"duplicate_copy_number": "(副本 {copyNumber}",
"e_commerce": "电子商务",
"edit": "编辑",
"elements": "元素",
"email": "邮箱",
"ending_card": "结尾卡片",
"enter_url": "输入 URL",
@@ -286,7 +285,6 @@
"members_and_teams": "成员和团队",
"membership": "会员资格",
"membership_not_found": "未找到会员资格",
"meta": "元数据",
"metadata": "元数据",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
@@ -328,7 +326,6 @@
"organization_id": "组织 ID",
"organization_settings": "组织 设置",
"other": "其他",
"other_filters": "其他筛选条件",
"others": "其他",
"overlay_color": "覆盖层颜色",
"overview": "概览",
-3
View File
@@ -215,7 +215,6 @@
"duplicate_copy_number": "(複製 {copyNumber}",
"e_commerce": "電子商務",
"edit": "編輯",
"elements": "元素",
"email": "電子郵件",
"ending_card": "結尾卡片",
"enter_url": "輸入 URL",
@@ -286,7 +285,6 @@
"members_and_teams": "成員與團隊",
"membership": "會員資格",
"membership_not_found": "找不到成員資格",
"meta": "Meta",
"metadata": "元數據",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
@@ -328,7 +326,6 @@
"organization_id": "組織 ID",
"organization_settings": "組織設定",
"other": "其他",
"other_filters": "其他篩選條件",
"others": "其他",
"overlay_color": "覆蓋層顏色",
"overview": "概覽",
+125 -71
View File
@@ -3,32 +3,16 @@ import { Provider } from "next-auth/providers/index";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
// Import mocked rate limiting functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import {
applyPublicIpRateLimit,
publicEdgeRateLimitPolicies,
} from "@/modules/core/rate-limit/public-edge-rate-limit";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { getUserByEmail } from "./user";
import { hashPassword } from "./utils";
vi.mock("@next-auth/prisma-adapter", () => ({
PrismaAdapter: vi.fn(() => ({
createUser: vi.fn(),
getUser: vi.fn(),
getUserByEmail: vi.fn(),
getUserByAccount: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
linkAccount: vi.fn(),
unlinkAccount: vi.fn(),
createSession: vi.fn(),
getSessionAndUser: vi.fn(),
updateSession: vi.fn(),
deleteSession: vi.fn(),
createVerificationToken: vi.fn(),
useVerificationToken: vi.fn(),
})),
}));
// Mock encryption utilities
vi.mock("@/lib/encryption", () => ({
symmetricEncrypt: vi.fn((value: string) => `encrypted_${value}`),
@@ -38,11 +22,48 @@ vi.mock("@/lib/encryption", () => ({
// Mock JWT
vi.mock("@/lib/jwt");
// Mock rate limiting dependencies
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
vi.mock("@/modules/core/rate-limit/public-edge-rate-limit", () => ({
applyPublicIpRateLimit: vi.fn(),
publicEdgeRateLimitPolicies: {
authLogin: "auth.login",
authVerifyEmail: "auth.verify_email",
},
}));
vi.mock("./user", () => ({
getUserByEmail: vi.fn(),
updateUser: vi.fn(),
updateUserLastLoginAt: vi.fn(),
}));
vi.mock("./brevo", () => ({
createBrevoCustomer: vi.fn(),
}));
vi.mock("@/modules/ee/sso/lib/providers", () => ({
getSSOProviders: vi.fn(() => []),
}));
vi.mock("@/modules/ee/sso/lib/sso-handlers", () => ({
handleSsoCallback: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/types/audit-log", () => ({
UNKNOWN_DATA: "unknown",
}));
vi.mock("./utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("./utils")>();
return {
...actual,
shouldLogAuthFailure: vi.fn().mockResolvedValue(false),
};
});
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
auth: {
@@ -52,26 +73,22 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
},
}));
// Mock constants that this test needs while preserving untouched exports.
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: undefined,
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
};
});
vi.mock("@/lib/constants", () => ({
EMAIL_VERIFICATION_DISABLED: false,
EDGE_RATE_LIMIT_PROVIDER: "none",
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: undefined,
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
}));
// Mock next/headers
vi.mock("next/headers", () => ({
@@ -133,7 +150,7 @@ describe("authOptions", () => {
});
test("should throw error if user not found", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(null);
const credentials = { email: mockUser.email, password: mockPassword };
@@ -144,7 +161,7 @@ describe("authOptions", () => {
});
test("should throw error if user has no password stored", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
@@ -159,7 +176,7 @@ describe("authOptions", () => {
});
test("should throw error if password verification fails", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
@@ -174,7 +191,7 @@ describe("authOptions", () => {
});
test("should successfully login when credentials are valid", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
const fakeUser = {
id: mockUserId,
email: mockUser.email,
@@ -197,7 +214,7 @@ describe("authOptions", () => {
describe("Rate Limiting", () => {
test("should apply rate limiting before credential validation", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
@@ -210,12 +227,15 @@ describe("authOptions", () => {
await credentialsProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.login);
expect(applyIPRateLimit).toHaveBeenCalledBefore(prisma.user.findUnique as any);
expect(applyPublicIpRateLimit).toHaveBeenCalledWith(
publicEdgeRateLimitPolicies.authLogin,
rateLimitConfigs.auth.login
);
expect(applyPublicIpRateLimit).toHaveBeenCalledBefore(prisma.user.findUnique as any);
});
test("should block login when rate limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
vi.mocked(applyPublicIpRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
const findUniqueSpy = vi.spyOn(prisma.user, "findUnique");
@@ -230,7 +250,7 @@ describe("authOptions", () => {
});
test("should use correct rate limit configuration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
@@ -243,7 +263,7 @@ describe("authOptions", () => {
await credentialsProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith({
expect(applyPublicIpRateLimit).toHaveBeenCalledWith(publicEdgeRateLimitPolicies.authLogin, {
interval: 900,
allowedPerInterval: 30,
namespace: "auth:login",
@@ -253,7 +273,7 @@ describe("authOptions", () => {
describe("Two-Factor Backup Code login", () => {
test("should throw error if backup codes are missing", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
@@ -282,7 +302,7 @@ describe("authOptions", () => {
});
test("should throw error if token is invalid or user not found", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
const credentials = { token: "badtoken" };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
@@ -292,17 +312,20 @@ describe("authOptions", () => {
describe("Rate Limiting", () => {
test("should apply rate limiting before token verification", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
const credentials = { token: "sometoken" };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow();
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
expect(applyPublicIpRateLimit).toHaveBeenCalledWith(
publicEdgeRateLimitPolicies.authVerifyEmail,
rateLimitConfigs.auth.verifyEmail
);
});
test("should block verification when rate limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
vi.mocked(applyPublicIpRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
const findUniqueSpy = vi.spyOn(prisma.user, "findUnique");
@@ -319,20 +342,51 @@ describe("authOptions", () => {
});
describe("Callbacks", () => {
describe("session callback", () => {
test("should add user id and isActive to session from database user", async () => {
const session = { user: { email: "user6@example.com" } };
const user = { id: "user6", isActive: false };
describe("jwt callback", () => {
test("should add profile information to token if user is found", async () => {
vi.mocked(getUserByEmail).mockResolvedValue({
id: mockUser.id,
locale: mockUser.locale,
email: mockUser.email,
emailVerified: mockUser.emailVerified,
} as any);
const token = { email: mockUser.email };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual({
...token,
profile: { id: mockUser.id },
});
});
test("should return token unchanged if no existing user is found", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(null);
const token = { email: "nonexistent@example.com" };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual(token);
});
});
describe("session callback", () => {
test("should add user profile to session", async () => {
const token = {
id: "user6",
profile: { id: "user6", email: "user6@example.com" },
};
const session = { user: {} };
if (!authOptions.callbacks?.session) {
throw new Error("session callback is not defined");
}
const result = await authOptions.callbacks.session({ session, user } as any);
expect(result.user).toEqual({
email: "user6@example.com",
id: "user6",
isActive: false,
});
const result = await authOptions.callbacks.session({ session, token } as any);
expect(result.user).toEqual(token.profile);
});
});
@@ -354,7 +408,7 @@ describe("authOptions", () => {
const credentialsProvider = getProviderById("credentials");
test("should throw error if TOTP code is missing when 2FA is enabled", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
@@ -372,7 +426,7 @@ describe("authOptions", () => {
});
test("should throw error if two factor secret is missing", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
+30 -13
View File
@@ -1,4 +1,3 @@
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
@@ -14,7 +13,7 @@ import {
} from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import {
logAuthAttempt,
logAuthEvent,
@@ -24,7 +23,10 @@ import {
shouldLogAuthFailure,
verifyPassword,
} from "@/modules/auth/lib/utils";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import {
applyPublicIpRateLimit,
publicEdgeRateLimitPolicies,
} from "@/modules/core/rate-limit/public-edge-rate-limit";
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";
@@ -32,7 +34,6 @@ import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import { createBrevoCustomer } from "./brevo";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
id: "credentials",
@@ -57,7 +58,7 @@ export const authOptions: NextAuthOptions = {
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
},
async authorize(credentials, _req) {
await applyIPRateLimit(rateLimitConfigs.auth.login);
await applyPublicIpRateLimit(publicEdgeRateLimitPolicies.authLogin, 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
@@ -247,7 +248,10 @@ export const authOptions: NextAuthOptions = {
},
},
async authorize(credentials, _req) {
await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail);
await applyPublicIpRateLimit(
publicEdgeRateLimitPolicies.authVerifyEmail,
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
@@ -312,17 +316,30 @@ export const authOptions: NextAuthOptions = {
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
],
session: {
strategy: "database",
maxAge: SESSION_MAX_AGE,
},
callbacks: {
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
if ("isActive" in user && typeof user.isActive === "boolean") {
session.user.isActive = user.isActive;
}
async jwt({ token }) {
const existingUser = await getUserByEmail(token?.email!);
if (!existingUser) {
return token;
}
return {
...token,
profile: { id: existingUser.id },
isActive: existingUser.isActive,
};
},
async session({ session, token }) {
// @ts-expect-error
session.user.id = token?.id;
// @ts-expect-error
session.user = token.profile;
// @ts-expect-error
session.user.isActive = token.isActive;
return session;
},
async signIn({ user, account }) {
@@ -1,115 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getProxySession, getSessionTokenFromRequest } from "./proxy-session";
const { mockFindUnique } = vi.hoisted(() => ({
mockFindUnique: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
session: {
findUnique: mockFindUnique,
},
},
}));
const createRequest = (cookies: Record<string, string> = {}) => ({
cookies: {
get: (name: string) => {
const value = cookies[name];
return value ? { value } : undefined;
},
},
});
describe("proxy-session", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("reads the secure session cookie when present", () => {
const request = createRequest({
"__Secure-next-auth.session-token": "secure-token",
});
expect(getSessionTokenFromRequest(request)).toBe("secure-token");
});
test("returns null when no session cookie is present", async () => {
const request = createRequest();
const session = await getProxySession(request);
expect(session).toBeNull();
expect(mockFindUnique).not.toHaveBeenCalled();
});
test("returns null when the session is expired", async () => {
mockFindUnique.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() - 60_000),
user: {
isActive: true,
},
});
const request = createRequest({
"next-auth.session-token": "expired-token",
});
const session = await getProxySession(request);
expect(session).toBeNull();
expect(mockFindUnique).toHaveBeenCalledWith({
where: {
sessionToken: "expired-token",
},
select: {
userId: true,
expires: true,
user: {
select: {
isActive: true,
},
},
},
});
});
test("returns null when the session belongs to an inactive user", async () => {
mockFindUnique.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() + 60_000),
user: {
isActive: false,
},
});
const request = createRequest({
"next-auth.session-token": "inactive-user-token",
});
const session = await getProxySession(request);
expect(session).toBeNull();
});
test("returns the session when the cookie maps to a valid session", async () => {
const validSession = {
userId: "user-1",
expires: new Date(Date.now() + 60_000),
user: {
isActive: true,
},
};
mockFindUnique.mockResolvedValue(validSession);
const request = createRequest({
"next-auth.session-token": "valid-token",
});
const session = await getProxySession(request);
expect(session).toEqual(validSession);
});
});
@@ -1,54 +0,0 @@
import { prisma } from "@formbricks/database";
const NEXT_AUTH_SESSION_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
] as const;
type TCookieStore = {
get: (name: string) => { value: string } | undefined;
};
type TRequestWithCookies = {
cookies: TCookieStore;
};
export const getSessionTokenFromRequest = (request: TRequestWithCookies): string | null => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
const cookie = request.cookies.get(cookieName);
if (cookie?.value) {
return cookie.value;
}
}
return null;
};
export const getProxySession = async (request: TRequestWithCookies) => {
const sessionToken = getSessionTokenFromRequest(request);
if (!sessionToken) {
return null;
}
const session = await prisma.session.findUnique({
where: {
sessionToken,
},
select: {
userId: true,
expires: true,
user: {
select: {
isActive: true,
},
},
},
});
if (!session || session.expires <= new Date() || session.user.isActive === false) {
return null;
}
return session;
};
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { applyIPRateLimit } from "./helpers";
import {
applyPublicIpRateLimit,
applyPublicIpRateLimitForRoute,
getEdgeRateLimitProvider,
getPublicEdgeRateLimitPolicyId,
isPublicEdgeRateLimitManaged,
publicEdgeRateLimitPolicies,
} from "./public-edge-rate-limit";
vi.mock("./helpers", () => ({
applyIPRateLimit: vi.fn(),
}));
const mockConfig = {
interval: 60,
allowedPerInterval: 100,
namespace: "api:client",
};
describe("public-edge-rate-limit", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getEdgeRateLimitProvider", () => {
test("falls back to none for unknown providers", () => {
expect(getEdgeRateLimitProvider(undefined)).toBe("none");
expect(getEdgeRateLimitProvider("unknown")).toBe("none");
});
test("accepts configured providers", () => {
expect(getEdgeRateLimitProvider("cloudflare")).toBe("cloudflare");
expect(getEdgeRateLimitProvider("cloudarmor")).toBe("cloudarmor");
expect(getEdgeRateLimitProvider("envoy")).toBe("envoy");
});
});
describe("getPublicEdgeRateLimitPolicyId", () => {
test("classifies auth callback routes", () => {
expect(getPublicEdgeRateLimitPolicyId("/api/auth/callback/credentials", "POST")).toBe(
publicEdgeRateLimitPolicies.authLogin
);
expect(getPublicEdgeRateLimitPolicyId("/api/auth/callback/token", "POST")).toBe(
publicEdgeRateLimitPolicies.authVerifyEmail
);
});
test("classifies v1 client routes", () => {
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/env_123/environment", "GET")).toBe(
publicEdgeRateLimitPolicies.v1ClientDefault
);
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/env_123/storage", "POST")).toBe(
publicEdgeRateLimitPolicies.v1ClientStorageUpload
);
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/og", "GET")).toBeNull();
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/og/image", "GET")).toBeNull();
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/og-image", "GET")).toBe(
publicEdgeRateLimitPolicies.v1ClientDefault
);
});
test("classifies v2 public write routes", () => {
expect(getPublicEdgeRateLimitPolicyId("/api/v2/client/env_123/responses", "POST")).toBe(
publicEdgeRateLimitPolicies.v2ClientResponses
);
expect(getPublicEdgeRateLimitPolicyId("/api/v2/client/env_123/responses/resp_123", "PUT")).toBe(
publicEdgeRateLimitPolicies.v2ClientResponses
);
expect(getPublicEdgeRateLimitPolicyId("/api/v2/client/env_123/displays", "POST")).toBe(
publicEdgeRateLimitPolicies.v2ClientDisplays
);
expect(getPublicEdgeRateLimitPolicyId("/api/v2/client/env_123/storage", "POST")).toBe(
publicEdgeRateLimitPolicies.v2ClientStorageUpload
);
});
});
describe("isPublicEdgeRateLimitManaged", () => {
test("manages public policies on cloudflare and cloudarmor only", () => {
expect(isPublicEdgeRateLimitManaged(publicEdgeRateLimitPolicies.authLogin, "cloudflare")).toBe(true);
expect(isPublicEdgeRateLimitManaged(publicEdgeRateLimitPolicies.authLogin, "cloudarmor")).toBe(true);
expect(isPublicEdgeRateLimitManaged(publicEdgeRateLimitPolicies.authLogin, "none")).toBe(false);
expect(isPublicEdgeRateLimitManaged(publicEdgeRateLimitPolicies.authLogin, "envoy")).toBe(false);
});
});
describe("applyPublicIpRateLimit", () => {
test("uses app rate limiting when no edge provider manages the policy", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const source = await applyPublicIpRateLimit(
publicEdgeRateLimitPolicies.v2ClientResponses,
mockConfig,
"none"
);
expect(source).toBe("app");
expect(applyIPRateLimit).toHaveBeenCalledWith(mockConfig);
});
test("skips app rate limiting when the edge provider manages the policy", async () => {
const source = await applyPublicIpRateLimit(
publicEdgeRateLimitPolicies.v2ClientResponses,
mockConfig,
"cloudflare"
);
expect(source).toBe("edge");
expect(applyIPRateLimit).not.toHaveBeenCalled();
});
});
describe("applyPublicIpRateLimitForRoute", () => {
test("uses the route classifier for managed public routes", async () => {
const source = await applyPublicIpRateLimitForRoute(
"/api/v2/client/env_123/displays",
"POST",
mockConfig,
"cloudarmor"
);
expect(source).toBe("edge");
expect(applyIPRateLimit).not.toHaveBeenCalled();
});
test("falls back to app rate limiting for unmanaged routes", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const source = await applyPublicIpRateLimitForRoute(
"/api/v1/client/env_123/environment",
"GET",
mockConfig,
"envoy"
);
expect(source).toBe("app");
expect(applyIPRateLimit).toHaveBeenCalledWith(mockConfig);
});
});
});
@@ -0,0 +1,135 @@
import { EDGE_RATE_LIMIT_PROVIDER } from "@/lib/constants";
import { applyIPRateLimit } from "./helpers";
import { TRateLimitConfig } from "./types/rate-limit";
export const publicEdgeRateLimitPolicies = {
authLogin: "auth.login",
authVerifyEmail: "auth.verify_email",
v1ClientDefault: "client.v1.default",
v1ClientStorageUpload: "client.storage.upload.v1",
v2ClientResponses: "client.responses.v2",
v2ClientDisplays: "client.displays.v2",
v2ClientStorageUpload: "client.storage.upload.v2",
} as const;
export type TPublicEdgeRateLimitPolicyId =
(typeof publicEdgeRateLimitPolicies)[keyof typeof publicEdgeRateLimitPolicies];
export type TEdgeRateLimitProvider = "none" | "cloudflare" | "cloudarmor" | "envoy";
const managedPublicEdgePolicies = Object.values(
publicEdgeRateLimitPolicies
) as TPublicEdgeRateLimitPolicyId[];
const managedPublicEdgePoliciesByProvider: Record<
TEdgeRateLimitProvider,
readonly TPublicEdgeRateLimitPolicyId[]
> = {
none: [],
cloudflare: managedPublicEdgePolicies,
cloudarmor: managedPublicEdgePolicies,
envoy: [],
};
const normalizeEdgeRateLimitProvider = (provider: string | undefined): TEdgeRateLimitProvider => {
switch (provider) {
case "cloudflare":
case "cloudarmor":
case "envoy":
return provider;
default:
return "none";
}
};
const normalizePathname = (pathname: string): string => {
if (pathname.length > 1 && pathname.endsWith("/")) {
return pathname.slice(0, -1);
}
return pathname;
};
export const getEdgeRateLimitProvider = (
provider: string | undefined = EDGE_RATE_LIMIT_PROVIDER
): TEdgeRateLimitProvider => normalizeEdgeRateLimitProvider(provider);
export const getPublicEdgeRateLimitPolicyId = (
pathname: string,
method: string
): TPublicEdgeRateLimitPolicyId | null => {
const normalizedPathname = normalizePathname(pathname);
const normalizedMethod = method.toUpperCase();
if (normalizedMethod === "POST" && normalizedPathname === "/api/auth/callback/credentials") {
return publicEdgeRateLimitPolicies.authLogin;
}
if (normalizedMethod === "POST" && normalizedPathname === "/api/auth/callback/token") {
return publicEdgeRateLimitPolicies.authVerifyEmail;
}
if (/^\/api\/v1\/client\/og(?:\/.*)?$/.test(normalizedPathname)) {
return null;
}
if (/^\/api\/v1\/client\/[^/]+\/storage$/.test(normalizedPathname) && normalizedMethod === "POST") {
return publicEdgeRateLimitPolicies.v1ClientStorageUpload;
}
if (/^\/api\/v2\/client\/[^/]+\/storage$/.test(normalizedPathname) && normalizedMethod === "POST") {
return publicEdgeRateLimitPolicies.v2ClientStorageUpload;
}
if (
/^\/api\/v2\/client\/[^/]+\/responses(?:\/[^/]+)?$/.test(normalizedPathname) &&
(normalizedMethod === "POST" || normalizedMethod === "PUT")
) {
return publicEdgeRateLimitPolicies.v2ClientResponses;
}
if (/^\/api\/v2\/client\/[^/]+\/displays$/.test(normalizedPathname) && normalizedMethod === "POST") {
return publicEdgeRateLimitPolicies.v2ClientDisplays;
}
if (normalizedPathname.startsWith("/api/v1/client/")) {
return publicEdgeRateLimitPolicies.v1ClientDefault;
}
return null;
};
export const isPublicEdgeRateLimitManaged = (
policyId: TPublicEdgeRateLimitPolicyId,
provider: string | undefined = EDGE_RATE_LIMIT_PROVIDER
): boolean => managedPublicEdgePoliciesByProvider[getEdgeRateLimitProvider(provider)].includes(policyId);
export const applyPublicIpRateLimit = async (
policyId: TPublicEdgeRateLimitPolicyId,
config: TRateLimitConfig,
provider: string | undefined = EDGE_RATE_LIMIT_PROVIDER
): Promise<"app" | "edge"> => {
if (isPublicEdgeRateLimitManaged(policyId, provider)) {
return "edge";
}
await applyIPRateLimit(config);
return "app";
};
export const applyPublicIpRateLimitForRoute = async (
pathname: string,
method: string,
config: TRateLimitConfig,
provider: string | undefined = EDGE_RATE_LIMIT_PROVIDER
): Promise<"app" | "edge"> => {
const policyId = getPublicEdgeRateLimitPolicyId(pathname, method);
if (!policyId) {
await applyIPRateLimit(config);
return "app";
}
return await applyPublicIpRateLimit(policyId, config, provider);
};
+10 -4
View File
@@ -106,7 +106,10 @@ describe("billing actions", () => {
});
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -125,7 +128,10 @@ describe("billing actions", () => {
} as any);
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -139,7 +145,7 @@ describe("billing actions", () => {
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -159,7 +165,7 @@ describe("billing actions", () => {
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
+2 -2
View File
@@ -216,7 +216,7 @@ export const startHobbyAction = authenticatedActionClient
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
@@ -248,7 +248,7 @@ export const startProTrialAction = authenticatedActionClient
}
await createProTrialSubscription(parsedInput.organizationId, customerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
@@ -150,7 +150,7 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
await handleSetupCheckoutCompleted(event.data.object, stripe);
}
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
await syncOrganizationBillingFromStripe(organizationId, {
id: event.id,
created: event.created,
@@ -1905,7 +1905,7 @@ describe("organization-billing", () => {
items: [{ price: "price_hobby_monthly", quantity: 1 }],
metadata: { organizationId: "org_1" },
},
{ idempotencyKey: "ensure-hobby-subscription-org_1-0" }
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" }
);
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
where: { organizationId: "org_1" },
@@ -1974,7 +1974,7 @@ describe("organization-billing", () => {
],
});
await reconcileCloudStripeSubscriptionsForOrganization("org_1");
await reconcileCloudStripeSubscriptionsForOrganization("org_1", "evt_123");
expect(mocks.subscriptionsCancel).toHaveBeenCalledWith("sub_hobby", { prorate: false });
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();
@@ -458,21 +458,18 @@ const resolvePendingChangeEffectiveAt = (
const ensureHobbySubscription = async (
organizationId: string,
customerId: string,
subscriptionCount: number
idempotencySuffix: string
): Promise<void> => {
if (!stripeClient) return;
const hobbyItems = await getCatalogItemsForPlan("hobby", "monthly");
// Include subscriptionCount so the key is stable across concurrent calls (same
// count → same key → Stripe deduplicates) but changes after a cancellation
// (count increases → new key → allows legitimate re-creation).
await stripeClient.subscriptions.create(
{
customer: customerId,
items: hobbyItems,
metadata: { organizationId },
},
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${subscriptionCount}` }
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${idempotencySuffix}` }
);
};
@@ -1267,7 +1264,8 @@ export const findOrganizationIdByStripeCustomerId = async (customerId: string):
};
export const reconcileCloudStripeSubscriptionsForOrganization = async (
organizationId: string
organizationId: string,
idempotencySuffix = "reconcile"
): Promise<void> => {
const client = stripeClient;
if (!IS_FORMBRICKS_CLOUD || !client) return;
@@ -1315,26 +1313,11 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
);
await Promise.all(
hobbySubscriptions.map(async ({ subscription }) => {
try {
await client.subscriptions.cancel(subscription.id, {
prorate: false,
});
} catch (err) {
if (
err instanceof Stripe.errors.StripeInvalidRequestError &&
err.statusCode === 404 &&
err.code === "resource_missing"
) {
logger.warn(
{ subscriptionId: subscription.id, organizationId },
"Subscription already deleted, skipping cancel"
);
return;
}
throw err;
}
})
hobbySubscriptions.map(({ subscription }) =>
client.subscriptions.cancel(subscription.id, {
prorate: false,
})
)
);
return;
}
@@ -1344,14 +1327,12 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
// (e.g. webhook + bootstrap) both seeing 0 and creating duplicate hobbies.
const freshSubscriptions = await client.subscriptions.list({
customer: customerId,
status: "all",
limit: 20,
status: "active",
limit: 1,
});
const freshActive = freshSubscriptions.data.filter((sub) => ACTIVE_SUBSCRIPTION_STATUSES.has(sub.status));
if (freshActive.length === 0) {
await ensureHobbySubscription(organizationId, customerId, freshSubscriptions.data.length);
if (freshSubscriptions.data.length === 0) {
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
}
}
};
@@ -1359,6 +1340,6 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
await ensureStripeCustomerForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, "bootstrap");
await syncOrganizationBillingFromStripe(organizationId);
};
+5 -20
View File
@@ -3,7 +3,7 @@ import type { Account } from "next-auth";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import type { TUser, TUserNotificationSettings } from "@formbricks/types/user";
import { upsertAccount } from "@/lib/account/service";
import { createAccount } from "@/lib/account/service";
import { DEFAULT_TEAM_ID, SKIP_INVITE_FOR_SSO } from "@/lib/constants";
import { getIsFreshInstance } from "@/lib/instance/service";
import { verifyInviteToken } from "@/lib/jwt";
@@ -23,21 +23,6 @@ import {
import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
const syncSsoAccount = async (userId: string, account: Account) => {
await upsertAccount({
userId,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
...(account.access_token !== undefined ? { access_token: account.access_token } : {}),
...(account.refresh_token !== undefined ? { refresh_token: account.refresh_token } : {}),
...(account.expires_at !== undefined ? { expires_at: account.expires_at } : {}),
...(account.scope !== undefined ? { scope: account.scope } : {}),
...(account.token_type !== undefined ? { token_type: account.token_type } : {}),
...(account.id_token !== undefined ? { id_token: account.id_token } : {}),
});
};
export const handleSsoCallback = async ({
user,
account,
@@ -123,7 +108,6 @@ export const handleSsoCallback = async ({
// User with this provider found
// check if email still the same
if (existingUserWithAccount.email === user.email) {
await syncSsoAccount(existingUserWithAccount.id, account);
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"SSO callback successful: existing user, email matches"
@@ -149,7 +133,6 @@ export const handleSsoCallback = async ({
);
await updateUser(existingUserWithAccount.id, { email: user.email });
await syncSsoAccount(existingUserWithAccount.id, account);
return true;
}
@@ -171,7 +154,6 @@ export const handleSsoCallback = async ({
const existingUserWithEmail = await getUserByEmail(user.email);
if (existingUserWithEmail) {
await syncSsoAccount(existingUserWithEmail.id, account);
contextLogger.debug(
{ existingUserId: existingUserWithEmail.id, action: "existing_user_login" },
"SSO callback successful: existing user found by email"
@@ -360,7 +342,6 @@ export const handleSsoCallback = async ({
// send new user to brevo
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
await syncSsoAccount(userProfile.id, account);
if (isMultiOrgEnabled) {
contextLogger.debug(
@@ -377,6 +358,10 @@ export const handleSsoCallback = async ({
"Assigning user to organization"
);
await createMembership(organization.id, userProfile.id, { role: "member", accepted: true });
await createAccount({
...account,
userId: userProfile.id,
});
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
contextLogger.debug(
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import type { TUser } from "@formbricks/types/user";
import { upsertAccount } from "@/lib/account/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { findMatchingLocale } from "@/lib/utils/locale";
@@ -63,7 +62,7 @@ vi.mock("@/modules/ee/sso/lib/team", () => ({
}));
vi.mock("@/lib/account/service", () => ({
upsertAccount: vi.fn(),
createAccount: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
@@ -204,36 +203,6 @@ describe("handleSsoCallback", () => {
});
});
test("should not overwrite stored tokens when the provider omits them", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...mockUser,
email: mockUser.email,
accounts: [{ provider: mockAccount.provider }],
} as any);
const result = await handleSsoCallback({
user: mockUser,
account: {
...mockAccount,
access_token: undefined,
refresh_token: undefined,
expires_at: undefined,
scope: undefined,
token_type: undefined,
id_token: undefined,
},
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(upsertAccount).toHaveBeenCalledWith({
userId: mockUser.id,
type: mockAccount.type,
provider: mockAccount.provider,
providerAccountId: mockAccount.providerAccountId,
});
});
test("should update user email if user with account exists but email changed", async () => {
const existingUser = {
...mockUser,
@@ -11,10 +11,9 @@ import { AddWebhookModal } from "./add-webhook-modal";
interface AddWebhookButtonProps {
environment: TEnvironment;
surveys: TSurvey[];
allowInternalUrls: boolean;
}
export const AddWebhookButton = ({ environment, surveys, allowInternalUrls }: AddWebhookButtonProps) => {
export const AddWebhookButton = ({ environment, surveys }: AddWebhookButtonProps) => {
const { t } = useTranslation();
const [isAddWebhookModalOpen, setAddWebhookModalOpen] = useState(false);
return (
@@ -32,7 +31,6 @@ export const AddWebhookButton = ({ environment, surveys, allowInternalUrls }: Ad
surveys={surveys}
open={isAddWebhookModalOpen}
setOpen={setAddWebhookModalOpen}
allowInternalUrls={allowInternalUrls}
/>
</>
);
@@ -34,16 +34,9 @@ interface AddWebhookModalProps {
open: boolean;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
allowInternalUrls: boolean;
}
export const AddWebhookModal = ({
environmentId,
surveys,
open,
setOpen,
allowInternalUrls,
}: AddWebhookModalProps) => {
export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWebhookModalProps) => {
const router = useRouter();
const {
handleSubmit,
@@ -66,7 +59,7 @@ export const AddWebhookModal = ({
sendSuccessToast: boolean
): Promise<{ success: boolean; secret?: string }> => {
try {
const { valid, error } = validWebHookURL(testEndpointInput, allowInternalUrls);
const { valid, error } = validWebHookURL(testEndpointInput);
if (!valid) {
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
return { success: false };
@@ -23,17 +23,9 @@ interface WebhookModalProps {
webhook: Webhook;
surveys: TSurvey[];
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookModal = ({
open,
setOpen,
webhook,
surveys,
isReadOnly,
allowInternalUrls,
}: WebhookModalProps) => {
export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: WebhookModalProps) => {
const { t, i18n } = useTranslation();
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
const [activeTab, setActiveTab] = useState(0);
@@ -46,13 +38,7 @@ export const WebhookModal = ({
{
title: t("common.settings"),
children: (
<WebhookSettingsTab
webhook={webhook}
surveys={surveys}
setOpen={setOpen}
isReadOnly={isReadOnly}
allowInternalUrls={allowInternalUrls}
/>
<WebhookSettingsTab webhook={webhook} surveys={surveys} setOpen={setOpen} isReadOnly={isReadOnly} />
),
},
];
@@ -26,16 +26,9 @@ interface WebhookSettingsTabProps {
surveys: TSurvey[];
setOpen: (v: boolean) => void;
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookSettingsTab = ({
webhook,
surveys,
setOpen,
isReadOnly,
allowInternalUrls,
}: WebhookSettingsTabProps) => {
export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: WebhookSettingsTabProps) => {
const { t } = useTranslation();
const router = useRouter();
const { register, handleSubmit } = useForm({
@@ -67,7 +60,7 @@ export const WebhookSettingsTab = ({
const handleTestEndpoint = async (sendSuccessToast: boolean): Promise<boolean> => {
try {
const { valid, error } = validWebHookURL(testEndpointInput, allowInternalUrls);
const { valid, error } = validWebHookURL(testEndpointInput);
if (!valid) {
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
return false;
@@ -14,7 +14,6 @@ interface WebhookTableProps {
surveys: TSurvey[];
children: [JSX.Element, JSX.Element[]];
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookTable = ({
@@ -23,7 +22,6 @@ export const WebhookTable = ({
surveys,
children: [TableHeading, webhookRows],
isReadOnly,
allowInternalUrls,
}: WebhookTableProps) => {
const [isWebhookDetailModalOpen, setWebhookDetailModalOpen] = useState(false);
const { t } = useTranslation();
@@ -73,7 +71,6 @@ export const WebhookTable = ({
webhook={activeWebhook}
surveys={surveys}
isReadOnly={isReadOnly}
allowInternalUrls={allowInternalUrls}
/>
</>
);
@@ -1,4 +1,4 @@
export const validWebHookURL = (urlInput: string, allowInternalUrls = false) => {
export const validWebHookURL = (urlInput: string) => {
const trimmedInput = urlInput.trim();
if (!trimmedInput) {
return { valid: false, error: "Please enter a URL" };
@@ -7,13 +7,6 @@ export const validWebHookURL = (urlInput: string, allowInternalUrls = false) =>
try {
const url = new URL(trimmedInput);
if (allowInternalUrls) {
if (url.protocol !== "https:" && url.protocol !== "http:") {
return { valid: false, error: "URL must start with https:// or http://" };
}
return { valid: true };
}
if (url.protocol !== "https:") {
return { valid: false, error: "URL must start with https://" };
}
@@ -1,4 +1,3 @@
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
import { getSurveys } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -22,24 +21,13 @@ export const WebhooksPage = async (props: { params: Promise<{ environmentId: str
getSurveys(params.environmentId, 200), // HOTFIX: not getting all surveys for now since it's maxing out the prisma accelerate limit
]);
const renderAddWebhookButton = () => (
<AddWebhookButton
environment={environment}
surveys={surveys}
allowInternalUrls={DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS}
/>
);
const renderAddWebhookButton = () => <AddWebhookButton environment={environment} surveys={surveys} />;
return (
<PageContentWrapper>
<GoBackButton />
<PageHeader pageTitle={t("common.webhooks")} cta={!isReadOnly ? renderAddWebhookButton() : <></>} />
<WebhookTable
environment={environment}
webhooks={webhooks}
surveys={surveys}
isReadOnly={isReadOnly}
allowInternalUrls={DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS}>
<WebhookTable environment={environment} webhooks={webhooks} surveys={surveys} isReadOnly={isReadOnly}>
<WebhookTableHeading />
{webhooks.map((webhook) => (
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} />
@@ -42,14 +42,14 @@ export interface ButtonProps
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, asChild = false, disabled, children, ...props }, ref) => {
({ className, variant, size, loading, asChild = false, children, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, loading, className }))}
disabled={loading}
ref={ref}
{...props}
disabled={loading || disabled}>
{...props}>
{loading ? (
<>
<Loader2 className="animate-spin" />
+1 -2
View File
@@ -42,7 +42,6 @@
"@lexical/react": "0.41.0",
"@lexical/rich-text": "0.41.0",
"@lexical/table": "0.41.0",
"@next-auth/prisma-adapter": "1.0.7",
"@opentelemetry/auto-instrumentations-node": "0.71.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.213.0",
"@opentelemetry/exporter-prometheus": "0.213.0",
@@ -100,7 +99,7 @@
"next-auth": "4.24.13",
"next-safe-action": "8.1.8",
"node-fetch": "3.3.2",
"nodemailer": "8.0.4",
"nodemailer": "8.0.2",
"otplib": "12.0.1",
"papaparse": "5.5.3",
"posthog-js": "1.360.0",
@@ -485,55 +485,5 @@ test.describe("Authentication Security Tests - Vulnerability Prevention", () =>
logger.info(`✅ Malformed request handled gracefully: status ${response.status()}`);
});
test("should invalidate a copied session cookie after logout", async ({ page, browser, users }) => {
const user = await users.create();
await user.login();
const sessionCookie = (await page.context().cookies()).find((cookie) =>
cookie.name.includes("next-auth.session-token")
);
expect(sessionCookie).toBeDefined();
const preLogoutContext = await browser.newContext();
try {
await preLogoutContext.addCookies([sessionCookie!]);
const preLogoutPage = await preLogoutContext.newPage();
await preLogoutPage.goto("http://localhost:3000/environments");
await expect(preLogoutPage).not.toHaveURL(/\/auth\/login/);
} finally {
await preLogoutContext.close();
}
const signOutCsrfToken = await page
.context()
.request.get("/api/auth/csrf")
.then((response) => response.json())
.then((json) => json.csrfToken);
const signOutResponse = await page.context().request.post("/api/auth/signout", {
form: {
callbackUrl: "/auth/login",
csrfToken: signOutCsrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
expect(signOutResponse.status()).not.toBe(500);
const replayContext = await browser.newContext();
try {
await replayContext.addCookies([sessionCookie!]);
const replayPage = await replayContext.newPage();
await replayPage.goto("http://localhost:3000/environments");
await expect(replayPage).toHaveURL(/\/auth\/login/);
} finally {
await replayContext.close();
}
});
});
});
-85
View File
@@ -1,85 +0,0 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { proxy } from "./proxy";
const { mockGetProxySession, mockIsPublicDomainConfigured, mockIsRequestFromPublicDomain } = vi.hoisted(
() => ({
mockGetProxySession: vi.fn(),
mockIsPublicDomainConfigured: vi.fn(),
mockIsRequestFromPublicDomain: vi.fn(),
})
);
vi.mock("@/modules/auth/lib/proxy-session", () => ({
getProxySession: mockGetProxySession,
}));
vi.mock("@/app/middleware/domain-utils", () => ({
isPublicDomainConfigured: mockIsPublicDomainConfigured,
isRequestFromPublicDomain: mockIsRequestFromPublicDomain,
}));
vi.mock("@/app/middleware/endpoint-validator", () => ({
isAuthProtectedRoute: (url: string) => url.startsWith("/environments"),
isRouteAllowedForDomain: vi.fn(() => true),
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/lib/utils/url", () => ({
isValidCallbackUrl: (url: string) => url.startsWith("http://localhost:3000"),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsPublicDomainConfigured.mockReturnValue(false);
mockIsRequestFromPublicDomain.mockReturnValue(false);
});
test("redirects unauthenticated protected routes to login with callbackUrl", async () => {
mockGetProxySession.mockResolvedValue(null);
const response = await proxy(new NextRequest("http://localhost:3000/environments/test"));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"http://localhost:3000/auth/login?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest"
);
});
test("rejects invalid callback URLs", async () => {
mockGetProxySession.mockResolvedValue(null);
const response = await proxy(
new NextRequest("http://localhost:3000/auth/login?callbackUrl=https%3A%2F%2Fevil.example")
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ error: "Invalid callback URL" });
});
test("redirects authenticated callback requests to the callback URL", async () => {
mockGetProxySession.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() + 60_000),
});
const response = await proxy(
new NextRequest(
"http://localhost:3000/auth/login?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest"
)
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("http://localhost:3000/environments/test");
});
});
+4 -4
View File
@@ -1,3 +1,4 @@
import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@formbricks/logger";
@@ -5,12 +6,11 @@ import { isPublicDomainConfigured, isRequestFromPublicDomain } from "@/app/middl
import { isAuthProtectedRoute, isRouteAllowedForDomain } from "@/app/middleware/endpoint-validator";
import { WEBAPP_URL } from "@/lib/constants";
import { isValidCallbackUrl } from "@/lib/utils/url";
import { getProxySession } from "@/modules/auth/lib/proxy-session";
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
const session = await getProxySession(request);
const token = await getToken({ req: request as any });
if (isAuthProtectedRoute(request.nextUrl.pathname) && !session) {
if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) {
const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`;
return NextResponse.redirect(loginUrl);
}
@@ -21,7 +21,7 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
return NextResponse.json({ error: "Invalid callback URL" }, { status: 400 });
}
if (session && callbackUrl) {
if (token && callbackUrl) {
return NextResponse.redirect(callbackUrl);
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default defineConfig({
provider: "v8", // Use V8 as the coverage provider
reporter: ["text", "html", "lcov"], // Generate text summary and HTML reports
reportsDirectory: "./coverage", // Output coverage reports to the coverage/ directory
include: ["app/**/*.ts", "modules/**/*.ts", "lib/**/*.ts", "lingodotdev/**/*.ts", "proxy.ts"],
include: ["app/**/*.ts", "modules/**/*.ts", "lib/**/*.ts", "lingodotdev/**/*.ts"],
exclude: [
// Build and configuration files
"**/.next/**", // Next.js build output
+1
View File
@@ -186,6 +186,7 @@ export const testInputValidation = async (service: Function, ...args: any[]): Pr
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
EDGE_RATE_LIMIT_PROVIDER: "none",
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
@@ -32,7 +32,6 @@ These variables are present inside your machine's docker-compose file. Restart t
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
| DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS | Allows webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x) if set to 1. Useful for self-hosted instances that need to send webhooks to internal services. | optional | |
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to 1. | optional | |
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
| MAIL_FROM_NAME | Email name/title to send emails from. | optional (required if email services are to be enabled) | |
@@ -70,18 +70,6 @@ endpoint with [ngrok](https://ngrok.com/docs/universal-gateway/http).
workflow while validating the webhook setup.
</Note>
### Allowing Internal URLs (Self-Hosted Only)
By default, Formbricks blocks webhook URLs that point to private or internal IP addresses (e.g. `localhost`, `192.168.x.x`, `10.x.x.x`) to prevent [SSRF attacks](https://owasp.org/www-community/attacks/Server-Side_Request_Forgery). If you are self-hosting Formbricks and need to send webhooks to internal services, you can set the following environment variable:
```sh
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
```
<Warning>
Only enable this on trusted, self-hosted environments. Enabling this on a publicly accessible instance exposes your server to SSRF risks.
</Warning>
If you encounter any issues or need help setting up webhooks, feel free to reach out to us on [GitHub Discussions](https://github.com/formbricks/formbricks/discussions). 😃
---
+2 -10
View File
@@ -91,26 +91,18 @@
"flatted": "3.4.2",
"hono": "4.12.7",
"@microsoft/api-extractor>minimatch": "10.2.4",
"minimatch@3.1.5": "file:vendor/minimatch-3.1.5",
"node-forge": "1.4.0",
"brace-expansion@5.0.4": "5.0.5",
"minimatch@3.1.5>brace-expansion": "5.0.5",
"minimatch@9.0.9": "10.2.4",
"lodash": "4.17.23",
"picomatch@2.3.1": "2.3.2",
"picomatch@4.0.3": "4.0.4",
"node-forge": ">=1.3.2",
"qs": "6.14.2",
"rollup": "4.59.0",
"socket.io-parser": "4.2.6",
"tar": ">=7.5.11",
"typeorm": ">=0.3.26",
"undici": "7.24.0",
"yaml": "2.8.3",
"fast-xml-parser": "5.5.7",
"diff": ">=8.0.3"
},
"comments": {
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono (Dependabot #313/#316/#317) - awaiting Prisma update | @tootallnate/once (Dependabot #305) - awaiting sqlite3/node-gyp chain update | schema-utils@3>ajv (Dependabot #287) - awaiting eslint/file-loader schema-utils update | axios (CVE-2025-58754, CVE-2026-25639) - awaiting @boxyhq/saml-jackson update | effect (Dependabot #339) - awaiting Prisma update | flatted (Dependabot #324/#338) - awaiting eslint/flat-cache update | minimatch (Dependabot #288/#294/#297) - awaiting react-email/glob update | node-forge (Dependabot #347/#348/#349/#350) - awaiting @boxyhq/saml-jackson update | brace-expansion (Dependabot #346 / npm audit) - awaiting upstream adoption of safe minimatch/brace-expansion combos in transitive tooling and @boxyhq/saml-jackson | lodash (npm audit) - awaiting @boxyhq/saml-jackson update | picomatch (Dependabot #342/#343) - awaiting Vite/Vitest/lint-staged patch adoption | qs (Dependabot #277) - awaiting googleapis/googleapis-common update | rollup (Dependabot #291) - awaiting Vite patch adoption | socket.io-parser (Dependabot #334) - awaiting react-email/socket.io update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | undici (Dependabot #319/#322/#323) - awaiting jsdom/vitest/isomorphic-dompurify updates | yaml (Dependabot #344) - awaiting Vite/lint-staged patch adoption | fast-xml-parser (CVE-2026-25896/26278/33036/33349) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono (Dependabot #313/#316/#317) - awaiting Prisma update | @tootallnate/once (Dependabot #305) - awaiting sqlite3/node-gyp chain update | schema-utils@3>ajv (Dependabot #287) - awaiting eslint/file-loader schema-utils update | axios (CVE-2025-58754, CVE-2026-25639) - awaiting @boxyhq/saml-jackson update | effect (Dependabot #339) - awaiting Prisma update | flatted (Dependabot #324/#338) - awaiting eslint/flat-cache update | minimatch (Dependabot #288/#294/#297) - awaiting react-email/glob update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | qs (Dependabot #277) - awaiting googleapis/googleapis-common update | rollup (Dependabot #291) - awaiting Vite patch adoption | socket.io-parser (Dependabot #334) - awaiting react-email/socket.io update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | undici (Dependabot #319/#322/#323) - awaiting jsdom/vitest/isomorphic-dompurify updates | fast-xml-parser (CVE-2026-25896/26278/33036/33349) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
},
"patchedDependencies": {
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
@@ -1,30 +0,0 @@
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-27
View File
@@ -853,32 +853,6 @@ model Account {
@@index([userId])
}
/// Stores active authentication sessions for revocable server-side login state.
///
/// @property sessionToken - Opaque token stored in the browser cookie
/// @property user - The Formbricks user who owns this session
/// @property expires - Hard expiry for the session
model Session {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
sessionToken String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
expires DateTime
@@index([userId])
}
/// Stores one-time verification tokens used by Auth.js adapter flows.
model VerificationToken {
identifier String
token String
expires DateTime
@@unique([identifier, token])
}
/// Represents a user in the Formbricks system.
/// Central model for user authentication and profile management.
///
@@ -904,7 +878,6 @@ model User {
identityProviderAccountId String?
memberships Membership[]
accounts Account[]
sessions Session[]
groupId String?
invitesCreated Invite[] @relation("inviteCreatedBy")
invitesAccepted Invite[] @relation("inviteAcceptedBy")
@@ -73,7 +73,7 @@ function Consent({
/>
{/* Consent Checkbox */}
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<label
@@ -83,7 +83,7 @@ function CTA({
/>
{/* CTA Button */}
<div className="relative space-y-2" data-element-input>
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={dir} />
{buttonExternal ? (
@@ -95,7 +95,7 @@ function CTA({
disabled={disabled}
className="text-button font-button-weight flex items-center gap-2"
variant={buttonVariant}
size="custom">
size={"custom"}>
{buttonLabel}
<SquareArrowOutUpRightIcon className="size-4" />
</Button>
@@ -161,7 +161,7 @@ function DateElement({
videoUrl={videoUrl}
/>
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
{/* Calendar - Always visible */}
<div className="w-full">
@@ -292,7 +292,7 @@ function FileUpload({
imageAltText={imageAltText}
/>
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<div
@@ -112,7 +112,7 @@ function FormField({
/>
{/* Form Fields */}
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<div className="space-y-3">
{visibleFields.map((field) => {
@@ -94,7 +94,7 @@ function Matrix({
/>
{/* Matrix Table */}
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
{/* Table container with overflow for mobile */}
@@ -145,7 +145,7 @@ function DropdownVariant({
searchPlaceholder,
searchNoResultsText,
}: Readonly<DropdownVariantProps>): React.JSX.Element {
const handleOptionToggle = (optionId: string): void => {
const handleOptionToggle = (optionId: string) => {
if (selectedValues.includes(optionId)) {
handleOptionRemove(optionId);
} else {
@@ -540,7 +540,7 @@ function MultiSelect({
/>
{/* Options */}
<div className="relative" data-element-input>
<div className="relative">
{variant === "dropdown" ? (
<DropdownVariant
inputId={inputId}
@@ -172,7 +172,7 @@ function NPS({
/>
{/* NPS Options */}
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full px-[2px]" dir={dir}>
<legend className="sr-only">NPS rating options</legend>
@@ -79,7 +79,7 @@ function OpenText({
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} />
{/* Input or Textarea */}
<div className="space-y-1">
@@ -106,7 +106,7 @@ function PictureSelect({
/>
{/* Picture Grid - 2 columns */}
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
{allowMulti ? (
<div className="grid grid-cols-2 gap-2">
@@ -223,7 +223,7 @@ function Ranking({
/>
{/* Ranking Options */}
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full" dir={dir}>
<legend className="sr-only">Ranking options</legend>
@@ -407,7 +407,7 @@ function Rating({
/>
{/* Rating Options */}
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full" dir={dir}>
<legend className="sr-only">Rating options</legend>
@@ -181,7 +181,7 @@ function SingleSelect({
/>
{/* Options */}
<div data-element-input>
<div>
{variant === "dropdown" ? (
<>
<ElementError errorMessage={errorMessage} dir={dir} />
@@ -278,7 +278,7 @@ function SingleSelect({
) : null}
</>
) : (
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<RadioGroup
name={inputId}
-85
View File
@@ -1,85 +0,0 @@
{
"common": {
"and": "ja",
"apply": "rakenda",
"auto_close_wrapper": "Automaatse sulgemise ümbris",
"back": "Tagasi",
"close_survey": "Sulge küsitlus",
"company_logo": "Ettevõtte logo",
"finish": "Lõpeta",
"language_switch": "Keele vahetamine",
"next": "Edasi",
"no_results_found": "Tulemusi ei leitud",
"open_in_new_tab": "Ava uuel vahelehel",
"people_responded": "{count, plural, one {1 inimene vastas} other {{count} inimest vastas}}",
"please_retry_now_or_try_again_later": "Palun proovi uuesti kohe või hiljem.",
"powered_by": "Teenust pakub",
"privacy_policy": "Privaatsuspoliitika",
"protected_by_reCAPTCHA_and_the_Google": "Kaitstud reCAPTCHA ja Google'i poolt",
"question": "Küsimus",
"question_video": "Küsimuse video",
"required": "Kohustuslik",
"respondents_will_not_see_this_card": "Vastajad ei näe seda kaarti",
"retry": "Proovi uuesti",
"retrying": "Proovin uuesti…",
"search": "Otsi...",
"select_option": "Vali variant",
"select_options": "Vali variandid",
"sending_responses": "Vastuste saatmine…",
"takes_less_than_x_minutes": "{count, plural, one {Võtab vähem kui 1 minuti} other {Võtab vähem kui {count} minutit}}",
"takes_x_minutes": "{count, plural, one {Võtab 1 minuti} other {Võtab {count} minutit}}",
"takes_x_plus_minutes": "Võtab {count}+ minutit",
"terms_of_service": "Teenusetingimused",
"the_servers_cannot_be_reached_at_the_moment": "Serveritega ei saa hetkel ühendust.",
"they_will_be_redirected_immediately": "Nad suunatakse kohe ümber",
"your_feedback_is_stuck": "Sinu tagasiside on kinni jäänud :("
},
"errors": {
"all_options_must_be_ranked": "Palun järjesta kõik variandid",
"all_rows_must_be_answered": "Palun vasta kõikidele ridadele",
"file_extension_must_be": "Faililaiend peab olema {extension}",
"file_extension_must_not_be": "Faililaiend ei tohi olla {extension}",
"file_input": {
"duplicate_files": "Järgmised failid on juba üles laaditud: {duplicateNames}. Duplikaatfailid ei ole lubatud.",
"file_size_exceeded": "Järgmised failid ületavad maksimaalse suuruse {maxSizeInMB} MB ja eemaldati: {fileNames}",
"file_size_exceeded_alert": "Fail peab olema väiksem kui {maxSizeInMB} MB",
"no_valid_file_types_selected": "Ühtegi kehtivat failitüüpi pole valitud. Palun vali kehtiv failitüüp.",
"only_one_file_can_be_uploaded_at_a_time": "Korraga saab üles laadida ainult ühe faili.",
"placeholder_text": "Klõpsa või lohista failide üleslaadimiseks",
"upload_failed": "Üleslaadimine ebaõnnestus! Palun proovi uuesti.",
"uploading": "Üleslaadimine...",
"you_can_only_upload_a_maximum_of_files": "Saad üles laadida maksimaalselt {FILE_LIMIT} faili."
},
"invalid_device_error": {
"message": "Palun keela küsitluse seadetes rämpsposti kaitse, et jätkata selle seadmega.",
"title": "See seade ei toeta rämpsposti kaitset."
},
"invalid_format": "Palun sisesta kehtiv vorming",
"is_between": "Palun vali kuupäev vahemikus {startDate} kuni {endDate}",
"is_earlier_than": "Palun vali kuupäev enne {date}",
"is_greater_than": "Palun sisesta väärtus, mis on suurem kui {min}",
"is_later_than": "Palun vali kuupäev pärast {date}",
"is_less_than": "Palun sisesta väärtus, mis on väiksem kui {max}",
"is_not_between": "Palun vali kuupäev, mis ei jää vahemikku {startDate} kuni {endDate}",
"max_length": "Palun sisesta mitte rohkem kui {max} tähemärki",
"max_selections": "Palun vali mitte rohkem kui {max} varianti",
"max_value": "Palun sisesta väärtus, mis ei ole suurem kui {max}",
"min_length": "Palun sisesta vähemalt {min} tähemärki",
"min_selections": "Palun vali vähemalt {min} varianti",
"min_value": "Palun sisesta väärtus vähemalt {min}",
"minimum_options_ranked": "Palun järjesta vähemalt {min} varianti",
"minimum_rows_answered": "Palun vasta vähemalt {min} reale",
"please_enter_a_valid_email_address": "Palun sisesta kehtiv e-posti aadress",
"please_enter_a_valid_phone_number": "Palun sisesta kehtiv telefoninumber",
"please_enter_a_valid_url": "Palun sisesta kehtiv URL",
"please_fill_out_this_field": "Palun täida see väli",
"recaptcha_error": {
"message": "Sinu vastust ei saanud esitada, kuna see märgiti automatiseeritud tegevuseks. Kui sa hingad, palun proovi uuesti.",
"title": "Me ei suutnud kinnitada, et sa oled inimene."
},
"value_must_contain": "Väärtus peab sisaldama {value}",
"value_must_equal": "Väärtus peab võrduma {value}",
"value_must_not_contain": "Väärtus ei tohi sisaldada {value}",
"value_must_not_equal": "Väärtus ei tohi võrduda {value}"
}
}
@@ -278,12 +278,11 @@ export function BlockConditional({
if (hasValidationErrors) {
setElementErrors(errorMap);
// Find the first element with an error and scroll to its input area (not the headline)
// Find the first element with an error and scroll to it
const firstErrorElementId = Object.keys(errorMap)[0];
const form = elementFormRefs.current.get(firstErrorElementId);
if (form) {
const scrollTarget = form.querySelector("[data-element-input]") ?? form;
scrollTarget.scrollIntoView({ behavior: "smooth", block: "center" });
form.scrollIntoView({ behavior: "smooth", block: "center" });
}
return;
}
@@ -291,8 +290,7 @@ export function BlockConditional({
// Also run legacy validation for elements not yet migrated to centralized validation
const firstInvalidForm = findFirstInvalidForm();
if (firstInvalidForm) {
const scrollTarget = firstInvalidForm.querySelector("[data-element-input]") ?? firstInvalidForm;
scrollTarget.scrollIntoView({ behavior: "smooth", block: "center" });
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
-3
View File
@@ -6,7 +6,6 @@ import daTranslations from "../../locales/da.json";
import deTranslations from "../../locales/de.json";
import enTranslations from "../../locales/en.json";
import esTranslations from "../../locales/es.json";
import etTranslations from "../../locales/et.json";
import frTranslations from "../../locales/fr.json";
import hiTranslations from "../../locales/hi.json";
import huTranslations from "../../locales/hu.json";
@@ -31,7 +30,6 @@ i18n
"de",
"en",
"es",
"et",
"fr",
"hi",
"hu",
@@ -52,7 +50,6 @@ i18n
de: { translation: deTranslations },
en: { translation: enTranslations },
es: { translation: esTranslations },
et: { translation: etTranslations },
fr: { translation: frTranslations },
hi: { translation: hiTranslations },
hu: { translation: huTranslations },
+3 -5
View File
@@ -1,13 +1,11 @@
import NextAuth, { type DefaultSession } from "next-auth";
import NextAuth from "next-auth";
import { type TUser } from "./user";
declare module "next-auth" {
/**
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
*/
interface Session {
user: DefaultSession["user"] & {
id: string;
isActive?: boolean;
};
user: { id: string };
}
}
+26 -3
View File
@@ -1,7 +1,7 @@
import { type z } from "zod";
import type { TI18nString } from "../i18n";
import type { TSurveyLanguage } from "./types";
import { findLanguageCodesForDuplicateLabels, getTextContent } from "./validation";
import { getTextContent } from "./validation";
const extractLanguageCodes = (surveyLanguages?: TSurveyLanguage[]): string[] => {
if (!surveyLanguages) return [];
@@ -92,5 +92,28 @@ export const validateElementLabels = (
return null;
};
// Re-export for backwards compatibility
export { findLanguageCodesForDuplicateLabels };
export const findLanguageCodesForDuplicateLabels = (
labels: TI18nString[],
surveyLanguages: TSurveyLanguage[]
): string[] => {
const enabledLanguages = surveyLanguages.filter((lang) => lang.enabled);
const languageCodes = extractLanguageCodes(enabledLanguages);
const languagesToCheck = languageCodes.length === 0 ? ["default"] : languageCodes;
const duplicateLabels = new Set<string>();
for (const language of languagesToCheck) {
const labelTexts = labels
.map((label) => label[language])
.filter((text): text is string => typeof text === "string" && text.trim().length > 0)
.map((text) => text.trim());
const uniqueLabels = new Set(labelTexts);
if (uniqueLabels.size !== labelTexts.length) {
duplicateLabels.add(language);
}
}
return Array.from(duplicateLabels);
};
+1 -4
View File
@@ -228,10 +228,7 @@ export const findLanguageCodesForDuplicateLabels = (
const duplicateLabels = new Set<string>();
for (const language of languagesToCheck) {
const labelTexts = labels
.map((label) => label[language])
.filter((text): text is string => typeof text === "string" && text.trim().length > 0)
.map((text) => text.trim());
const labelTexts = labels.map((label) => label[language].trim()).filter(Boolean);
const uniqueLabels = new Set(labelTexts);
if (uniqueLabels.size !== labelTexts.length) {
+2 -220
View File
@@ -1,30 +1,8 @@
diff --git a/core/lib/assert.js b/core/lib/assert.js
--- a/core/lib/assert.js
+++ b/core/lib/assert.js
@@ -52,12 +52,6 @@
if (provider.type === "credentials") hasCredentials = true;else if (provider.type === "email") hasEmail = true;else if (provider.id === "twitter" && provider.version === "2.0") hasTwitterOAuth2 = true;
}
if (hasCredentials) {
- var _options$session;
- const dbStrategy = ((_options$session = options.session) === null || _options$session === void 0 ? void 0 : _options$session.strategy) === "database";
- const onlyCredentials = !options.providers.some(p => p.type !== "credentials");
- if (dbStrategy && onlyCredentials) {
- return new _errors.UnsupportedStrategy("Signin in with credentials only supported if JWT strategy is enabled");
- }
const credentialsNoAuthorize = options.providers.some(p => p.type === "credentials" && !p.authorize);
if (credentialsNoAuthorize) {
return new _errors.MissingAuthorize("Must define an authorize() handler to use credentials authentication provider");
@@ -80,4 +74,4 @@
warned = true;
}
return warnings;
-}
\ No newline at end of file
+}
diff --git a/core/lib/oauth/client.js b/core/lib/oauth/client.js
index 52c51eb6ff422dc0899ccec31baf3fa39e42eeae..472772cfefc2c2947536d6a22b022c2f9c27c61f 100644
--- a/core/lib/oauth/client.js
+++ b/core/lib/oauth/client.js
@@ -5,9 +5,73 @@
@@ -5,9 +5,73 @@ Object.defineProperty(exports, "__esModule", {
});
exports.openidClient = openidClient;
var _openidClient = require("openid-client");
@@ -99,199 +77,3 @@ diff --git a/core/lib/oauth/client.js b/core/lib/oauth/client.js
let issuer;
if (provider.wellKnown) {
issuer = await _openidClient.Issuer.discover(provider.wellKnown);
diff --git a/core/routes/callback.js b/core/routes/callback.js
--- a/core/routes/callback.js
+++ b/core/routes/callback.js
@@ -377,29 +377,48 @@
cookies
};
}
- const defaultToken = {
- name: user.name,
- email: user.email,
- picture: user.image,
- sub: (_user$id3 = user.id) === null || _user$id3 === void 0 ? void 0 : _user$id3.toString()
- };
- const token = await callbacks.jwt({
- token: defaultToken,
- user,
- account,
- isNewUser: false,
- trigger: "signIn"
- });
- const newToken = await jwt.encode({
- ...jwt,
- token
- });
- const cookieExpires = new Date();
- cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000);
- const sessionCookies = sessionStore.chunk(newToken, {
- expires: cookieExpires
- });
- cookies.push(...sessionCookies);
+ if (useJwtSession) {
+ const defaultToken = {
+ name: user.name,
+ email: user.email,
+ picture: user.image,
+ sub: (_user$id3 = user.id) === null || _user$id3 === void 0 ? void 0 : _user$id3.toString()
+ };
+ const token = await callbacks.jwt({
+ token: defaultToken,
+ user,
+ account,
+ isNewUser: false,
+ trigger: "signIn"
+ });
+ const newToken = await jwt.encode({
+ ...jwt,
+ token
+ });
+ const cookieExpires = new Date();
+ cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000);
+ const sessionCookies = sessionStore.chunk(newToken, {
+ expires: cookieExpires
+ });
+ cookies.push(...sessionCookies);
+ } else {
+ if (!adapter) {
+ throw new Error("Missing adapter");
+ }
+ const session = await adapter.createSession({
+ sessionToken: await options.session.generateSessionToken(),
+ userId: user.id,
+ expires: (0, _utils.fromDate)(options.session.maxAge)
+ });
+ cookies.push({
+ name: options.cookies.sessionToken.name,
+ value: session.sessionToken,
+ options: {
+ ...options.cookies.sessionToken.options,
+ expires: session.expires
+ }
+ });
+ }
await ((_events$signIn3 = events.signIn) === null || _events$signIn3 === void 0 ? void 0 : _events$signIn3.call(events, {
user,
account
@@ -414,4 +433,4 @@
body: `Error: Callback for provider type ${provider.type} not supported`,
cookies
};
-}
\ No newline at end of file
+}
diff --git a/src/core/lib/assert.ts b/src/core/lib/assert.ts
--- a/src/core/lib/assert.ts
+++ b/src/core/lib/assert.ts
@@ -101,16 +101,6 @@
}
if (hasCredentials) {
- const dbStrategy = options.session?.strategy === "database"
- const onlyCredentials = !options.providers.some(
- (p) => p.type !== "credentials"
- )
- if (dbStrategy && onlyCredentials) {
- return new UnsupportedStrategy(
- "Signin in with credentials only supported if JWT strategy is enabled"
- )
- }
-
const credentialsNoAuthorize = options.providers.some(
(p) => p.type === "credentials" && !p.authorize
)
diff --git a/src/core/routes/callback.ts b/src/core/routes/callback.ts
--- a/src/core/routes/callback.ts
+++ b/src/core/routes/callback.ts
@@ -1,6 +1,6 @@
import oAuthCallback from "../lib/oauth/callback"
import callbackHandler from "../lib/callback-handler"
-import { hashToken } from "../lib/utils"
+import { fromDate, hashToken } from "../lib/utils"
import getAdapterUserFromEmail from "../lib/email/getUserFromEmail"
import type { InternalOptions } from "../types"
@@ -385,37 +385,58 @@
)}`,
cookies,
}
- }
-
- const defaultToken = {
- name: user.name,
- email: user.email,
- picture: user.image,
- sub: user.id?.toString(),
}
- const token = await callbacks.jwt({
- token: defaultToken,
- user,
- // @ts-expect-error
- account,
- isNewUser: false,
- trigger: "signIn",
- })
+ if (useJwtSession) {
+ const defaultToken = {
+ name: user.name,
+ email: user.email,
+ picture: user.image,
+ sub: user.id?.toString(),
+ }
- // Encode token
- const newToken = await jwt.encode({ ...jwt, token })
+ const token = await callbacks.jwt({
+ token: defaultToken,
+ user,
+ // @ts-expect-error
+ account,
+ isNewUser: false,
+ trigger: "signIn",
+ })
- // Set cookie expiry date
- const cookieExpires = new Date()
- cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
+ // Encode token
+ const newToken = await jwt.encode({ ...jwt, token })
- const sessionCookies = sessionStore.chunk(newToken, {
- expires: cookieExpires,
- })
+ // Set cookie expiry date
+ const cookieExpires = new Date()
+ cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
- cookies.push(...sessionCookies)
+ const sessionCookies = sessionStore.chunk(newToken, {
+ expires: cookieExpires,
+ })
+ cookies.push(...sessionCookies)
+ } else {
+ if (!adapter) {
+ throw new Error("Missing adapter")
+ }
+
+ const session = await adapter.createSession({
+ sessionToken: await options.session.generateSessionToken(),
+ userId: user.id,
+ expires: fromDate(options.session.maxAge),
+ })
+
+ cookies.push({
+ name: options.cookies.sessionToken.name,
+ value: (session as AdapterSession).sessionToken,
+ options: {
+ ...options.cookies.sessionToken.options,
+ expires: (session as AdapterSession).expires,
+ },
+ })
+ }
+
// @ts-expect-error
await events.signIn?.({ user, account })
+9447 -15858
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -141,12 +141,12 @@
"BREVO_API_KEY",
"BREVO_LIST_ID",
"CRON_SECRET",
"DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS",
"DATABASE_URL",
"DEBUG",
"E2E_TESTING",
"EMAIL_AUTH_DISABLED",
"EMAIL_VERIFICATION_DISABLED",
"EDGE_RATE_LIMIT_PROVIDER",
"ENCRYPTION_KEY",
"ENTERPRISE_LICENSE_KEY",
"ENVIRONMENT",
-15
View File
@@ -1,15 +0,0 @@
The ISC License
Copyright (c) Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
File diff suppressed because it is too large Load Diff
-28
View File
@@ -1,28 +0,0 @@
{
"author": "Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me)",
"name": "minimatch",
"description": "a glob matcher in javascript",
"version": "3.1.5",
"repository": {
"type": "git",
"url": "git://github.com/isaacs/minimatch.git"
},
"main": "minimatch.js",
"scripts": {
"test": "tap",
"preversion": "npm test"
},
"engines": {
"node": "*"
},
"dependencies": {
"brace-expansion": "5.0.5"
},
"devDependencies": {
"tap": "^15.1.6"
},
"license": "ISC",
"files": [
"minimatch.js"
]
}