From ed5a7d72ab7a3b9f8ac127035bbd93ff5b7ccd3c Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 2 Jul 2024 19:57:58 +0800 Subject: [PATCH] feat: added native slack webhook type --- ...0240702055253_add-encrypted-webhook-url.ts | 49 +++++++ backend/src/db/schemas/webhooks.ts | 6 +- .../src/server/routes/v1/webhook-router.ts | 31 +++-- backend/src/services/webhook/webhook-fns.ts | 125 ++++++++++++++---- .../src/services/webhook/webhook-service.ts | 45 +++++-- backend/src/services/webhook/webhook-types.ts | 6 + .../components/WebhooksTab/AddWebhookForm.tsx | 95 ++++++++++--- 7 files changed, 289 insertions(+), 68 deletions(-) create mode 100644 backend/src/db/migrations/20240702055253_add-encrypted-webhook-url.ts diff --git a/backend/src/db/migrations/20240702055253_add-encrypted-webhook-url.ts b/backend/src/db/migrations/20240702055253_add-encrypted-webhook-url.ts new file mode 100644 index 0000000000..c2ca86d348 --- /dev/null +++ b/backend/src/db/migrations/20240702055253_add-encrypted-webhook-url.ts @@ -0,0 +1,49 @@ +import { Knex } from "knex"; + +import { WebhookType } from "@app/services/webhook/webhook-types"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasEncryptedURL = await knex.schema.hasColumn(TableName.Webhook, "encryptedUrl"); + const hasUrlIV = await knex.schema.hasColumn(TableName.Webhook, "urlIV"); + const hasUrlTag = await knex.schema.hasColumn(TableName.Webhook, "urlTag"); + const hasType = await knex.schema.hasColumn(TableName.Webhook, "type"); + + await knex.schema.alterTable(TableName.Webhook, (tb) => { + if (!hasEncryptedURL) { + tb.text("encryptedUrl"); + } + if (!hasUrlIV) { + tb.string("urlIV"); + } + if (!hasUrlTag) { + tb.string("urlTag"); + } + if (!hasType) { + tb.string("type").defaultTo(WebhookType.GENERAL); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasEncryptedURL = await knex.schema.hasColumn(TableName.Webhook, "encryptedUrl"); + const hasUrlIV = await knex.schema.hasColumn(TableName.Webhook, "urlIV"); + const hasUrlTag = await knex.schema.hasColumn(TableName.Webhook, "urlTag"); + const hasType = await knex.schema.hasColumn(TableName.Webhook, "type"); + + await knex.schema.alterTable(TableName.Webhook, (t) => { + if (hasEncryptedURL) { + t.dropColumn("encryptedUrl"); + } + if (hasUrlIV) { + t.dropColumn("urlIV"); + } + if (hasUrlTag) { + t.dropColumn("urlTag"); + } + if (hasType) { + t.dropColumn("type"); + } + }); +} diff --git a/backend/src/db/schemas/webhooks.ts b/backend/src/db/schemas/webhooks.ts index 44aa8c5da9..49b64662c9 100644 --- a/backend/src/db/schemas/webhooks.ts +++ b/backend/src/db/schemas/webhooks.ts @@ -21,7 +21,11 @@ export const WebhooksSchema = z.object({ keyEncoding: z.string().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), - envId: z.string().uuid() + envId: z.string().uuid(), + encryptedUrl: z.string().nullable().optional(), + urlIV: z.string().nullable().optional(), + urlTag: z.string().nullable().optional(), + type: z.string().default("general").nullable().optional() }); export type TWebhooks = z.infer; diff --git a/backend/src/server/routes/v1/webhook-router.ts b/backend/src/server/routes/v1/webhook-router.ts index 1698c0c4b5..4124d9150e 100644 --- a/backend/src/server/routes/v1/webhook-router.ts +++ b/backend/src/server/routes/v1/webhook-router.ts @@ -6,13 +6,17 @@ import { removeTrailingSlash } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { WebhookType } from "@app/services/webhook/webhook-types"; export const sanitizedWebhookSchema = WebhooksSchema.omit({ encryptedSecretKey: true, iv: true, tag: true, algorithm: true, - keyEncoding: true + keyEncoding: true, + encryptedUrl: true, + urlIV: true, + urlTag: true }).merge( z.object({ projectId: z.string(), @@ -33,13 +37,24 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), schema: { - body: z.object({ - workspaceId: z.string().trim(), - environment: z.string().trim(), - webhookUrl: z.string().url().trim(), - webhookSecretKey: z.string().trim().optional(), - secretPath: z.string().trim().default("/").transform(removeTrailingSlash) - }), + body: z + .object({ + type: z.nativeEnum(WebhookType).default(WebhookType.GENERAL), + workspaceId: z.string().trim(), + environment: z.string().trim(), + webhookUrl: z.string().url().trim(), + webhookSecretKey: z.string().trim().optional(), + secretPath: z.string().trim().default("/").transform(removeTrailingSlash) + }) + .superRefine((data, ctx) => { + if (data.type === WebhookType.SLACK && !data.webhookUrl.includes("hooks.slack.com")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Incoming Webhook URL is invalid.", + path: ["webhookUrl"] + }); + } + }), response: { 200: z.object({ message: z.string(), diff --git a/backend/src/services/webhook/webhook-fns.ts b/backend/src/services/webhook/webhook-fns.ts index 35d2ba7fcf..391d1b3b76 100644 --- a/backend/src/services/webhook/webhook-fns.ts +++ b/backend/src/services/webhook/webhook-fns.ts @@ -12,47 +12,82 @@ import { logger } from "@app/lib/logger"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TWebhookDALFactory } from "./webhook-dal"; +import { WebhookType } from "./webhook-types"; const WEBHOOK_TRIGGER_TIMEOUT = 15 * 1000; -export const triggerWebhookRequest = async ( - { url, encryptedSecretKey, iv, tag, keyEncoding }: TWebhooks, - data: Record -) => { - const headers: Record = {}; - const payload = { ...data, timestamp: Date.now() }; + +export const decryptWebhookDetails = (webhook: TWebhooks) => { const appCfg = getConfig(); + const { keyEncoding, iv, encryptedSecretKey, tag, encryptedUrl, urlIV, urlTag, url } = webhook; - if (encryptedSecretKey) { - const encryptionKey = appCfg.ENCRYPTION_KEY; - const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; - let secretKey; - if (rootEncryptionKey && keyEncoding === SecretKeyEncoding.BASE64) { - // case: encoding scheme is base64 - secretKey = decryptSymmetric({ + const encryptionKey = appCfg.ENCRYPTION_KEY; + const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; + + let decryptedSecretKey = ""; + let decryptedUrl = url; + + if (rootEncryptionKey && keyEncoding === SecretKeyEncoding.BASE64) { + // case: encoding scheme is base64 + if (encryptedSecretKey) { + decryptedSecretKey = decryptSymmetric({ ciphertext: encryptedSecretKey, iv: iv as string, tag: tag as string, key: rootEncryptionKey }); - } else if (encryptionKey && keyEncoding === SecretKeyEncoding.UTF8) { - // case: encoding scheme is utf8 - secretKey = decryptSymmetric128BitHexKeyUTF8({ + } + + if (encryptedUrl) { + decryptedUrl = decryptSymmetric({ + ciphertext: encryptedUrl, + iv: urlIV as string, + tag: urlTag as string, + key: rootEncryptionKey + }); + } + } else if (encryptionKey && keyEncoding === SecretKeyEncoding.UTF8) { + // case: encoding scheme is utf8 + if (encryptedSecretKey) { + decryptedSecretKey = decryptSymmetric128BitHexKeyUTF8({ ciphertext: encryptedSecretKey, iv: iv as string, tag: tag as string, key: encryptionKey }); } - if (secretKey) { - const webhookSign = crypto.createHmac("sha256", secretKey).update(JSON.stringify(payload)).digest("hex"); - headers["x-infisical-signature"] = `t=${payload.timestamp};${webhookSign}`; + + if (encryptedUrl) { + decryptedUrl = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: encryptedUrl, + iv: urlIV as string, + tag: urlTag as string, + key: encryptionKey + }); } } + + return { + secretKey: decryptedSecretKey, + url: decryptedUrl + }; +}; + +export const triggerWebhookRequest = async (webhook: TWebhooks, data: Record) => { + const headers: Record = {}; + const payload = { ...data, timestamp: Date.now() }; + const { secretKey, url } = decryptWebhookDetails(webhook); + + if (secretKey) { + const webhookSign = crypto.createHmac("sha256", secretKey).update(JSON.stringify(payload)).digest("hex"); + headers["x-infisical-signature"] = `t=${payload.timestamp};${webhookSign}`; + } + const req = await request.post(url, payload, { headers, timeout: WEBHOOK_TRIGGER_TIMEOUT, signal: AbortSignal.timeout(WEBHOOK_TRIGGER_TIMEOUT) }); + return req; }; @@ -60,15 +95,48 @@ export const getWebhookPayload = ( eventName: string, workspaceId: string, environment: string, - secretPath?: string -) => ({ - event: eventName, - project: { - workspaceId, - environment, - secretPath + secretPath?: string, + type?: string | null +) => { + switch (type) { + case WebhookType.SLACK: + return { + text: "A secret value has been updated", + attachments: [ + { + color: "#E7F256", + fields: [ + { + title: "Workspace ID", + value: workspaceId, + short: true + }, + { + title: "Environment", + value: environment, + short: true + }, + { + title: "Secret Path", + value: secretPath, + short: true + } + ] + } + ] + }; + case WebhookType.GENERAL: + default: + return { + event: eventName, + project: { + workspaceId, + environment, + secretPath + } + }; } -}); +}; export type TFnTriggerWebhookDTO = { projectId: string; @@ -95,9 +163,10 @@ export const fnTriggerWebhook = async ({ logger.info("Secret webhook job started", { environment, secretPath, projectId }); const webhooksTriggered = await Promise.allSettled( toBeTriggeredHooks.map((hook) => - triggerWebhookRequest(hook, getWebhookPayload("secrets.modified", projectId, environment, secretPath)) + triggerWebhookRequest(hook, getWebhookPayload("secrets.modified", projectId, environment, secretPath, hook.type)) ) ); + // filter hooks by status const successWebhooks = webhooksTriggered .filter(({ status }) => status === "fulfilled") diff --git a/backend/src/services/webhook/webhook-service.ts b/backend/src/services/webhook/webhook-service.ts index 4a05ad219e..acf4e2d750 100644 --- a/backend/src/services/webhook/webhook-service.ts +++ b/backend/src/services/webhook/webhook-service.ts @@ -9,7 +9,7 @@ import { BadRequestError } from "@app/lib/errors"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TWebhookDALFactory } from "./webhook-dal"; -import { getWebhookPayload, triggerWebhookRequest } from "./webhook-fns"; +import { decryptWebhookDetails, getWebhookPayload, triggerWebhookRequest } from "./webhook-fns"; import { TCreateWebhookDTO, TDeleteWebhookDTO, @@ -36,8 +36,13 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer webhookUrl, environment, secretPath, - webhookSecretKey + webhookSecretKey, + type }: TCreateWebhookDTO) => { + const appCfg = getConfig(); + const encryptionKey = appCfg.ENCRYPTION_KEY; + const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -50,15 +55,14 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer if (!env) throw new BadRequestError({ message: "Env not found" }); const insertDoc: TWebhooksInsert = { - url: webhookUrl, + url: "", // deprecated - we are moving away from plaintext URLs envId: env.id, isDisabled: false, - secretPath: secretPath || "/" + secretPath: secretPath || "/", + type }; + if (webhookSecretKey) { - const appCfg = getConfig(); - const encryptionKey = appCfg.ENCRYPTION_KEY; - const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; if (rootEncryptionKey) { const { ciphertext, iv, tag } = encryptSymmetric(webhookSecretKey, rootEncryptionKey); insertDoc.encryptedSecretKey = ciphertext; @@ -76,6 +80,22 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer } } + if (rootEncryptionKey) { + const { ciphertext, iv, tag } = encryptSymmetric(webhookUrl, rootEncryptionKey); + insertDoc.encryptedUrl = ciphertext; + insertDoc.urlIV = iv; + insertDoc.urlTag = tag; + insertDoc.algorithm = SecretEncryptionAlgo.AES_256_GCM; + insertDoc.keyEncoding = SecretKeyEncoding.BASE64; + } else if (encryptionKey) { + const { ciphertext, iv, tag } = encryptSymmetric128BitHexKeyUTF8(webhookUrl, encryptionKey); + insertDoc.encryptedUrl = ciphertext; + insertDoc.urlIV = iv; + insertDoc.urlTag = tag; + insertDoc.algorithm = SecretEncryptionAlgo.AES_256_GCM; + insertDoc.keyEncoding = SecretKeyEncoding.UTF8; + } + const webhook = await webhookDAL.create(insertDoc); return { ...webhook, projectId, environment: env }; }; @@ -131,7 +151,7 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer try { await triggerWebhookRequest( webhook, - getWebhookPayload("test", webhook.projectId, webhook.environment.slug, webhook.secretPath) + getWebhookPayload("test", webhook.projectId, webhook.environment.slug, webhook.secretPath, webhook.type) ); } catch (err) { webhookError = (err as Error).message; @@ -162,7 +182,14 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); - return webhookDAL.findAllWebhooks(projectId, environment, secretPath); + const webhooks = await webhookDAL.findAllWebhooks(projectId, environment, secretPath); + return webhooks.map((w) => { + const { url } = decryptWebhookDetails(w); + return { + ...w, + url + }; + }); }; return { diff --git a/backend/src/services/webhook/webhook-types.ts b/backend/src/services/webhook/webhook-types.ts index 7a6e92c80a..40dacb42ab 100644 --- a/backend/src/services/webhook/webhook-types.ts +++ b/backend/src/services/webhook/webhook-types.ts @@ -5,6 +5,7 @@ export type TCreateWebhookDTO = { secretPath?: string; webhookUrl: string; webhookSecretKey?: string; + type: string; } & TProjectPermission; export type TUpdateWebhookDTO = { @@ -24,3 +25,8 @@ export type TListWebhookDTO = { environment?: string; secretPath?: string; } & TProjectPermission; + +export enum WebhookType { + GENERAL = "general", + SLACK = "slack" +} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/AddWebhookForm.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/AddWebhookForm.tsx index bf60372ef7..5c756bfc29 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/AddWebhookForm.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/AddWebhookForm.tsx @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; +import { zodResolver } from "@hookform/resolvers/zod"; +import z from "zod"; import GlobPatternExamples from "@app/components/basic/popups/GlobPatternExamples"; import { @@ -15,14 +15,30 @@ import { SelectItem } from "@app/components/v2"; -const formSchema = yup.object({ - environment: yup.string().required().trim().label("Environment"), - webhookUrl: yup.string().url().required().trim().label("Webhook URL"), - webhookSecretKey: yup.string().trim().label("Secret Key"), - secretPath: yup.string().required().trim().label("Secret Path") -}); +enum WebhookType { + GENERAL = "general", + SLACK = "slack" +} -export type TFormSchema = yup.InferType; +const formSchema = z + .object({ + environment: z.string().trim().describe("Environment"), + webhookUrl: z.string().url().trim().describe("Webhook URL"), + webhookSecretKey: z.string().trim().optional().describe("Secret Key"), + secretPath: z.string().trim().describe("Secret Path"), + type: z.nativeEnum(WebhookType).describe("Type").default(WebhookType.GENERAL) + }) + .superRefine((data, ctx) => { + if (data.type === WebhookType.SLACK && !data.webhookUrl.includes("hooks.slack.com")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Incoming Webhook URL is invalid.", + path: ["webhookUrl"] + }); + } + }); + +type TFormSchema = z.infer; type Props = { isOpen: boolean; @@ -42,11 +58,17 @@ export const AddWebhookForm = ({ handleSubmit, register, reset, + watch, formState: { errors, isSubmitting } } = useForm({ - resolver: yupResolver(formSchema) + resolver: zodResolver(formSchema), + defaultValues: { + type: WebhookType.GENERAL + } }); + const webhookType = watch("type"); + useEffect(() => { if (!isOpen) { reset(); @@ -58,6 +80,33 @@ export const AddWebhookForm = ({
+ ( + + + + )} + /> + {webhookType === WebhookType.GENERAL && ( + + + + )} - - -