Compare commits

..

2 Commits

Author SHA1 Message Date
Bhagya Amarasinghe 6b1b2c8e81 fix: address Docker setup review follow-ups 2026-05-12 11:59:44 +05:30
Bhagya Amarasinghe f747af59f4 fix: preserve existing Docker installs in setup script 2026-05-12 11:22:22 +05:30
23 changed files with 2011 additions and 2917 deletions
+8 -8
View File
@@ -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"
}
}
+14 -33
View File
@@ -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 -127
View File
@@ -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();
}
});
});
+47 -108
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+7 -7
View File
@@ -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"
}
}
+3 -3
View File
@@ -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 -3
View File
@@ -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"
}
}
+1 -1
View File
@@ -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"
}
}
+1 -1
View File
@@ -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"
}
+2 -2
View File
@@ -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"
}
}
+3 -3
View File
@@ -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"
}
}
+2 -2
View File
@@ -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"
}
}
+3 -3
View File
@@ -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"
}
}
+7 -7
View File
@@ -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"
}
}
+2 -2
View File
@@ -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"
}
}
+1 -1
View File
@@ -17,6 +17,6 @@
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"vite": "7.3.3"
"vite": "7.3.2"
}
}
+1831 -2468
View File
File diff suppressed because it is too large Load Diff