mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-26 11:48:27 -05:00
chore: adds webhook signing to test event (#7320)
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user