mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 03:20:43 -05:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b1b2c8e81 | |||
| f747af59f4 |
@@ -12,18 +12,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "5.0.2",
|
||||
"@storybook/addon-a11y": "10.3.6",
|
||||
"@storybook/addon-docs": "10.3.6",
|
||||
"@storybook/addon-links": "10.3.6",
|
||||
"@storybook/addon-onboarding": "10.3.6",
|
||||
"@storybook/react-vite": "10.3.6",
|
||||
"@storybook/addon-a11y": "10.3.5",
|
||||
"@storybook/addon-docs": "10.3.5",
|
||||
"@storybook/addon-links": "10.3.5",
|
||||
"@storybook/addon-onboarding": "10.3.5",
|
||||
"@storybook/react-vite": "10.3.5",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.3.6",
|
||||
"storybook": "10.3.6",
|
||||
"vite": "7.3.3"
|
||||
"eslint-plugin-storybook": "10.3.5",
|
||||
"storybook": "10.3.5",
|
||||
"vite": "7.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import type { Agent } from "undici";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -17,7 +16,7 @@ import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { convertDatesInObject } from "@/lib/time";
|
||||
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { createPinnedDispatcher, validateAndResolveWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
|
||||
@@ -99,19 +98,11 @@ export const POST = async (request: Request) => {
|
||||
// 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";
|
||||
// Uses AbortSignal to actually cancel the underlying fetch when the timer fires —
|
||||
// a Promise.race would only reject the wrapper while the fetch keeps the socket
|
||||
// open, which then deadlocks dispatcher.close() (graceful drain waits for it).
|
||||
const fetchWithTimeout = (
|
||||
url: string,
|
||||
options: RequestInit & { dispatcher?: Agent },
|
||||
timeout: number = 5000
|
||||
): Promise<Response> => {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
redirect: redirectMode,
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
} as RequestInit & { dispatcher?: Agent });
|
||||
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
|
||||
return Promise.race([
|
||||
fetch(url, { ...options, redirect: redirectMode }),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
|
||||
]);
|
||||
};
|
||||
|
||||
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
|
||||
@@ -153,24 +144,14 @@ export const POST = async (request: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
return validateAndResolveWebhookUrl(webhook.url)
|
||||
.then(async (address) => {
|
||||
// Pin TCP connect to the validated IP. Without this, undici resolves DNS
|
||||
// again at fetch time and an attacker-controlled domain can rebind to a
|
||||
// private/internal IP after validation passed (TOCTOU SSRF).
|
||||
const dispatcher = address ? createPinnedDispatcher(address) : undefined;
|
||||
try {
|
||||
return await fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
dispatcher,
|
||||
});
|
||||
} finally {
|
||||
// destroy() — not close() — force-kills sockets and rejects any in-flight request
|
||||
await dispatcher?.destroy();
|
||||
}
|
||||
})
|
||||
return validateWebhookUrl(webhook.url)
|
||||
.then(() =>
|
||||
fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import dns from "node:dns";
|
||||
import type { Agent } from "undici";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
createPinnedDispatcher,
|
||||
validateAndResolveWebhookUrl,
|
||||
validateWebhookUrl,
|
||||
} from "./validate-webhook-url";
|
||||
import { validateWebhookUrl } from "./validate-webhook-url";
|
||||
|
||||
vi.mock("node:dns", () => ({
|
||||
default: {
|
||||
@@ -377,125 +372,4 @@ describe("validateWebhookUrl", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAndResolveWebhookUrl returns pinned address", () => {
|
||||
test("returns IPv4 literal as { ip, family: 4 }", async () => {
|
||||
await expect(validateAndResolveWebhookUrl("https://93.184.216.34/webhook")).resolves.toEqual({
|
||||
ip: "93.184.216.34",
|
||||
family: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns IPv6 literal stripped of brackets as { ip, family: 6 }", async () => {
|
||||
await expect(
|
||||
validateAndResolveWebhookUrl("https://[2606:2800:220:1:248:1893:25c8:1946]/webhook")
|
||||
).resolves.toEqual({
|
||||
ip: "2606:2800:220:1:248:1893:25c8:1946",
|
||||
family: 6,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns first resolved IPv4 for hostnames", async () => {
|
||||
setupDnsResolution(["93.184.216.34", "23.23.23.23"]);
|
||||
await expect(validateAndResolveWebhookUrl("https://example.com/webhook")).resolves.toEqual({
|
||||
ip: "93.184.216.34",
|
||||
family: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns IPv6 when only IPv6 is resolvable", async () => {
|
||||
setupDnsResolution(null, ["2606:2800:220:1:248:1893:25c8:1946"]);
|
||||
await expect(validateAndResolveWebhookUrl("https://example.com/webhook")).resolves.toEqual({
|
||||
ip: "2606:2800:220:1:248:1893:25c8:1946",
|
||||
family: 6,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null for blocked hostname when DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS is enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
const { validateAndResolveWebhookUrl: fn } = await import("./validate-webhook-url");
|
||||
await expect(fn("http://localhost/webhook")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPinnedDispatcher", () => {
|
||||
test("returns an undici Agent instance", async () => {
|
||||
const { Agent } = await import("undici");
|
||||
const dispatcher = createPinnedDispatcher({ ip: "93.184.216.34", family: 4 });
|
||||
expect(dispatcher).toBeInstanceOf(Agent);
|
||||
await dispatcher.close();
|
||||
});
|
||||
|
||||
// Reach into the Agent's connect options to grab the lookup function we
|
||||
// installed. This is implementation-coupled but the only way to assert the
|
||||
// pinning behavior without spinning up a real socket. If undici changes
|
||||
// internals and this stops finding the lookup, the integration-style test
|
||||
// below still verifies the end-to-end behavior.
|
||||
const extractLookup = (
|
||||
agent: Agent
|
||||
):
|
||||
| ((
|
||||
host: string,
|
||||
opts: { all?: boolean },
|
||||
cb: (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
address: string | { address: string; family: number }[],
|
||||
family?: number
|
||||
) => void
|
||||
) => void)
|
||||
| undefined => {
|
||||
const symbols = Object.getOwnPropertySymbols(agent);
|
||||
for (const sym of symbols) {
|
||||
const value = (agent as unknown as Record<symbol, unknown>)[sym];
|
||||
if (value && typeof value === "object" && "connect" in value) {
|
||||
const connect = (value as { connect?: { lookup?: unknown } }).connect;
|
||||
if (connect && typeof connect.lookup === "function") {
|
||||
return connect.lookup as never;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
test("lookup returns the pinned IP regardless of which hostname is queried (all=true)", async () => {
|
||||
const dispatcher = createPinnedDispatcher({ ip: "93.184.216.34", family: 4 });
|
||||
const lookup = extractLookup(dispatcher);
|
||||
|
||||
// If we couldn't reach into the Agent, skip the deep assertion — the
|
||||
// integration test still covers the contract.
|
||||
if (!lookup) {
|
||||
await dispatcher.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await new Promise<{ address: string; family: number }[]>((resolve, reject) => {
|
||||
lookup("attacker-rebound.example.com", { all: true }, (err, addresses) => {
|
||||
if (err) reject(err);
|
||||
else resolve(addresses as { address: string; family: number }[]);
|
||||
});
|
||||
});
|
||||
expect(result).toEqual([{ address: "93.184.216.34", family: 4 }]);
|
||||
await dispatcher.close();
|
||||
});
|
||||
|
||||
test("lookup honours legacy (err, address, family) form when all is not set", async () => {
|
||||
const dispatcher = createPinnedDispatcher({ ip: "2606:4700::1", family: 6 });
|
||||
const lookup = extractLookup(dispatcher);
|
||||
if (!lookup) {
|
||||
await dispatcher.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await new Promise<{ address: string; family: number }>((resolve, reject) => {
|
||||
lookup("anything.example", {}, (err, address, family) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ address: address as string, family: family ?? -1 });
|
||||
});
|
||||
});
|
||||
expect(result).toEqual({ address: "2606:4700::1", family: 6 });
|
||||
await dispatcher.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { createPinnedDispatcher } from "./validate-webhook-url";
|
||||
|
||||
// Real DNS, no node:dns mock. The whole point of this file is to prove that
|
||||
// the pinned dispatcher bypasses DNS entirely — so the hostname we use must
|
||||
// genuinely fail to resolve in real life.
|
||||
vi.unmock("node:dns");
|
||||
|
||||
vi.mock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
|
||||
}));
|
||||
|
||||
describe("DNS rebinding TOCTOU — pinned dispatcher", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
const visited: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
visited.push(req.headers.host ?? "");
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.end("hit-pinned-target");
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
test("baseline: fetch to *.invalid hostname fails (real DNS cannot resolve it)", async () => {
|
||||
// RFC 2606 reserves the .invalid TLD — guaranteed to never resolve.
|
||||
// This proves DNS is what fetch normally relies on.
|
||||
await expect(
|
||||
fetch(`http://attacker-rebind.invalid:${port}/`).catch((e: Error) => {
|
||||
throw new Error(e.message);
|
||||
})
|
||||
).rejects.toThrow(/fetch failed/i);
|
||||
});
|
||||
|
||||
test("with pinned dispatcher: connection lands on pinned IP even though hostname is unresolvable", async () => {
|
||||
// Simulates: validate resolved attacker.com to a public IP (here represented
|
||||
// by 127.0.0.1 — the local test server). Attacker then rebinds DNS so a
|
||||
// second lookup would return something different (or nothing). The pinned
|
||||
// dispatcher means there *is* no second lookup — undici uses our IP.
|
||||
const dispatcher = createPinnedDispatcher({ ip: "127.0.0.1", family: 4 });
|
||||
try {
|
||||
const response = await fetch(`http://attacker-rebind.invalid:${port}/`, {
|
||||
// RequestInit doesn't type `dispatcher` — undici accepts it at runtime.
|
||||
dispatcher,
|
||||
} as RequestInit & { dispatcher: typeof dispatcher });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe("hit-pinned-target");
|
||||
// The Host header preserves the original hostname (TLS SNI parity);
|
||||
// only the TCP target was rerouted via the pin.
|
||||
expect(visited.at(-1)).toContain("attacker-rebind.invalid");
|
||||
} finally {
|
||||
await dispatcher.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import "server-only";
|
||||
import dns from "node:dns";
|
||||
import { Agent } from "undici";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
|
||||
|
||||
@@ -68,15 +67,13 @@ const isPrivateIPv6 = (ip: string): boolean => {
|
||||
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
||||
};
|
||||
|
||||
const isPrivateIP = (ip: string, family: 4 | 6): boolean => {
|
||||
return family === 4 ? isPrivateIPv4(ip) : isPrivateIPv6(ip);
|
||||
const isPrivateIP = (ip: string): boolean => {
|
||||
return isPrivateIPv4(ip) || isPrivateIPv6(ip);
|
||||
};
|
||||
|
||||
const DNS_TIMEOUT_MS = 3000;
|
||||
|
||||
export type ResolvedAddress = { ip: string; family: 4 | 6 };
|
||||
|
||||
const resolveHostnameToAddresses = (hostname: string): Promise<ResolvedAddress[]> => {
|
||||
const resolveHostnameToIPs = (hostname: string): Promise<string[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
@@ -92,16 +89,16 @@ const resolveHostnameToAddresses = (hostname: string): Promise<ResolvedAddress[]
|
||||
}, DNS_TIMEOUT_MS);
|
||||
|
||||
dns.resolve(hostname, (errV4, ipv4Addresses) => {
|
||||
const ipv4: ResolvedAddress[] = errV4 ? [] : ipv4Addresses.map((ip) => ({ ip, family: 4 as const }));
|
||||
const ipv4 = errV4 ? [] : ipv4Addresses;
|
||||
|
||||
dns.resolve6(hostname, (errV6, ipv6Addresses) => {
|
||||
const ipv6: ResolvedAddress[] = errV6 ? [] : ipv6Addresses.map((ip) => ({ ip, family: 6 as const }));
|
||||
const all = [...ipv4, ...ipv6];
|
||||
const ipv6 = errV6 ? [] : ipv6Addresses;
|
||||
const allAddresses = [...ipv4, ...ipv6];
|
||||
|
||||
if (all.length === 0) {
|
||||
if (allAddresses.length === 0) {
|
||||
settle(reject, new Error(`DNS resolution failed for hostname: ${hostname}`));
|
||||
} else {
|
||||
settle(resolve, all);
|
||||
settle(resolve, allAddresses);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -117,35 +114,59 @@ const stripIPv6Brackets = (hostname: string): string => {
|
||||
|
||||
const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
||||
|
||||
const parseWebhookUrl = (url: string): URL => {
|
||||
/**
|
||||
* Validates a webhook URL to prevent Server-Side Request Forgery (SSRF).
|
||||
*
|
||||
* Checks performed:
|
||||
* 1. URL must be well-formed
|
||||
* 2. Protocol must be HTTPS or HTTP
|
||||
* 3. Hostname must not be a known internal name (localhost, metadata endpoints)
|
||||
* 4. IP literal hostnames are checked directly against private ranges
|
||||
* 5. Domain hostnames are resolved via DNS; all resulting IPs must be public
|
||||
*
|
||||
* @throws {InvalidInputError} when the URL fails any validation check
|
||||
*/
|
||||
export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new InvalidInputError("Invalid webhook URL format");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
throw new InvalidInputError("Webhook URL must use HTTPS or HTTP protocol");
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const validateIpLiteral = (hostname: string): ResolvedAddress | null => {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
// Direct IP literal — validate without DNS resolution
|
||||
const isIPv4Literal = IPV4_LITERAL.test(hostname);
|
||||
const isIPv6Literal = hostname.startsWith("[");
|
||||
if (!isIPv4Literal && !isIPv6Literal) return null;
|
||||
|
||||
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
|
||||
const family: 4 | 6 = isIPv4Literal ? 4 : 6;
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip, family)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
if (isIPv4Literal || isIPv6Literal) {
|
||||
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
return;
|
||||
}
|
||||
return { ip, family };
|
||||
};
|
||||
|
||||
const resolveHostnameOrThrow = async (hostname: string): Promise<ResolvedAddress[]> => {
|
||||
// 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 {
|
||||
return await resolveHostnameToAddresses(hostname);
|
||||
resolvedIPs = await resolveHostnameToIPs(hostname);
|
||||
} catch (error) {
|
||||
const isTimeout = error instanceof Error && error.message.includes("timed out");
|
||||
throw new InvalidInputError(
|
||||
@@ -154,94 +175,12 @@ const resolveHostnameOrThrow = async (hostname: string): Promise<ResolvedAddress
|
||||
: `Could not resolve webhook URL hostname: ${hostname}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a webhook URL and returns a resolved address pinned for delivery.
|
||||
*
|
||||
* Returns the IP literal or first DNS-resolved address. Returns `null` only when
|
||||
* `DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS` is enabled for a known internal hostname
|
||||
* (localhost etc.) — in that case the caller skips IP pinning so /etc/hosts works.
|
||||
*
|
||||
* Pinning the returned address into the fetch dispatcher closes the TOCTOU window
|
||||
* where DNS could rebind between this validation and the subsequent HTTP request.
|
||||
*
|
||||
* @throws {InvalidInputError} when the URL fails any validation check
|
||||
*/
|
||||
export const validateAndResolveWebhookUrl = async (url: string): Promise<ResolvedAddress | null> => {
|
||||
const parsed = parseWebhookUrl(url);
|
||||
const hostname = parsed.hostname;
|
||||
const isBlockedName = BLOCKED_HOSTNAMES.has(hostname.toLowerCase());
|
||||
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isBlockedName) {
|
||||
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
|
||||
}
|
||||
|
||||
const literal = validateIpLiteral(hostname);
|
||||
if (literal) return literal;
|
||||
|
||||
// Skip DNS for localhost-like hostnames when internal URLs are allowed (resolved via /etc/hosts)
|
||||
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isBlockedName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolved = await resolveHostnameOrThrow(hostname);
|
||||
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
|
||||
for (const addr of resolved) {
|
||||
if (isPrivateIP(addr.ip, addr.family)) {
|
||||
for (const ip of resolvedIPs) {
|
||||
if (isPrivateIP(ip)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pin to the first resolved address. All addresses already passed the public-IP
|
||||
// check above, so any choice is safe.
|
||||
return resolved[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a webhook URL to prevent Server-Side Request Forgery (SSRF).
|
||||
* Thin wrapper around {@link validateAndResolveWebhookUrl} for callers that only
|
||||
* need validation (e.g. webhook create/update) and discard the resolved address.
|
||||
*
|
||||
* @throws {InvalidInputError} when the URL fails any validation check
|
||||
*/
|
||||
export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
await validateAndResolveWebhookUrl(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds an undici Agent that pins outgoing TCP connections to the given IP/family,
|
||||
* regardless of what hostname the URL resolves to at fetch time. Use the address
|
||||
* returned by {@link validateAndResolveWebhookUrl} so the IP that was validated is
|
||||
* the IP that gets connected to — closes the DNS-rebinding TOCTOU window.
|
||||
*
|
||||
* TLS SNI/cert validation still uses the original hostname from the URL.
|
||||
*/
|
||||
export const createPinnedDispatcher = (address: ResolvedAddress): Agent => {
|
||||
return new Agent({
|
||||
connect: {
|
||||
// undici calls `lookup(host, { all: true, ... }, cb)`, so honor both forms:
|
||||
// when `all` is true we must return an array; otherwise the legacy
|
||||
// (err, address, family) signature. Returning the wrong form yields
|
||||
// "Invalid IP address: undefined" at connect time.
|
||||
lookup: (_hostname, options, callback) => {
|
||||
if (options && typeof options === "object" && (options as { all?: boolean }).all) {
|
||||
(
|
||||
callback as (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
addresses: { address: string; family: number }[]
|
||||
) => void
|
||||
)(null, [{ address: address.ip, family: address.family }]);
|
||||
return;
|
||||
}
|
||||
(callback as (err: NodeJS.ErrnoException | null, address: string, family: number) => void)(
|
||||
null,
|
||||
address.ip,
|
||||
address.family
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import {
|
||||
createPinnedDispatcher,
|
||||
validateAndResolveWebhookUrl,
|
||||
validateWebhookUrl,
|
||||
} from "@/lib/utils/validate-webhook-url";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { testEndpoint } from "./webhook";
|
||||
@@ -36,12 +32,6 @@ vi.mock("@/lib/crypto", () => ({
|
||||
|
||||
vi.mock("@/lib/utils/validate-webhook-url", () => ({
|
||||
validateWebhookUrl: vi.fn(async () => undefined),
|
||||
validateAndResolveWebhookUrl: vi.fn(async () => ({ ip: "93.184.216.34", family: 4 })),
|
||||
createPinnedDispatcher: vi.fn(() => ({
|
||||
__pinned: true,
|
||||
close: vi.fn(async () => undefined),
|
||||
destroy: vi.fn(async () => undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lingodotdev/server", () => ({
|
||||
@@ -62,12 +52,6 @@ describe("testEndpoint", () => {
|
||||
constantsMock.dangerouslyAllow = false;
|
||||
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
|
||||
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
|
||||
vi.mocked(validateAndResolveWebhookUrl).mockResolvedValue({ ip: "93.184.216.34", family: 4 });
|
||||
vi.mocked(createPinnedDispatcher).mockReturnValue({
|
||||
__pinned: true,
|
||||
close: vi.fn(async () => undefined),
|
||||
destroy: vi.fn(async () => undefined),
|
||||
} as never);
|
||||
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
|
||||
vi.mocked(isDiscordWebhook).mockReturnValue(false);
|
||||
});
|
||||
@@ -96,7 +80,7 @@ describe("testEndpoint", () => {
|
||||
new InvalidInputError(messageKey)
|
||||
);
|
||||
|
||||
expect(validateAndResolveWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
|
||||
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
|
||||
expect(generateStandardWebhookSignature).toHaveBeenCalled();
|
||||
expect(getTranslate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -12,11 +12,7 @@ import {
|
||||
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import {
|
||||
createPinnedDispatcher,
|
||||
validateAndResolveWebhookUrl,
|
||||
validateWebhookUrl,
|
||||
} from "@/lib/utils/validate-webhook-url";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { TWebhookInput } from "../types/webhooks";
|
||||
@@ -167,7 +163,7 @@ export const getWebhooks = async (environmentId: string): Promise<Webhook[]> =>
|
||||
};
|
||||
|
||||
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
|
||||
const address = await validateAndResolveWebhookUrl(url);
|
||||
await validateWebhookUrl(url);
|
||||
|
||||
if (isDiscordWebhook(url)) {
|
||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||
@@ -175,10 +171,6 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
// Hoisted out of the try so the finally can close it on every path.
|
||||
// Pin TCP connect to the validated IP — closes DNS-rebinding TOCTOU between
|
||||
// validation and fetch (undici otherwise resolves the hostname a second time).
|
||||
const dispatcher = address ? createPinnedDispatcher(address) : undefined;
|
||||
|
||||
try {
|
||||
const webhookMessageId = uuidv7();
|
||||
@@ -211,8 +203,7 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
redirect: redirectMode,
|
||||
dispatcher,
|
||||
} as RequestInit & { dispatcher?: ReturnType<typeof createPinnedDispatcher> });
|
||||
});
|
||||
|
||||
const statusCode = response.status;
|
||||
|
||||
@@ -245,9 +236,5 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
// destroy() — not close() — force-kills sockets. close() drains gracefully and
|
||||
// would deadlock if the endpoint accepted TCP but never responded (controller.abort()
|
||||
// above cancels fetch, but destroy is the belt-and-suspenders cleanup).
|
||||
await dispatcher?.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
+10
-10
@@ -45,9 +45,9 @@
|
||||
"@lexical/rich-text": "0.41.0",
|
||||
"@lexical/table": "0.41.0",
|
||||
"@next-auth/prisma-adapter": "1.0.7",
|
||||
"@opentelemetry/auto-instrumentations-node": "0.75.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "0.71.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "0.213.0",
|
||||
"@opentelemetry/exporter-prometheus": "0.217.0",
|
||||
"@opentelemetry/exporter-prometheus": "0.213.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.213.0",
|
||||
"@opentelemetry/resources": "2.6.1",
|
||||
"@opentelemetry/sdk-metrics": "2.6.1",
|
||||
@@ -96,7 +96,7 @@
|
||||
"lexical": "0.41.0",
|
||||
"lucide-react": "0.577.0",
|
||||
"markdown-it": "14.1.1",
|
||||
"next": "16.2.6",
|
||||
"next": "16.2.4",
|
||||
"next-auth": "4.24.13",
|
||||
"next-safe-action": "8.1.10",
|
||||
"nodemailer": "8.0.7",
|
||||
@@ -107,12 +107,12 @@
|
||||
"prismjs": "1.30.0",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.2.6",
|
||||
"react": "19.2.5",
|
||||
"react-calendar": "6.0.1",
|
||||
"react-colorful": "5.6.2",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-confetti": "6.4.0",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.6",
|
||||
"react-dom": "19.2.5",
|
||||
"react-hook-form": "7.71.2",
|
||||
"react-hot-toast": "2.6.0",
|
||||
"react-i18next": "16.5.8",
|
||||
@@ -142,15 +142,15 @@
|
||||
"@types/papaparse": "5.5.2",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"autoprefixer": "10.4.27",
|
||||
"cross-env": "10.1.0",
|
||||
"dotenv": "17.3.1",
|
||||
"postcss": "8.5.14",
|
||||
"postcss": "8.5.12",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"vite": "7.3.3",
|
||||
"vite": "7.3.2",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.6",
|
||||
"vitest": "4.1.5",
|
||||
"vitest-mock-extended": "3.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
+58
-25
@@ -170,10 +170,6 @@ install_formbricks() {
|
||||
echo "🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your $ubuntu_version server."
|
||||
echo ""
|
||||
|
||||
# Remove any old Docker installations, without stopping the script if they're not found
|
||||
echo "🧹 Time to sweep away any old Docker installations."
|
||||
sudo apt-get remove docker docker-engine docker.io containerd runc >/dev/null 2>&1 || true
|
||||
|
||||
# Update package list
|
||||
echo "🔄 Updating your package list."
|
||||
sudo apt-get update >/dev/null 2>&1
|
||||
@@ -183,32 +179,69 @@ install_formbricks() {
|
||||
sudo apt-get install -y \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gnupg \
|
||||
lsb-release >/dev/null 2>&1
|
||||
|
||||
# Set up Docker's official GPG key & stable repository
|
||||
echo "🔑 Adding Docker's official GPG key and setting up the stable repository."
|
||||
sudo mkdir -m 0755 -p /etc/apt/keyrings >/dev/null 2>&1
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg >/dev/null 2>&1
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
|
||||
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null 2>&1
|
||||
# Reuse an existing Docker installation instead of replacing it implicitly.
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
echo "✅ Docker is already installed."
|
||||
|
||||
# Update package list again
|
||||
echo "🔄 Updating your package list again."
|
||||
sudo apt-get update >/dev/null 2>&1
|
||||
if docker info >/dev/null 2>&1 || sudo docker info >/dev/null 2>&1; then
|
||||
echo "✅ Docker daemon is reachable. Reusing the existing Docker installation."
|
||||
|
||||
# Install Docker
|
||||
echo "🐳 Installing Docker."
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null 2>&1
|
||||
|
||||
# Test Docker installation
|
||||
echo "🚀 Testing your Docker installation."
|
||||
if docker --version >/dev/null 2>&1; then
|
||||
echo "🎉 Docker is installed!"
|
||||
if docker compose version >/dev/null 2>&1 || sudo docker compose version >/dev/null 2>&1; then
|
||||
echo "✅ Docker Compose is available."
|
||||
else
|
||||
echo "❌ Docker Compose is not available on this system."
|
||||
echo "Please install Docker Compose or upgrade Docker so 'docker compose' works, then rerun this script."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ Docker is installed, but the daemon is not reachable."
|
||||
echo "Please start or fix Docker and rerun this script."
|
||||
echo "To avoid modifying an existing Docker setup without your consent, this script will not remove or reinstall Docker automatically."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ Docker is not installed. Please install Docker before proceeding."
|
||||
exit 1
|
||||
# Remove old Docker packages only when Docker is not installed at all.
|
||||
echo "⚠️ Legacy Docker-related packages may conflict with Docker CE."
|
||||
echo "These packages can also be used outside Docker, so they will only be removed with your consent."
|
||||
read -p "Remove legacy packages (docker/docker-engine/docker.io/containerd/runc)? [y/N] " remove_legacy_docker_pkgs
|
||||
remove_legacy_docker_pkgs=$(echo "$remove_legacy_docker_pkgs" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$remove_legacy_docker_pkgs" == "y" || "$remove_legacy_docker_pkgs" == "yes" ]]; then
|
||||
echo "🧹 Removing old Docker installations."
|
||||
sudo apt-get remove docker docker-engine docker.io containerd runc >/dev/null 2>&1 || true
|
||||
else
|
||||
echo "⏭️ Skipping legacy package removal."
|
||||
echo "If Docker installation fails due to conflicting packages, rerun the script and allow the removal step."
|
||||
fi
|
||||
|
||||
echo "📦 Installing Docker-specific dependencies."
|
||||
sudo apt-get install -y gnupg >/dev/null 2>&1
|
||||
|
||||
# Set up Docker's official GPG key & stable repository.
|
||||
echo "🔑 Adding Docker's official GPG key and setting up the stable repository."
|
||||
sudo mkdir -m 0755 -p /etc/apt/keyrings >/dev/null 2>&1
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg >/dev/null 2>&1
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
|
||||
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null 2>&1
|
||||
|
||||
# Update package list again after adding the Docker repository.
|
||||
echo "🔄 Updating your package list again."
|
||||
sudo apt-get update >/dev/null 2>&1
|
||||
|
||||
# Install Docker only when it is not already present on the system.
|
||||
echo "🐳 Installing Docker."
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null 2>&1
|
||||
|
||||
# Test Docker installation.
|
||||
echo "🚀 Testing your Docker installation."
|
||||
if docker --version >/dev/null 2>&1; then
|
||||
echo "🎉 Docker is installed!"
|
||||
else
|
||||
echo "❌ Docker is not installed. Please install Docker before proceeding."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Adding your user to the Docker group
|
||||
|
||||
+2
-2
@@ -46,8 +46,8 @@
|
||||
"dev:setup": "bash scripts/setup-dev-env.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@azure/playwright": "1.1.5",
|
||||
|
||||
@@ -36,19 +36,19 @@
|
||||
},
|
||||
"author": "Formbricks <hola@formbricks.com>",
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "4.0.104",
|
||||
"@ai-sdk/azure": "3.0.64",
|
||||
"@ai-sdk/google-vertex": "4.0.126",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/azure": "3.0.54",
|
||||
"@ai-sdk/google-vertex": "4.0.112",
|
||||
"@aws-sdk/credential-providers": "3.1017.0",
|
||||
"@formbricks/logger": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"ai": "6.0.177"
|
||||
"ai": "6.0.168"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"vite": "8.0.12",
|
||||
"vitest": "4.1.6"
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"vite": "8.0.10",
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+3
-3
@@ -44,9 +44,9 @@
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"vite": "7.3.3",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"vite": "7.3.2",
|
||||
"vite-plugin-dts": "4.5.4",
|
||||
"vitest": "4.1.6"
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "15.5.18",
|
||||
"@next/eslint-plugin-next": "15.5.15",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@vercel/style-guide": "6.0.0",
|
||||
"eslint-config-next": "15.5.18",
|
||||
"eslint-config-next": "15.5.15",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-config-turbo": "2.8.21",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.5.2",
|
||||
"@vitest/eslint-plugin": "1.6.17"
|
||||
"@vitest/eslint-plugin": "1.6.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"prisma": "6.19.3",
|
||||
"prisma-json-types-generator": "3.6.2",
|
||||
"tsx": "4.21.0",
|
||||
"vite": "7.3.3",
|
||||
"vite": "7.3.2",
|
||||
"vite-plugin-dts": "4.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@formbricks/types": "workspace:*",
|
||||
"autoprefixer": "10.4.27",
|
||||
"clsx": "2.1.1",
|
||||
"postcss": "8.5.14",
|
||||
"postcss": "8.5.12",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"tailwindcss": "3.4.19"
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@types/node": "^25.4.0",
|
||||
"tsx": "^4.21.0",
|
||||
"vite": "7.3.3",
|
||||
"vitest": "4.1.6"
|
||||
"vite": "7.3.2",
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +45,9 @@
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"vite": "7.3.3",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"vite": "7.3.2",
|
||||
"vite-plugin-dts": "4.5.4",
|
||||
"vitest": "4.1.6"
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"vite": "7.3.3",
|
||||
"vitest": "4.1.6"
|
||||
"vite": "7.3.2",
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"vite": "7.3.3",
|
||||
"vitest": "4.1.6"
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"vite": "7.3.2",
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,21 +85,21 @@
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@storybook/react": "10.3.6",
|
||||
"@storybook/react-vite": "10.3.6",
|
||||
"@storybook/react": "10.3.5",
|
||||
"@storybook/react-vite": "10.3.5",
|
||||
"@tailwindcss/postcss": "4.2.4",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"rimraf": "6.1.3",
|
||||
"tailwindcss": "4.2.4",
|
||||
"vite": "7.3.3",
|
||||
"vite": "7.3.2",
|
||||
"vite-plugin-dts": "4.5.4",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.6"
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,10 +65,10 @@
|
||||
"concurrently": "9.2.1",
|
||||
"fake-indexeddb": "6.2.5",
|
||||
"happy-dom": "20.8.9",
|
||||
"postcss": "8.5.14",
|
||||
"postcss": "8.5.12",
|
||||
"rollup-plugin-visualizer": "7.0.1",
|
||||
"tailwindcss": "4.2.4",
|
||||
"vite": "7.3.3",
|
||||
"vite": "7.3.2",
|
||||
"vite-tsconfig-paths": "6.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,6 @@
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"vite": "7.3.3"
|
||||
"vite": "7.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1831
-2468
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user