chore: adds webhook signing to test event (#7320)

This commit is contained in:
Anshuman Pandey
2026-02-23 16:36:50 +04:00
committed by GitHub
parent 0636989d67
commit 7cea53130c
4 changed files with 92 additions and 28 deletions
@@ -2,6 +2,8 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { generateWebhookSecret } from "@/lib/crypto";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -24,6 +26,7 @@ import { ZWebhookInput } from "@/modules/integrations/webhooks/types/webhooks";
const ZCreateWebhookAction = z.object({
environmentId: ZId,
webhookInput: ZWebhookInput,
webhookSecret: z.string().optional(),
});
export const createWebhookAction = authenticatedActionClient.schema(ZCreateWebhookAction).action(
@@ -47,7 +50,11 @@ export const createWebhookAction = authenticatedActionClient.schema(ZCreateWebho
},
],
});
const webhook = await createWebhook(parsedInput.environmentId, parsedInput.webhookInput);
const webhook = await createWebhook(
parsedInput.environmentId,
parsedInput.webhookInput,
parsedInput.webhookSecret
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.newObject = parsedInput.webhookInput;
return webhook;
@@ -131,10 +138,43 @@ export const updateWebhookAction = authenticatedActionClient.schema(ZUpdateWebho
const ZTestEndpointAction = z.object({
url: z.string(),
webhookId: ZId.optional(),
secret: z.string().optional(),
});
export const testEndpointAction = authenticatedActionClient
.schema(ZTestEndpointAction)
.action(async ({ parsedInput }) => {
return testEndpoint(parsedInput.url);
.action(async ({ ctx, parsedInput }) => {
let secret: string | undefined;
if (parsedInput.webhookId) {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromWebhookId(parsedInput.webhookId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromWebhookId(parsedInput.webhookId),
},
],
});
const webhookResult = await getWebhook(parsedInput.webhookId);
if (!webhookResult.ok) {
throw new ResourceNotFoundError("Webhook", parsedInput.webhookId);
}
secret = webhookResult.data.secret ?? undefined;
} else {
// New webhook, use the provided secret or generate a new one
secret = parsedInput.secret ?? generateWebhookSecret();
}
await testEndpoint(parsedInput.url, secret);
return { success: true, secret };
});
@@ -53,16 +53,22 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
const [selectedAllSurveys, setSelectedAllSurveys] = useState(false);
const [creatingWebhook, setCreatingWebhook] = useState(false);
const [createdWebhook, setCreatedWebhook] = useState<Webhook | null>(null);
const [webhookSecret, setWebhookSecret] = useState<string | undefined>();
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
const handleTestEndpoint = async (
sendSuccessToast: boolean
): Promise<{ success: boolean; secret?: string }> => {
try {
const { valid, error } = validWebHookURL(testEndpointInput);
if (!valid) {
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
return;
return { success: false };
}
setHittingEndpoint(true);
const testEndpointActionResult = await testEndpointAction({ url: testEndpointInput });
const testEndpointActionResult = await testEndpointAction({
url: testEndpointInput,
secret: webhookSecret,
});
if (!testEndpointActionResult?.data) {
const errorMessage = getFormattedErrorMessage(testEndpointActionResult);
throw new Error(errorMessage);
@@ -70,7 +76,10 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
setHittingEndpoint(false);
if (sendSuccessToast) toast.success(t("environments.integrations.webhooks.endpoint_pinged"));
setEndpointAccessible(true);
return true;
if (testEndpointActionResult.data.secret) {
setWebhookSecret(testEndpointActionResult.data.secret);
}
return testEndpointActionResult.data;
} catch (err) {
setHittingEndpoint(false);
toast.error(
@@ -83,7 +92,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
);
console.error(t("environments.integrations.webhooks.webhook_test_failed_due_to"), err.message);
setEndpointAccessible(false);
return false;
return { success: false };
}
};
@@ -127,8 +136,8 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
throw new Error(t("environments.integrations.webhooks.discord_webhook_not_supported"));
}
const endpointHitSuccessfully = await handleTestEndpoint(false);
if (!endpointHitSuccessfully) return;
const testResult = await handleTestEndpoint(false);
if (!testResult.success) return;
const updatedData: TWebhookInput = {
name: data.name,
@@ -141,6 +150,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
const createWebhookActionResult = await createWebhookAction({
environmentId,
webhookInput: updatedData,
webhookSecret: testResult.secret,
});
if (createWebhookActionResult?.data) {
router.refresh();
@@ -167,6 +177,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
setSelectedTriggers([]);
setSelectedAllSurveys(false);
setCreatedWebhook(null);
setWebhookSecret(undefined);
};
// Show success dialog with secret after webhook creation
@@ -58,16 +58,19 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
setTimeout(() => setCopied(false), 2000);
};
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
const handleTestEndpoint = async (sendSuccessToast: boolean): Promise<boolean> => {
try {
const { valid, error } = validWebHookURL(testEndpointInput);
if (!valid) {
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
return;
return false;
}
setHittingEndpoint(true);
const testEndpointActionResult = await testEndpointAction({ url: testEndpointInput });
if (!testEndpointActionResult?.data) {
const testEndpointActionResult = await testEndpointAction({
url: testEndpointInput,
webhookId: webhook.id,
});
if (!testEndpointActionResult?.data?.success) {
const errorMessage = getFormattedErrorMessage(testEndpointActionResult);
throw new Error(errorMessage);
}
@@ -220,7 +223,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
/>
<button
type="button"
className="absolute top-1/2 right-3 -translate-y-1/2 transform"
className="absolute right-3 top-1/2 -translate-y-1/2 transform"
onClick={() => setShowSecret(!showSecret)}>
{showSecret ? (
<EyeOff className="h-5 w-5 text-slate-400" />
@@ -61,19 +61,23 @@ export const deleteWebhook = async (id: string): Promise<boolean> => {
}
};
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
export const createWebhook = async (
environmentId: string,
webhookInput: TWebhookInput,
secret?: string
): Promise<Webhook> => {
try {
if (isDiscordWebhook(webhookInput.url)) {
throw new UnknownError("Discord webhooks are currently not supported.");
}
const secret = generateWebhookSecret();
const signingSecret = secret ?? generateWebhookSecret();
const webhook = await prisma.webhook.create({
data: {
...webhookInput,
surveyIds: webhookInput.surveyIds || [],
secret,
secret: signingSecret,
environment: {
connect: {
id: environmentId,
@@ -118,7 +122,7 @@ export const getWebhooks = async (environmentId: string): Promise<Webhook[]> =>
}
};
export const testEndpoint = async (url: string): Promise<boolean> => {
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
@@ -131,19 +135,25 @@ export const testEndpoint = async (url: string): Promise<boolean> => {
const webhookTimestamp = Math.floor(Date.now() / 1000);
const body = JSON.stringify({ event: "testEndpoint" });
// Generate a temporary test secret and signature for consistency with actual webhooks
const testSecret = generateWebhookSecret();
const signature = generateStandardWebhookSignature(webhookMessageId, webhookTimestamp, body, testSecret);
const requestHeaders: Record<string, string> = {
"Content-Type": "application/json",
"webhook-id": webhookMessageId,
"webhook-timestamp": webhookTimestamp.toString(),
};
if (secret) {
requestHeaders["webhook-signature"] = generateStandardWebhookSignature(
webhookMessageId,
webhookTimestamp,
body,
secret
);
}
const response = await fetch(url, {
method: "POST",
body,
headers: {
"Content-Type": "application/json",
"webhook-id": webhookMessageId,
"webhook-timestamp": webhookTimestamp.toString(),
"webhook-signature": signature,
},
headers: requestHeaders,
signal: controller.signal,
});
clearTimeout(timeout);