Compare commits

..

8 Commits

Author SHA1 Message Date
Dhruwang Jariwala bad3b7a771 fix: (backport) prevent SSRF via redirect following in webhook delivery (#7877) (#7892) 2026-04-27 15:32:12 +05:30
Dhruwang Jariwala 007d99f6b8 fix: prevent Airtable integration crash when token expires (backport #7811) (#7873) 2026-04-27 15:32:03 +05:30
Dhruwang Jariwala 03b7dfefe4 fix: fixes sentry ref issue (backport #7776) (#7872) 2026-04-27 15:31:52 +05:30
Anshuman Pandey 9178558ba1 fix: prevent SSRF via redirect following in webhook delivery (#7877) 2026-04-27 15:08:17 +05:30
Dhruwang Jariwala a65e6d9093 fix: prevent Airtable integration crash when token expires (#7811)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 11:02:04 +05:30
Anshuman Pandey 592d36542f fix: fixes sentry ref issue (#7776) 2026-04-27 11:01:17 +05:30
Tiago 5ec8218666 fix: (backport) password hash visibility improvement (#7814) (#7833) 2026-04-24 14:33:26 +00:00
Tiago Farto e1a44817f2 fix: password hash visibility improvement
(cherry picked from commit 73ad130ece)
2026-04-24 13:10:40 +00:00
38 changed files with 495 additions and 71 deletions
@@ -18,6 +18,7 @@ interface AirtableWrapperProps {
isEnabled: boolean;
webAppUrl: string;
locale: TUserLocale;
showReconnectButton?: boolean;
}
export const AirtableWrapper = ({
@@ -28,6 +29,7 @@ export const AirtableWrapper = ({
isEnabled,
webAppUrl,
locale,
showReconnectButton = false,
}: AirtableWrapperProps) => {
const [isConnected, setIsConnected] = useState(
airtableIntegration ? airtableIntegration.config?.key : false
@@ -49,6 +51,8 @@ export const AirtableWrapper = ({
setIsConnected={setIsConnected}
surveys={surveys}
locale={locale}
showReconnectButton={showReconnectButton}
handleAirtableAuthorization={handleAirtableAuthorization}
/>
) : (
<ConnectIntegration
@@ -1,6 +1,6 @@
"use client";
import { Trash2Icon } from "lucide-react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,9 +12,11 @@ import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
@@ -24,10 +26,20 @@ interface ManageIntegrationProps {
surveys: TSurvey[];
airtableArray: TIntegrationItem[];
locale: TUserLocale;
showReconnectButton: boolean;
handleAirtableAuthorization: () => Promise<void>;
}
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
export const ManageIntegration = ({
airtableIntegration,
environmentId,
setIsConnected,
surveys,
airtableArray,
showReconnectButton,
handleAirtableAuthorization,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslation();
const tableHeaders = [
@@ -73,15 +85,34 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
: { isEditMode: false as const };
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end gap-x-6">
<div className="flex items-center">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>{t("environments.integrations.reconnect_button_description")}</AlertDescription>
<AlertButton onClick={handleAirtableAuthorization}>
{t("environments.integrations.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="cursor-pointer text-slate-500">
<span className="text-slate-500">
{t("environments.integrations.connected_with_email", {
email: airtableIntegration.config.email,
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleAirtableAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("environments.integrations.reconnect_button_tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setDefaultValues(null);
@@ -122,9 +153,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.tableName}</div>
<div className="col-span-2 text-center">{data.elements}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)}
</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
</button>
))}
</div>
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { logger } from "@formbricks/logger";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
@@ -31,8 +32,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
);
let airtableArray: TIntegrationItem[] = [];
let isTokenValid = true;
if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId);
try {
airtableArray = await getAirtableTables(params.environmentId);
} catch (error) {
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
isTokenValid = false;
}
}
if (isReadOnly) {
return redirect("./");
@@ -51,6 +58,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
showReconnectButton={!isTokenValid}
/>
</div>
</PageContentWrapper>
@@ -8,7 +8,7 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
import { CRON_SECRET, DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -91,10 +91,15 @@ export const POST = async (request: Request) => {
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
// Prepare webhook and email promises
// Fetch with timeout of 5 seconds to prevent hanging
// Fetch with timeout of 5 seconds to prevent hanging.
// `redirect: "manual"` blocks SSRF via redirect — webhook URLs are validated against private/internal
// ranges before delivery, but redirect targets would otherwise bypass that check. Gated on the same
// env var as `validateWebhookUrl`: self-hosters who opted into trusting internal URLs also get the
// pre-patch redirect-follow behavior for consistency.
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
return Promise.race([
fetch(url, options),
fetch(url, { ...options, redirect: redirectMode }),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
]);
};
@@ -5,7 +5,7 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -78,12 +78,16 @@ export const GET = withV1ApiWrapper({
}
const email = await getEmail(key.access_token);
// Preserve existing integration data (survey-to-table mappings) when re-authorizing
const existingIntegration = await getIntegrationByType(environmentId, "airtable");
const existingData = existingIntegration?.config?.data ?? [];
const airtableIntegrationInput = {
type: "airtable" as "airtable",
environment: environmentId,
config: {
key,
data: [],
data: existingData,
email,
},
};
@@ -1,8 +1,7 @@
import * as z from "zod";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getTables } from "@/lib/airtable/service";
import { getAirtableToken, getTables } from "@/lib/airtable/service";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getIntegrationByType } from "@/lib/integration/service";
@@ -36,7 +35,7 @@ export const GET = withV1ApiWrapper({
};
}
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
const integration = await getIntegrationByType(environmentId, "airtable");
if (!integration) {
return {
@@ -44,7 +43,12 @@ export const GET = withV1ApiWrapper({
};
}
const tables = await getTables(integration.config.key, baseId.data);
// Use getAirtableToken to ensure the access token is refreshed if expired
const freshAccessToken = await getAirtableToken(environmentId);
const tables = await getTables(
{ ...integration.config.key, access_token: freshAccessToken },
baseId.data
);
return {
response: responses.successResponse(tables),
};
@@ -0,0 +1,178 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { publicUserSelect } from "@/lib/user/public-user";
import { GET } from "./route";
const mocks = vi.hoisted(() => ({
headers: vi.fn(),
getSessionUser: vi.fn(),
parseApiKeyV2: vi.fn(),
hashSha256: vi.fn(),
verifySecret: vi.fn(),
applyRateLimit: vi.fn(),
notAuthenticatedResponse: vi.fn(
() => new Response(JSON.stringify({ message: "Not authenticated" }), { status: 401 })
),
tooManyRequestsResponse: vi.fn(
(message: string) => new Response(JSON.stringify({ message }), { status: 429 })
),
badRequestResponse: vi.fn((message: string) => new Response(JSON.stringify({ message }), { status: 400 })),
}));
vi.mock("next/headers", () => ({
headers: mocks.headers,
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
apiKey: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/app/api/v1/management/me/lib/utils", () => ({
getSessionUser: mocks.getSessionUser,
}));
vi.mock("@/app/lib/api/response", () => ({
responses: {
notAuthenticatedResponse: mocks.notAuthenticatedResponse,
tooManyRequestsResponse: mocks.tooManyRequestsResponse,
badRequestResponse: mocks.badRequestResponse,
},
}));
vi.mock("@/lib/crypto", () => ({
hashSha256: mocks.hashSha256,
parseApiKeyV2: mocks.parseApiKeyV2,
verifySecret: mocks.verifySecret,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: mocks.applyRateLimit,
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
v1: { windowMs: 60_000, max: 1000 },
},
},
}));
const getMockHeaders = (apiKey: string | null) => ({
get: (headerName: string) => (headerName === "x-api-key" ? apiKey : null),
});
describe("v1 management me route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.headers.mockResolvedValue(getMockHeaders(null));
mocks.getSessionUser.mockResolvedValue(undefined);
mocks.parseApiKeyV2.mockReturnValue(null);
mocks.hashSha256.mockReturnValue("hashed-api-key");
mocks.verifySecret.mockResolvedValue(false);
mocks.applyRateLimit.mockResolvedValue(undefined);
});
test("returns a sanitized authenticated user for session-based requests", async () => {
const publicUser = {
id: "user_123",
name: "Test User",
email: "test@example.com",
emailVerified: new Date("2025-04-17T20:11:54.947Z"),
createdAt: new Date("2025-04-17T20:09:14.021Z"),
updatedAt: new Date("2026-04-22T22:12:39.104Z"),
twoFactorEnabled: false,
identityProvider: "email" as const,
notificationSettings: {
alert: {},
unsubscribedOrganizationIds: [],
},
locale: "en-US" as const,
lastLoginAt: new Date("2026-04-22T22:12:39.104Z"),
isActive: true,
};
mocks.getSessionUser.mockResolvedValue({ id: publicUser.id });
vi.mocked(prisma.user.findUnique).mockResolvedValue(publicUser as never);
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(200);
expect(responseBody).toStrictEqual(JSON.parse(JSON.stringify(publicUser)));
expect(responseBody).not.toHaveProperty("password");
expect(responseBody).not.toHaveProperty("twoFactorSecret");
expect(responseBody).not.toHaveProperty("backupCodes");
expect(responseBody).not.toHaveProperty("identityProviderAccountId");
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: publicUser.id },
select: publicUserSelect,
});
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), publicUser.id);
});
test("returns the existing unauthenticated response when no session is present", async () => {
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(401);
expect(responseBody).toEqual({ message: "Not authenticated" });
expect(mocks.notAuthenticatedResponse).toHaveBeenCalled();
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
test("preserves the API key response path", async () => {
const apiKeyData = {
id: "api_key_123",
organizationId: "org_123",
hashedKey: "stored-hash",
lastUsedAt: new Date(),
apiKeyEnvironments: [
{
permission: "manage",
environment: {
id: "env_123",
type: "development",
createdAt: new Date("2025-01-01T00:00:00.000Z"),
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
projectId: "project_123",
appSetupCompleted: true,
project: {
id: "project_123",
name: "My Project",
},
},
},
],
};
mocks.headers.mockResolvedValue(getMockHeaders("api-key"));
vi.mocked(prisma.apiKey.findFirst).mockResolvedValue(apiKeyData as never);
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(200);
expect(responseBody).toStrictEqual({
id: "env_123",
type: "development",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
appSetupCompleted: true,
project: {
id: "project_123",
name: "My Project",
},
});
expect(mocks.getSessionUser).not.toHaveBeenCalled();
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), apiKeyData.id);
});
});
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { responses } from "@/app/lib/api/response";
import { CONTROL_HASH } from "@/lib/constants";
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
import { publicUserSelect } from "@/lib/user/public-user";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
@@ -176,6 +177,7 @@ const handleSessionAuthentication = async () => {
const user = await prisma.user.findUnique({
where: { id: sessionUser.id },
select: publicUserSelect,
});
return Response.json(user);
+3
View File
@@ -784,6 +784,9 @@ checksums:
environments/integrations/notion/update_connection_tooltip: 2429919f575e47f5c76e54b4442ba706
environments/integrations/notion_integration_description: 31a73dbe88fe18a078d6dc15f0c303e2
environments/integrations/please_select_a_survey_error: 465aa7048773079c8ffdde8b333b78eb
environments/integrations/reconnect_button: 8992a0f250278c116cb26be448b68ba2
environments/integrations/reconnect_button_description: 01f79dc561ff87b5f2a80bf66e492844
environments/integrations/reconnect_button_tooltip: 5552effda9df8d6778dda1cf42e5d880
environments/integrations/select_at_least_one_question_error: a3513cb02ab0de2a1531893ac0c7e089
environments/integrations/slack/already_connected_another_survey: 4508f9e4a2915e3818ea5f9e2695e000
environments/integrations/slack/channel_name: 1afcd1d0401850ff353f5ae27502b04a
+11 -5
View File
@@ -3,7 +3,6 @@ import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationAirtable,
TIntegrationAirtableConfigData,
TIntegrationAirtableCredential,
ZIntegrationAirtableBases,
@@ -24,6 +23,11 @@ export const getBases = async (key: string) => {
},
});
if (!req.ok) {
const body = await req.text().catch(() => "");
throw new Error(`Airtable API error fetching bases: ${req.status} ${req.statusText} ${body}`);
}
const res = await req.json();
return ZIntegrationAirtableBases.parse(res);
};
@@ -35,6 +39,11 @@ const tableFetcher = async (key: TIntegrationAirtableCredential, baseId: string)
},
});
if (!req.ok) {
const body = await req.text().catch(() => "");
throw new Error(`Airtable API error fetching tables: ${req.status} ${req.statusText} ${body}`);
}
const res = await req.json();
return res;
@@ -78,10 +87,7 @@ export const fetchAirtableAuthToken = async (formData: Record<string, any>) => {
export const getAirtableToken = async (environmentId: string) => {
try {
const airtableIntegration = (await getIntegrationByType(
environmentId,
"airtable"
)) as TIntegrationAirtable;
const airtableIntegration = await getIntegrationByType(environmentId, "airtable");
const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse(
airtableIntegration?.config.key
+11 -3
View File
@@ -5,7 +5,12 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
import {
TIntegration,
TIntegrationByType,
TIntegrationInput,
ZIntegrationType,
} from "@formbricks/types/integration";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -94,7 +99,10 @@ export const getIntegration = reactCache(async (integrationId: string): Promise<
});
export const getIntegrationByType = reactCache(
async (environmentId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
async <T extends TIntegrationInput["type"]>(
environmentId: string,
type: T
): Promise<TIntegrationByType<T> | null> => {
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
try {
@@ -106,7 +114,7 @@ export const getIntegrationByType = reactCache(
},
},
});
return integration ? transformIntegration(integration) : null;
return integration ? (transformIntegration(integration) as TIntegrationByType<T>) : null;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+2 -6
View File
@@ -1,8 +1,4 @@
import {
TIntegrationNotion,
TIntegrationNotionConfig,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TIntegrationNotionConfig, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIntegrationByType } from "../integration/service";
@@ -29,7 +25,7 @@ const fetchPages = async (config: TIntegrationNotionConfig) => {
export const getNotionDatabases = async (environmentId: string): Promise<TIntegrationNotionDatabase[]> => {
let results: TIntegrationNotionDatabase[] = [];
try {
const notionIntegration = (await getIntegrationByType(environmentId, "notion")) as TIntegrationNotion;
const notionIntegration = await getIntegrationByType(environmentId, "notion");
if (notionIntegration && notionIntegration.config?.key.bot_id) {
results = await fetchPages(notionIntegration.config);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { SLACK_MESSAGE_LIMIT } from "../constants";
import { deleteIntegration, getIntegrationByType } from "../integration/service";
import { truncateText } from "../utils/strings";
@@ -58,7 +58,7 @@ export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIn
export const getSlackChannels = async (environmentId: string): Promise<TIntegrationItem[]> => {
let channels: TIntegrationItem[] = [];
try {
const slackIntegration = (await getIntegrationByType(environmentId, "slack")) as TIntegrationSlack;
const slackIntegration = await getIntegrationByType(environmentId, "slack");
if (slackIntegration && slackIntegration.config?.key) {
channels = await fetchChannels(slackIntegration);
}
+20
View File
@@ -0,0 +1,20 @@
import { Prisma } from "@prisma/client";
export const publicUserSelect = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
} as const satisfies Prisma.UserSelect;
export type TPublicUser = Prisma.UserGetPayload<{
select: typeof publicUserSelect;
}>;
+10 -10
View File
@@ -6,6 +6,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { publicUserSelect } from "./public-user";
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
vi.mock("@formbricks/database", () => ({
@@ -47,11 +48,6 @@ describe("User Service", () => {
locale: "en-US" as TUserLocale,
lastLoginAt: new Date(),
isActive: true,
twoFactorSecret: null,
backupCodes: null,
password: null,
identityProviderAccountId: null,
groupId: null,
};
const mockOrganizations: TOrganization[] = [
@@ -102,8 +98,12 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: "user1" },
select: expect.any(Object),
select: publicUserSelect,
});
expect(result).not.toHaveProperty("password");
expect(result).not.toHaveProperty("twoFactorSecret");
expect(result).not.toHaveProperty("backupCodes");
expect(result).not.toHaveProperty("identityProviderAccountId");
});
test("should return null when user not found", async () => {
@@ -134,7 +134,7 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findFirst).toHaveBeenCalledWith({
where: { email: "test@example.com" },
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -176,7 +176,7 @@ describe("User Service", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user1" },
data: updateData,
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -204,7 +204,7 @@ describe("User Service", () => {
expect(deleteOrganization).toHaveBeenCalledWith("org1");
expect(prisma.user.delete).toHaveBeenCalledWith({
where: { id: "user1" },
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -236,7 +236,7 @@ describe("User Service", () => {
},
},
},
select: expect.any(Object),
select: publicUserSelect,
});
});
+7 -21
View File
@@ -10,21 +10,7 @@ import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbri
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
import { validateInputs } from "../utils/validate";
const responseSelection = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
};
import { publicUserSelect } from "./public-user";
// function to retrive basic information about a user's user
export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
@@ -35,7 +21,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
if (!user) {
@@ -59,7 +45,7 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
where: {
email,
},
select: responseSelection,
select: publicUserSelect,
});
return user;
@@ -82,7 +68,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
id: personId,
},
data: data,
select: responseSelection,
select: publicUserSelect,
});
return updatedUser;
@@ -105,7 +91,7 @@ const deleteUserById = async (id: string): Promise<TUser> => {
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
return user;
} catch (error) {
@@ -153,7 +139,7 @@ export const getUsersWithOrganization = async (organizationId: string): Promise<
},
},
},
select: responseSelection,
select: publicUserSelect,
});
return users;
@@ -174,7 +160,7 @@ export const getUserLocale = reactCache(async (id: string): Promise<TUserLocale
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
if (!user) {
+3
View File
@@ -828,6 +828,9 @@
},
"notion_integration_description": "Sende Daten an deine Notion Datenbank",
"please_select_a_survey_error": "Bitte wähle eine Umfrage aus",
"reconnect_button": "Erneut verbinden",
"reconnect_button_description": "Deine Integrationsverbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Links und Daten bleiben erhalten.",
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Links und Daten bleiben erhalten.",
"select_at_least_one_question_error": "Bitte wähle mindestens eine Frage aus",
"slack": {
"already_connected_another_survey": "Du hast bereits eine andere Umfrage mit diesem Kanal verbunden.",
+3
View File
@@ -828,6 +828,9 @@
},
"notion_integration_description": "Send data to your Notion database",
"please_select_a_survey_error": "Please select a survey",
"reconnect_button": "Reconnect",
"reconnect_button_description": "Your integration connection has expired. Please reconnect to continue syncing responses. Your existing links and data will be preserved.",
"reconnect_button_tooltip": "Reconnect the integration to refresh your access. Your existing links and data will be preserved.",
"select_at_least_one_question_error": "Please select at least one question",
"slack": {
"already_connected_another_survey": "You have already connected another survey to this channel.",
+3
View File
@@ -828,6 +828,9 @@
},
"notion_integration_description": "Envía datos a tu base de datos de Notion",
"please_select_a_survey_error": "Por favor, selecciona una encuesta",
"reconnect_button": "Reconectar",
"reconnect_button_description": "Tu conexión de integración ha caducado. Por favor, reconecta para seguir sincronizando las respuestas. Tus enlaces y datos existentes se conservarán.",
"reconnect_button_tooltip": "Reconecta la integración para actualizar tu acceso. Tus enlaces y datos existentes se conservarán.",
"select_at_least_one_question_error": "Por favor, selecciona al menos una pregunta",
"slack": {
"already_connected_another_survey": "Ya has conectado otra encuesta a este canal.",
+3
View File
@@ -828,6 +828,9 @@
},
"notion_integration_description": "Envoyez des données à votre base de données Notion.",
"please_select_a_survey_error": "Veuillez sélectionner une enquête.",
"reconnect_button": "Reconnecter",
"reconnect_button_description": "Ta connexion à l'intégration a expiré. Reconnecte-toi pour continuer à synchroniser les réponses. Tes liens et données existants seront conservés.",
"reconnect_button_tooltip": "Reconnecte l'intégration pour actualiser ton accès. Tes liens et données existants seront conservés.",
"select_at_least_one_question_error": "Veuillez sélectionner au moins une question.",
"slack": {
"already_connected_another_survey": "Vous avez déjà connecté une autre enquête à ce canal.",
+3
View File
@@ -828,6 +828,9 @@
},
"notion_integration_description": "Adatok küldése a Notion-adatbázisba",
"please_select_a_survey_error": "Válasszon kérdőívet",
"reconnect_button": "Újracsatlakozás",
"reconnect_button_description": "Az integráció kapcsolata lejárt. Kérjük, csatlakozzon újra a válaszok szinkronizálásának folytatásához. A meglévő hivatkozások és adatok megmaradnak.",
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő hivatkozások és adatok megmaradnak.",
"select_at_least_one_question_error": "Válasszon legalább egy kérdést",
"slack": {
"already_connected_another_survey": "Már hozzákapcsolt egy másik kérdőívet ehhez a csatornához.",
+3
View File
@@ -828,6 +828,9 @@
},
"notion_integration_description": "回答を直接Notionに送信します",
"please_select_a_survey_error": "フォームを選択してください",
"reconnect_button": "再接続",
"reconnect_button_description": "統合の接続が期限切れになりました。回答の同期を続けるには再接続してください。既存のリンクとデータは保持されます。",
"reconnect_button_tooltip": "統合を再接続してアクセスを更新します。既存のリンクとデータは保持されます。",
"select_at_least_one_question_error": "少なくとも1つの質問を選択してください",
"slack": {
"already_connected_another_survey": "このチャンネルには別のフォームがすでに接続されています。",
+3
View File
@@ -828,6 +828,9 @@
},
"notion_integration_description": "Verzend gegevens naar uw Notion-database",
"please_select_a_survey_error": "Selecteer een enquête",
"reconnect_button": "Opnieuw verbinden",
"reconnect_button_description": "Je integratieverbinding is verlopen. Maak opnieuw verbinding om door te gaan met het synchroniseren van reacties. Je bestaande links en gegevens blijven behouden.",
"reconnect_button_tooltip": "Verbind de integratie opnieuw om je toegang te vernieuwen. Je bestaande links en gegevens blijven behouden.",
"select_at_least_one_question_error": "Selecteer minimaal één vraag",
"slack": {
"already_connected_another_survey": "U heeft al een andere enquête aan dit kanaal gekoppeld.",
+3
View File
@@ -828,6 +828,9 @@
},
"notion_integration_description": "Enviar dados para seu banco de dados do Notion",
"please_select_a_survey_error": "Por favor, escolha uma pesquisa",
"reconnect_button": "Reconectar",
"reconnect_button_description": "Sua conexão de integração expirou. Por favor, reconecte para continuar sincronizando respostas. Seus links e dados existentes serão preservados.",
"reconnect_button_tooltip": "Reconecte a integração para atualizar seu acesso. Seus links e dados existentes serão preservados.",
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
"slack": {
"already_connected_another_survey": "Você já conectou outra pesquisa a este canal.",
+3
View File
@@ -828,6 +828,9 @@
},
"notion_integration_description": "Enviar dados para a sua base de dados do Notion",
"please_select_a_survey_error": "Por favor, selecione um inquérito",
"reconnect_button": "Voltar a ligar",
"reconnect_button_description": "A ligação da tua integração expirou. Por favor, volta a ligar para continuar a sincronizar as respostas. As tuas ligações e dados existentes serão preservados.",
"reconnect_button_tooltip": "Volta a ligar a integração para atualizar o teu acesso. As tuas ligações e dados existentes serão preservados.",
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
"slack": {
"already_connected_another_survey": "Já ligou outro inquérito a este canal.",
+3
View File
@@ -828,6 +828,9 @@
},
"notion_integration_description": "Trimiteți datele în baza de date Notion",
"please_select_a_survey_error": "Vă rugăm să selectați un sondaj",
"reconnect_button": "Reconectează",
"reconnect_button_description": "Conexiunea integrării tale a expirat. Te rugăm să te reconectezi pentru a continua sincronizarea răspunsurilor. Linkurile și datele tale existente vor fi păstrate.",
"reconnect_button_tooltip": "Reconectează integrarea pentru a reîmprospăta accesul. Linkurile și datele tale existente vor fi păstrate.",
"select_at_least_one_question_error": "Vă rugăm să selectați cel puțin o întrebare",
"slack": {
"already_connected_another_survey": "Ați conectat deja un alt chestionar la acest canal.",
+3
View File
@@ -828,6 +828,9 @@
},
"notion_integration_description": "Отправляйте данные в вашу базу данных Notion",
"please_select_a_survey_error": "Пожалуйста, выберите опрос",
"reconnect_button": "Переподключить",
"reconnect_button_description": "Срок действия подключения интеграции истёк. Пожалуйста, переподключитесь, чтобы продолжить синхронизацию ответов. Ваши существующие ссылки и данные будут сохранены.",
"reconnect_button_tooltip": "Переподключите интеграцию, чтобы обновить доступ. Ваши существующие ссылки и данные будут сохранены.",
"select_at_least_one_question_error": "Пожалуйста, выберите хотя бы один вопрос",
"slack": {
"already_connected_another_survey": "Вы уже подключили другой опрос к этому каналу.",
+3
View File
@@ -828,6 +828,9 @@
},
"notion_integration_description": "Skicka data till din Notion-databas",
"please_select_a_survey_error": "Vänligen välj en enkät",
"reconnect_button": "Återanslut",
"reconnect_button_description": "Din integrationsanslutning har gått ut. Vänligen återanslut för att fortsätta synkronisera svar. Dina befintliga länkar och data kommer att bevaras.",
"reconnect_button_tooltip": "Återanslut integrationen för att uppdatera din åtkomst. Dina befintliga länkar och data kommer att bevaras.",
"select_at_least_one_question_error": "Vänligen välj minst en fråga",
"slack": {
"already_connected_another_survey": "Du har redan anslutit en annan enkät till denna kanal.",
+3
View File
@@ -828,6 +828,9 @@
},
"notion_integration_description": "将 数据 发送到 您的 Notion 数据库",
"please_select_a_survey_error": "请选择 一个 调查",
"reconnect_button": "重新连接",
"reconnect_button_description": "你的集成连接已过期。请重新连接以继续同步响应。你现有的链接和数据将被保留。",
"reconnect_button_tooltip": "重新连接集成以刷新你的访问权限。你现有的链接和数据将被保留。",
"select_at_least_one_question_error": "请选择至少 一个问题",
"slack": {
"already_connected_another_survey": "您 已 经 将 另 一 个 调 查 连 接 到 此 频 道 。",
+3
View File
@@ -828,6 +828,9 @@
},
"notion_integration_description": "將資料傳送至您的 Notion 資料庫",
"please_select_a_survey_error": "請選取問卷",
"reconnect_button": "重新連接",
"reconnect_button_description": "您的整合連線已過期。請重新連接以繼續同步回應。您現有的連結和資料將會保留。",
"reconnect_button_tooltip": "重新連接整合以更新您的存取權限。您現有的連結和資料將會保留。",
"select_at_least_one_question_error": "請選取至少一個問題",
"slack": {
"already_connected_another_survey": "您已將另一個問卷連線到此頻道。",
@@ -13,6 +13,10 @@ const mockUser = {
createdAt: new Date(),
updatedAt: new Date(),
isActive: true,
password: "$2b$12$hashedPassword",
twoFactorSecret: "encrypted-2fa-secret",
backupCodes: "encrypted-backup-codes",
identityProviderAccountId: "provider-account-id",
role: "admin",
memberships: [{ organizationId: "org456", role: "admin" }],
teamUsers: [{ team: { name: "Test Team", id: "team123", projectTeams: [{ projectId: "proj789" }] } }],
@@ -60,6 +64,10 @@ describe("Users Lib", () => {
updatedAt: expect.any(Date),
},
]);
expect(result.data.data[0]).not.toHaveProperty("password");
expect(result.data.data[0]).not.toHaveProperty("twoFactorSecret");
expect(result.data.data[0]).not.toHaveProperty("backupCodes");
expect(result.data.data[0]).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -84,6 +92,10 @@ describe("Users Lib", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.id).toBe(mockUser.id);
expect(result.data).not.toHaveProperty("password");
expect(result.data).not.toHaveProperty("twoFactorSecret");
expect(result.data).not.toHaveProperty("backupCodes");
expect(result.data).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -151,6 +163,10 @@ describe("Users Lib", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.name).toBe("Updated User");
expect(result.data).not.toHaveProperty("password");
expect(result.data).not.toHaveProperty("twoFactorSecret");
expect(result.data).not.toHaveProperty("backupCodes");
expect(result.data).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -76,6 +76,7 @@ export const AddWebhookModal = ({
url: testEndpointInput,
secret: webhookSecret,
});
if (!testEndpointActionResult?.data) {
const errorMessage = getFormattedErrorMessage(testEndpointActionResult);
throw new Error(errorMessage);
@@ -17,6 +17,14 @@ vi.mock("@formbricks/database", () => ({
},
}));
const constantsMock = vi.hoisted(() => ({ dangerouslyAllow: false }));
vi.mock("@/lib/constants", () => ({
get DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS() {
return constantsMock.dangerouslyAllow;
},
}));
vi.mock("@/lib/crypto", () => ({
generateStandardWebhookSignature: vi.fn(() => "signed-payload"),
generateWebhookSecret: vi.fn(() => "generated-secret"),
@@ -41,6 +49,7 @@ vi.mock("uuid", () => ({
describe("testEndpoint", () => {
beforeEach(() => {
vi.resetAllMocks();
constantsMock.dangerouslyAllow = false;
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
@@ -76,6 +85,36 @@ describe("testEndpoint", () => {
expect(getTranslate).toHaveBeenCalled();
});
test.each([301, 302, 303, 307, 308])(
"rejects %s redirects to prevent SSRF via redirect",
async (statusCode) => {
const fetchMock = vi.fn(async () => ({ status: statusCode }));
vi.stubGlobal("fetch", fetchMock);
await expect(testEndpoint("https://example.com/webhook")).rejects.toThrow(
"Webhook endpoint returned a redirect, which is not allowed"
);
expect(fetchMock).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({ redirect: "manual" })
);
}
);
test("follows redirects when DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS is enabled", async () => {
constantsMock.dangerouslyAllow = true;
const fetchMock = vi.fn(async () => ({ status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await expect(testEndpoint("https://example.com/webhook")).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({ redirect: "follow" })
);
});
test("allows non-blocked non-2xx statuses", async () => {
vi.stubGlobal(
"fetch",
@@ -9,6 +9,7 @@ import {
ResourceNotFoundError,
UnknownError,
} from "@formbricks/types/errors";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
@@ -191,13 +192,29 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
);
}
// `redirect: "manual"` prevents SSRF via redirect — validateWebhookUrl only checks the
// initial URL, so following 30x to a private/internal host (e.g. cloud metadata) would bypass it.
// Gated on the same env var as validateWebhookUrl: self-hosters who opted into trusting internal
// URLs also get the pre-patch redirect-follow behavior for consistency.
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
const response = await fetch(url, {
method: "POST",
body,
headers: requestHeaders,
signal: controller.signal,
redirect: redirectMode,
});
const statusCode = response.status;
// With `redirect: "manual"`, Node's undici returns the actual 30x response (not the spec's
// opaqueredirect filter). Treat any 30x as a redirect rejection so users get a clear error
// instead of a misleading success. With `redirect: "follow"`, fetch returns the final 2xx/4xx/5xx
// and this branch is unreachable.
if (statusCode >= 300 && statusCode < 400) {
throw new InvalidInputError("Webhook endpoint returned a redirect, which is not allowed");
}
const errorMessage = await getWebhookTestErrorMessage(statusCode);
if (errorMessage) {
@@ -0,0 +1,38 @@
import { expect } from "@playwright/test";
import { logger } from "@formbricks/logger";
import { test } from "../../lib/fixtures";
test.describe("API Tests for Management Me", () => {
test("Authenticated v1 me endpoint never exposes secret auth fields", async ({ page, users }) => {
const name = `Security Me User ${Date.now()}`;
const email = `security-me-${Date.now()}@example.com`;
try {
const user = await users.create({ name, email });
await user.login();
const response = await page.context().request.get("/api/v1/management/me");
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(responseBody).toMatchObject({
id: expect.any(String),
name,
email,
twoFactorEnabled: expect.any(Boolean),
identityProvider: expect.any(String),
notificationSettings: expect.any(Object),
locale: expect.any(String),
isActive: expect.any(Boolean),
});
expect(responseBody).not.toHaveProperty("password");
expect(responseBody).not.toHaveProperty("twoFactorSecret");
expect(responseBody).not.toHaveProperty("backupCodes");
expect(responseBody).not.toHaveProperty("identityProviderAccountId");
} catch (error) {
logger.error(error, "Error during management me API security test");
throw error;
}
});
});
@@ -20,12 +20,14 @@ function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownM
function DropdownMenuContent({
className,
sideOffset = 4,
ref,
...props
}: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Content>>) {
return (
<DropdownMenuPrimitive.Portal>
<div id="fbjs">
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
@@ -58,7 +58,9 @@ export function useDropdownSearch<T extends { id: string; label: string }>({
const focusSearchAndLockSide = (): void => {
searchInputRef.current?.focus();
const side = contentRef.current?.dataset.side;
const dataset = contentRef.current?.dataset;
if (!dataset) return;
const side = dataset.side;
if (side === "top" || side === "bottom") setLockedSide(side);
};
+18 -4
View File
@@ -1,8 +1,12 @@
import { z } from "zod";
import { ZIntegrationAirtableConfig, ZIntegrationAirtableInput } from "./airtable";
import { ZIntegrationGoogleSheetsConfig, ZIntegrationGoogleSheetsInput } from "./google-sheet";
import { ZIntegrationNotionConfig, ZIntegrationNotionInput } from "./notion";
import { ZIntegrationSlackConfig, ZIntegrationSlackInput } from "./slack";
import { type TIntegrationAirtable, ZIntegrationAirtableConfig, ZIntegrationAirtableInput } from "./airtable";
import {
type TIntegrationGoogleSheets,
ZIntegrationGoogleSheetsConfig,
ZIntegrationGoogleSheetsInput,
} from "./google-sheet";
import { type TIntegrationNotion, ZIntegrationNotionConfig, ZIntegrationNotionInput } from "./notion";
import { type TIntegrationSlack, ZIntegrationSlackConfig, ZIntegrationSlackInput } from "./slack";
export const ZIntegrationType = z.enum(["googleSheets", "n8n", "airtable", "notion", "slack"]);
export type TIntegrationType = z.infer<typeof ZIntegrationType>;
@@ -28,6 +32,16 @@ export const ZIntegration = ZIntegrationBase.extend({
export type TIntegration = z.infer<typeof ZIntegration>;
export type TIntegrationByType<T extends TIntegrationType> = T extends "airtable"
? TIntegrationAirtable
: T extends "googleSheets"
? TIntegrationGoogleSheets
: T extends "notion"
? TIntegrationNotion
: T extends "slack"
? TIntegrationSlack
: TIntegration;
export const ZIntegrationBaseSurveyData = z.object({
createdAt: z.date(),
elementIds: z.array(z.string()),