From 312676a9d3c812b1bb837e944accb1f14a629077 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:49:33 +0300 Subject: [PATCH 01/16] feat: add hawk-worker-webhook service and update package.json --- docker-compose.dev.yml | 12 +++++ package.json | 7 +-- workers/notifier/types/channel.ts | 1 + workers/webhook/package.json | 11 ++++ workers/webhook/src/deliverer.ts | 53 +++++++++++++++++++ workers/webhook/src/index.ts | 24 +++++++++ workers/webhook/src/provider.ts | 41 ++++++++++++++ workers/webhook/src/templates/event.ts | 38 +++++++++++++ workers/webhook/src/templates/index.ts | 7 +++ .../webhook/src/templates/several-events.ts | 40 ++++++++++++++ workers/webhook/types/template.d.ts | 28 ++++++++++ 11 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 workers/webhook/package.json create mode 100644 workers/webhook/src/deliverer.ts create mode 100644 workers/webhook/src/index.ts create mode 100644 workers/webhook/src/provider.ts create mode 100644 workers/webhook/src/templates/event.ts create mode 100644 workers/webhook/src/templates/index.ts create mode 100644 workers/webhook/src/templates/several-events.ts create mode 100644 workers/webhook/types/template.d.ts diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2be2154e..29766b23 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -158,6 +158,18 @@ services: - ./:/usr/src/app - workers-deps:/usr/src/app/node_modules + hawk-worker-webhook: + build: + dockerfile: "dev.Dockerfile" + context: . + env_file: + - .env + restart: unless-stopped + entrypoint: yarn run-webhook + volumes: + - ./:/usr/src/app + - workers-deps:/usr/src/app/node_modules + volumes: workers-deps: diff --git a/package.json b/package.json index a053c5b8..6fed7e04 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hawk.workers", "private": true, - "version": "0.1.2", + "version": "0.1.3", "description": "Hawk workers", "repository": "git@github.com:codex-team/hawk.workers.git", "license": "BUSL-1.1", @@ -49,13 +49,14 @@ "run-email": "yarn worker hawk-worker-email", "run-telegram": "yarn worker hawk-worker-telegram", "run-limiter": "yarn worker hawk-worker-limiter", - "run-task-manager": "yarn worker hawk-worker-task-manager" + "run-task-manager": "yarn worker hawk-worker-task-manager", + "run-webhook": "yarn worker hawk-worker-webhook" }, "dependencies": { "@babel/parser": "^7.26.9", "@babel/traverse": "7.26.9", "@hawk.so/nodejs": "^3.1.1", - "@hawk.so/types": "^0.5.7", + "@hawk.so/types": "^0.5.9", "@types/amqplib": "^0.8.2", "@types/jest": "^29.5.14", "@types/mongodb": "^3.5.15", diff --git a/workers/notifier/types/channel.ts b/workers/notifier/types/channel.ts index 001a1f27..832d34d3 100644 --- a/workers/notifier/types/channel.ts +++ b/workers/notifier/types/channel.ts @@ -6,6 +6,7 @@ export enum ChannelType { Telegram = 'telegram', Slack = 'slack', Loop = 'loop', + Webhook = 'webhook', } /** diff --git a/workers/webhook/package.json b/workers/webhook/package.json new file mode 100644 index 00000000..167692d6 --- /dev/null +++ b/workers/webhook/package.json @@ -0,0 +1,11 @@ +{ + "name": "hawk-worker-webhook", + "version": "1.0.0", + "description": "Webhook sender worker — delivers event notifications as JSON POST requests", + "main": "src/index.ts", + "license": "MIT", + "workerType": "sender/webhook", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/workers/webhook/src/deliverer.ts b/workers/webhook/src/deliverer.ts new file mode 100644 index 00000000..dfb9dce9 --- /dev/null +++ b/workers/webhook/src/deliverer.ts @@ -0,0 +1,53 @@ +import { createLogger, format, Logger, transports } from 'winston'; +import { WebhookPayload } from '../types/template'; + +/** + * Deliverer sends JSON POST requests to external webhook endpoints + */ +export default class WebhookDeliverer { + /** + * Logger module + * (default level='info') + */ + private logger: Logger = createLogger({ + level: process.env.LOG_LEVEL || 'info', + transports: [ + new transports.Console({ + format: format.combine( + format.timestamp(), + format.colorize(), + format.simple(), + format.printf((msg) => `${msg.timestamp} - ${msg.level}: ${msg.message}`) + ), + }), + ], + }); + + /** + * Sends JSON payload to the webhook endpoint via HTTP POST + * + * @param endpoint - URL to POST to + * @param payload - JSON body to send + */ + public async deliver(endpoint: string, payload: WebhookPayload): Promise { + const body = JSON.stringify(payload); + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Hawk-Webhook/1.0', + }, + body, + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + this.logger.log('error', `Webhook delivery failed: ${response.status} ${response.statusText} for ${endpoint}`); + } + } catch (e) { + this.logger.log('error', `Can't deliver webhook to ${endpoint}: `, e); + } + } +} diff --git a/workers/webhook/src/index.ts b/workers/webhook/src/index.ts new file mode 100644 index 00000000..f826bb02 --- /dev/null +++ b/workers/webhook/src/index.ts @@ -0,0 +1,24 @@ +import * as pkg from './../package.json'; +import WebhookProvider from './provider'; +import SenderWorker from 'hawk-worker-sender/src'; +import { ChannelType } from 'hawk-worker-notifier/types/channel'; + +/** + * Worker to send webhook notifications + */ +export default class WebhookSenderWorker extends SenderWorker { + /** + * Worker type + */ + public readonly type: string = pkg.workerType; + + /** + * Webhook channel type + */ + protected channelType = ChannelType.Webhook; + + /** + * Webhook provider + */ + protected provider = new WebhookProvider(); +} diff --git a/workers/webhook/src/provider.ts b/workers/webhook/src/provider.ts new file mode 100644 index 00000000..e695d93a --- /dev/null +++ b/workers/webhook/src/provider.ts @@ -0,0 +1,41 @@ +import NotificationsProvider from 'hawk-worker-sender/src/provider'; +import { Notification, EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; +import templates from './templates'; +import { WebhookPayload } from '../types/template'; +import WebhookDeliverer from './deliverer'; + +/** + * This class provides a 'send' method that renders and sends a webhook notification + */ +export default class WebhookProvider extends NotificationsProvider { + /** + * Class with the 'deliver' method for sending HTTP POST requests + */ + private readonly deliverer: WebhookDeliverer; + + constructor() { + super(); + + this.deliverer = new WebhookDeliverer(); + } + + /** + * Send webhook notification to recipient + * + * @param to - recipient endpoint URL + * @param notification - notification with payload and type + */ + public async send(to: string, notification: Notification): Promise { + let template: (tplData: EventsTemplateVariables) => WebhookPayload; + + switch (notification.type) { + case 'event': template = templates.EventTpl; break; + case 'several-events': template = templates.SeveralEventsTpl; break; + default: return; + } + + const payload = template(notification.payload as EventsTemplateVariables); + + await this.deliverer.deliver(to, payload); + } +} diff --git a/workers/webhook/src/templates/event.ts b/workers/webhook/src/templates/event.ts new file mode 100644 index 00000000..dcecfde2 --- /dev/null +++ b/workers/webhook/src/templates/event.ts @@ -0,0 +1,38 @@ +import type { EventsTemplateVariables, TemplateEventData } from 'hawk-worker-sender/types/template-variables'; +import { WebhookPayload } from '../../types/template'; + +/** + * Builds webhook JSON payload for a single event notification + * + * @param tplData - event template data + */ +export default function render(tplData: EventsTemplateVariables): WebhookPayload { + const eventInfo = tplData.events[0] as TemplateEventData; + const event = eventInfo.event; + const eventURL = tplData.host + '/project/' + tplData.project._id + '/event/' + event._id + '/'; + + let location: string | null = null; + + if (event.payload.backtrace && event.payload.backtrace.length > 0 && event.payload.backtrace[0].file) { + location = event.payload.backtrace[0].file; + } + + return { + type: 'event', + project: { + id: tplData.project._id.toString(), + name: tplData.project.name, + }, + events: [ + { + id: event._id.toString(), + title: event.payload.title, + newCount: eventInfo.newCount, + totalCount: event.totalCount, + url: eventURL, + location, + daysRepeated: eventInfo.daysRepeated, + }, + ], + }; +} diff --git a/workers/webhook/src/templates/index.ts b/workers/webhook/src/templates/index.ts new file mode 100644 index 00000000..6e23324c --- /dev/null +++ b/workers/webhook/src/templates/index.ts @@ -0,0 +1,7 @@ +import EventTpl from './event'; +import SeveralEventsTpl from './several-events'; + +export default { + EventTpl, + SeveralEventsTpl, +}; diff --git a/workers/webhook/src/templates/several-events.ts b/workers/webhook/src/templates/several-events.ts new file mode 100644 index 00000000..359de01f --- /dev/null +++ b/workers/webhook/src/templates/several-events.ts @@ -0,0 +1,40 @@ +import type { EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; +import { WebhookPayload } from '../../types/template'; + +/** + * Builds webhook JSON payload for a several-events notification + * + * @param tplData - event template data + */ +export default function render(tplData: EventsTemplateVariables): WebhookPayload { + const projectUrl = tplData.host + '/project/' + tplData.project._id; + + return { + type: 'several-events', + project: { + id: tplData.project._id.toString(), + name: tplData.project.name, + url: projectUrl, + }, + events: tplData.events.map(({ event, newCount, daysRepeated }) => { + const eventURL = tplData.host + '/project/' + tplData.project._id + '/event/' + event._id + '/'; + + let location: string | null = null; + + if (event.payload.backtrace && event.payload.backtrace.length > 0 && event.payload.backtrace[0].file) { + location = event.payload.backtrace[0].file; + } + + return { + id: event._id.toString(), + title: event.payload.title, + newCount, + totalCount: event.totalCount, + url: eventURL, + location, + daysRepeated, + }; + }), + period: tplData.period, + }; +} diff --git a/workers/webhook/types/template.d.ts b/workers/webhook/types/template.d.ts new file mode 100644 index 00000000..3db4df65 --- /dev/null +++ b/workers/webhook/types/template.d.ts @@ -0,0 +1,28 @@ +/** + * Shape of the JSON body sent to webhook endpoints + */ +export interface WebhookPayload { + /** Notification type */ + type: 'event' | 'several-events'; + + /** Project info */ + project: { + id: string; + name: string; + url?: string; + }; + + /** List of events in this notification */ + events: Array<{ + id: string; + title: string; + newCount: number; + totalCount: number; + url: string; + location: string | null; + daysRepeated: number; + }>; + + /** Time period in seconds (for several-events) */ + period?: number; +} From 5fff41342ff86ac911881413b963048940d059d6 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:50:44 +0300 Subject: [PATCH 02/16] chore: update @hawk.so/types to version 0.5.9 in yarn.lock --- yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index 2232af0a..3172b2bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -402,10 +402,10 @@ dependencies: "@types/mongodb" "^3.5.34" -"@hawk.so/types@^0.5.7": - version "0.5.7" - resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.5.7.tgz#964af0ad780998820801708292dcb06c390fb9a3" - integrity sha512-ccKVQkFcgTpIe9VVQiz94mCXvGk0Zn6uDyHONlXpS3E0j037eISaw2wUsddzAzKntXYbnFQbxah+zmx35KkVtA== +"@hawk.so/types@^0.5.9": + version "0.5.9" + resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.5.9.tgz#817e8b26283d0367371125f055f2e37a274797bc" + integrity sha512-86aE0Bdzvy8C+Dqd1iZpnDho44zLGX/t92SGuAv2Q52gjSJ7SHQdpGDWtM91FXncfT5uzAizl9jYMuE6Qrtm0Q== dependencies: bson "^7.0.0" From ca0efcaf3df900ace7466b96c75f995d86df7098 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:08:33 +0300 Subject: [PATCH 03/16] refactor: improve webhook delivery mechanism with HTTP/HTTPS support and timeout handling --- workers/webhook/src/deliverer.ts | 57 ++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/workers/webhook/src/deliverer.ts b/workers/webhook/src/deliverer.ts index dfb9dce9..8aa35adf 100644 --- a/workers/webhook/src/deliverer.ts +++ b/workers/webhook/src/deliverer.ts @@ -1,6 +1,13 @@ +import https from 'https'; +import http from 'http'; import { createLogger, format, Logger, transports } from 'winston'; import { WebhookPayload } from '../types/template'; +/** + * Timeout for webhook delivery in milliseconds + */ +const DELIVERY_TIMEOUT_MS = 10000; + /** * Deliverer sends JSON POST requests to external webhook endpoints */ @@ -31,23 +38,45 @@ export default class WebhookDeliverer { */ public async deliver(endpoint: string, payload: WebhookPayload): Promise { const body = JSON.stringify(payload); + const url = new URL(endpoint); + const transport = url.protocol === 'https:' ? https : http; - try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'Hawk-Webhook/1.0', + return new Promise((resolve) => { + const req = transport.request( + url, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Hawk-Webhook/1.0', + 'Content-Length': Buffer.byteLength(body), + }, + timeout: DELIVERY_TIMEOUT_MS, }, - body, - signal: AbortSignal.timeout(10_000), + (res) => { + res.resume(); + + if (res.statusCode && res.statusCode >= 400) { + this.logger.log('error', `Webhook delivery failed: ${res.statusCode} ${res.statusMessage} for ${endpoint}`); + } + + resolve(); + } + ); + + req.on('error', (e) => { + this.logger.log('error', `Can't deliver webhook to ${endpoint}: ${e.message}`); + resolve(); + }); + + req.on('timeout', () => { + this.logger.log('error', `Webhook delivery timed out for ${endpoint}`); + req.destroy(); + resolve(); }); - if (!response.ok) { - this.logger.log('error', `Webhook delivery failed: ${response.status} ${response.statusText} for ${endpoint}`); - } - } catch (e) { - this.logger.log('error', `Can't deliver webhook to ${endpoint}: `, e); - } + req.write(body); + req.end(); + }); } } From 676ca6c7cf15f9e7c5f6e3b2ec1058f2c34e9a26 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:10:35 +0300 Subject: [PATCH 04/16] refactor: replace magic number with constant for HTTP error status in webhook deliverer --- workers/webhook/src/deliverer.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/workers/webhook/src/deliverer.ts b/workers/webhook/src/deliverer.ts index 8aa35adf..604717e2 100644 --- a/workers/webhook/src/deliverer.ts +++ b/workers/webhook/src/deliverer.ts @@ -8,6 +8,11 @@ import { WebhookPayload } from '../types/template'; */ const DELIVERY_TIMEOUT_MS = 10000; +/** + * HTTP status code threshold for error responses + */ +const HTTP_ERROR_STATUS = 400; + /** * Deliverer sends JSON POST requests to external webhook endpoints */ @@ -56,7 +61,7 @@ export default class WebhookDeliverer { (res) => { res.resume(); - if (res.statusCode && res.statusCode >= 400) { + if (res.statusCode && res.statusCode >= HTTP_ERROR_STATUS) { this.logger.log('error', `Webhook delivery failed: ${res.statusCode} ${res.statusMessage} for ${endpoint}`); } From a4bb2cf57e3c02fe6aa648bee6b436280da27cd5 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 17 Feb 2026 04:58:42 +0300 Subject: [PATCH 05/16] feat(webhook): enhance webhook payload structure and add new test command --- package.json | 1 + workers/webhook/src/deliverer.ts | 3 +- workers/webhook/src/provider.ts | 4 +- workers/webhook/src/templates/event.ts | 34 +++----- .../webhook/src/templates/several-events.ts | 23 ++---- .../webhook/tests/__mocks__/event-notify.ts | 43 ++++++++++ .../tests/__mocks__/several-events-notify.ts | 63 ++++++++++++++ workers/webhook/tests/provider.test.ts | 82 +++++++++++++++++++ workers/webhook/types/template.d.ts | 36 +++----- 9 files changed, 224 insertions(+), 65 deletions(-) create mode 100644 workers/webhook/tests/__mocks__/event-notify.ts create mode 100644 workers/webhook/tests/__mocks__/several-events-notify.ts create mode 100644 workers/webhook/tests/provider.test.ts diff --git a/package.json b/package.json index 6fed7e04..175b7bc5 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "test:notifier": "jest workers/notifier", "test:js": "jest workers/javascript", "test:task-manager": "jest workers/task-manager", + "test:webhook": "jest workers/webhook", "test:clear": "jest --clearCache", "run-default": "yarn worker hawk-worker-default", "run-sentry": "yarn worker hawk-worker-sentry", diff --git a/workers/webhook/src/deliverer.ts b/workers/webhook/src/deliverer.ts index 604717e2..821f9dcf 100644 --- a/workers/webhook/src/deliverer.ts +++ b/workers/webhook/src/deliverer.ts @@ -1,7 +1,6 @@ import https from 'https'; import http from 'http'; import { createLogger, format, Logger, transports } from 'winston'; -import { WebhookPayload } from '../types/template'; /** * Timeout for webhook delivery in milliseconds @@ -41,7 +40,7 @@ export default class WebhookDeliverer { * @param endpoint - URL to POST to * @param payload - JSON body to send */ - public async deliver(endpoint: string, payload: WebhookPayload): Promise { + public async deliver(endpoint: string, payload: Record): Promise { const body = JSON.stringify(payload); const url = new URL(endpoint); const transport = url.protocol === 'https:' ? https : http; diff --git a/workers/webhook/src/provider.ts b/workers/webhook/src/provider.ts index e695d93a..7ec648bc 100644 --- a/workers/webhook/src/provider.ts +++ b/workers/webhook/src/provider.ts @@ -1,7 +1,7 @@ import NotificationsProvider from 'hawk-worker-sender/src/provider'; import { Notification, EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; import templates from './templates'; -import { WebhookPayload } from '../types/template'; +import { WebhookTemplate } from '../types/template'; import WebhookDeliverer from './deliverer'; /** @@ -26,7 +26,7 @@ export default class WebhookProvider extends NotificationsProvider { * @param notification - notification with payload and type */ public async send(to: string, notification: Notification): Promise { - let template: (tplData: EventsTemplateVariables) => WebhookPayload; + let template: WebhookTemplate; switch (notification.type) { case 'event': template = templates.EventTpl; break; diff --git a/workers/webhook/src/templates/event.ts b/workers/webhook/src/templates/event.ts index dcecfde2..16fe2e24 100644 --- a/workers/webhook/src/templates/event.ts +++ b/workers/webhook/src/templates/event.ts @@ -1,38 +1,30 @@ import type { EventsTemplateVariables, TemplateEventData } from 'hawk-worker-sender/types/template-variables'; -import { WebhookPayload } from '../../types/template'; /** - * Builds webhook JSON payload for a single event notification + * Builds webhook JSON payload for a single event notification. + * Mirrors the same data structure other workers receive, serialized as JSON. * * @param tplData - event template data */ -export default function render(tplData: EventsTemplateVariables): WebhookPayload { +export default function render(tplData: EventsTemplateVariables): Record { const eventInfo = tplData.events[0] as TemplateEventData; const event = eventInfo.event; const eventURL = tplData.host + '/project/' + tplData.project._id + '/event/' + event._id + '/'; - let location: string | null = null; - - if (event.payload.backtrace && event.payload.backtrace.length > 0 && event.payload.backtrace[0].file) { - location = event.payload.backtrace[0].file; - } - return { - type: 'event', project: { id: tplData.project._id.toString(), name: tplData.project.name, }, - events: [ - { - id: event._id.toString(), - title: event.payload.title, - newCount: eventInfo.newCount, - totalCount: event.totalCount, - url: eventURL, - location, - daysRepeated: eventInfo.daysRepeated, - }, - ], + event: { + id: event._id?.toString() ?? null, + groupHash: event.groupHash, + totalCount: event.totalCount, + newCount: eventInfo.newCount, + daysRepeated: eventInfo.daysRepeated, + url: eventURL, + payload: event.payload, + }, + period: tplData.period, }; } diff --git a/workers/webhook/src/templates/several-events.ts b/workers/webhook/src/templates/several-events.ts index 359de01f..36c861cc 100644 --- a/workers/webhook/src/templates/several-events.ts +++ b/workers/webhook/src/templates/several-events.ts @@ -1,16 +1,15 @@ import type { EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; -import { WebhookPayload } from '../../types/template'; /** - * Builds webhook JSON payload for a several-events notification + * Builds webhook JSON payload for a several-events notification. + * Mirrors the same data structure other workers receive, serialized as JSON. * * @param tplData - event template data */ -export default function render(tplData: EventsTemplateVariables): WebhookPayload { +export default function render(tplData: EventsTemplateVariables): Record { const projectUrl = tplData.host + '/project/' + tplData.project._id; return { - type: 'several-events', project: { id: tplData.project._id.toString(), name: tplData.project.name, @@ -19,20 +18,14 @@ export default function render(tplData: EventsTemplateVariables): WebhookPayload events: tplData.events.map(({ event, newCount, daysRepeated }) => { const eventURL = tplData.host + '/project/' + tplData.project._id + '/event/' + event._id + '/'; - let location: string | null = null; - - if (event.payload.backtrace && event.payload.backtrace.length > 0 && event.payload.backtrace[0].file) { - location = event.payload.backtrace[0].file; - } - return { - id: event._id.toString(), - title: event.payload.title, - newCount, + id: event._id?.toString() ?? null, + groupHash: event.groupHash, totalCount: event.totalCount, - url: eventURL, - location, + newCount, daysRepeated, + url: eventURL, + payload: event.payload, }; }), period: tplData.period, diff --git a/workers/webhook/tests/__mocks__/event-notify.ts b/workers/webhook/tests/__mocks__/event-notify.ts new file mode 100644 index 00000000..77a53b54 --- /dev/null +++ b/workers/webhook/tests/__mocks__/event-notify.ts @@ -0,0 +1,43 @@ +import { EventNotification } from 'hawk-worker-sender/types/template-variables'; +import { ObjectId } from 'mongodb'; + +/** + * Example of new-events notify template variables + */ +export default { + type: 'event', + payload: { + events: [ + { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + }, + daysRepeated: 1, + newCount: 1, + }, + ], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + }, +} as EventNotification; diff --git a/workers/webhook/tests/__mocks__/several-events-notify.ts b/workers/webhook/tests/__mocks__/several-events-notify.ts new file mode 100644 index 00000000..24ed30e8 --- /dev/null +++ b/workers/webhook/tests/__mocks__/several-events-notify.ts @@ -0,0 +1,63 @@ +import { SeveralEventsNotification } from 'hawk-worker-sender/types/template-variables'; +import { GroupedEventDBScheme } from '@hawk.so/types'; +import { ObjectId } from 'mongodb'; + +/** + * Example of several-events notify template variables + */ +export default { + type: 'several-events', + payload: { + events: [ + { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + } as GroupedEventDBScheme, + daysRepeated: 1, + newCount: 1, + }, + { + event: { + totalCount: 5, + payload: { + title: 'New event 2', + timestamp: Date.now(), + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + }, + daysRepeated: 100, + newCount: 1, + }, + ], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + }, +} as SeveralEventsNotification; diff --git a/workers/webhook/tests/provider.test.ts b/workers/webhook/tests/provider.test.ts new file mode 100644 index 00000000..396bbdda --- /dev/null +++ b/workers/webhook/tests/provider.test.ts @@ -0,0 +1,82 @@ +import templates from '../src/templates'; +import EventNotifyMock from './__mocks__/event-notify'; +import SeveralEventsNotifyMock from './__mocks__/several-events-notify'; +import WebhookProvider from '../src/provider'; + +/** + * The sample of a webhook endpoint + */ +const webhookEndpointSample = 'https://example.com/hawk-webhook'; + +/** + * Mock the 'deliver' method of WebhookDeliverer + */ +const deliver = jest.fn(); + +/** + * Webhook Deliverer mock + */ +jest.mock('./../src/deliverer.ts', () => { + return jest.fn().mockImplementation(() => { + /** + * Now we can track calls to 'deliver' + */ + return { + deliver: deliver, + }; + }); +}); + +/** + * Clear all records of mock calls between tests + */ +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('WebhookProvider', () => { + /** + * Check that the 'send' method works without errors + */ + it('The "send" method should render and deliver message', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, EventNotifyMock); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledWith(webhookEndpointSample, expect.anything()); + }); + + /** + * Logic for select the template depended on events count + */ + describe('Select correct template', () => { + /** + * If there is a single event in payload, use the 'event' template + */ + it('Select the event template if there is a single event in notify payload', async () => { + const provider = new WebhookProvider(); + const EventTpl = jest.spyOn(templates, 'EventTpl'); + const SeveralEventsTpl = jest.spyOn(templates, 'SeveralEventsTpl'); + + await provider.send(webhookEndpointSample, EventNotifyMock); + + expect(EventTpl).toHaveBeenCalledTimes(1); + expect(SeveralEventsTpl).toHaveBeenCalledTimes(0); + }); + + /** + * If there are several events in payload, use the 'several-events' template + */ + it('Select the several-events template if there are several events in notify payload', async () => { + const provider = new WebhookProvider(); + const EventTpl = jest.spyOn(templates, 'EventTpl'); + const SeveralEventsTpl = jest.spyOn(templates, 'SeveralEventsTpl'); + + await provider.send(webhookEndpointSample, SeveralEventsNotifyMock); + + expect(EventTpl).toHaveBeenCalledTimes(0); + expect(SeveralEventsTpl).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/workers/webhook/types/template.d.ts b/workers/webhook/types/template.d.ts index 3db4df65..13d4286c 100644 --- a/workers/webhook/types/template.d.ts +++ b/workers/webhook/types/template.d.ts @@ -1,28 +1,14 @@ +import type { EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; + /** - * Shape of the JSON body sent to webhook endpoints + * Webhook templates should implement this interface. + * Returns a JSON-serializable representation of EventsTemplateVariables. */ -export interface WebhookPayload { - /** Notification type */ - type: 'event' | 'several-events'; - - /** Project info */ - project: { - id: string; - name: string; - url?: string; - }; - - /** List of events in this notification */ - events: Array<{ - id: string; - title: string; - newCount: number; - totalCount: number; - url: string; - location: string | null; - daysRepeated: number; - }>; - - /** Time period in seconds (for several-events) */ - period?: number; +export interface WebhookTemplate { + /** + * Rendering method that accepts tpl args and returns a JSON-serializable object + * + * @param tplData - template variables + */ + (tplData: EventsTemplateVariables): Record; } From 1f21746574460b6165ff92500bd736e05f09767c Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:31:56 +0300 Subject: [PATCH 06/16] feat(webhook): unify webhook delivery structure and remove deprecated templates --- workers/webhook/src/deliverer.ts | 11 +- workers/webhook/src/provider.ts | 21 +-- workers/webhook/src/templates/event.ts | 30 ----- workers/webhook/src/templates/generic.ts | 55 ++++++++ workers/webhook/src/templates/index.ts | 8 +- .../webhook/src/templates/several-events.ts | 33 ----- .../tests/__mocks__/assignee-notify.ts | 33 +++++ workers/webhook/tests/provider.test.ts | 121 ++++++++++++------ workers/webhook/types/template.d.ts | 19 ++- 9 files changed, 192 insertions(+), 139 deletions(-) delete mode 100644 workers/webhook/src/templates/event.ts create mode 100644 workers/webhook/src/templates/generic.ts delete mode 100644 workers/webhook/src/templates/several-events.ts create mode 100644 workers/webhook/tests/__mocks__/assignee-notify.ts diff --git a/workers/webhook/src/deliverer.ts b/workers/webhook/src/deliverer.ts index 821f9dcf..d2f3cbce 100644 --- a/workers/webhook/src/deliverer.ts +++ b/workers/webhook/src/deliverer.ts @@ -1,6 +1,7 @@ import https from 'https'; import http from 'http'; import { createLogger, format, Logger, transports } from 'winston'; +import { WebhookDelivery } from '../types/template'; /** * Timeout for webhook delivery in milliseconds @@ -35,13 +36,14 @@ export default class WebhookDeliverer { }); /** - * Sends JSON payload to the webhook endpoint via HTTP POST + * Sends webhook delivery to the endpoint via HTTP POST. + * Adds X-Hawk-Notification header with the notification type (similar to GitHub's X-GitHub-Event). * * @param endpoint - URL to POST to - * @param payload - JSON body to send + * @param delivery - webhook delivery { type, payload } */ - public async deliver(endpoint: string, payload: Record): Promise { - const body = JSON.stringify(payload); + public async deliver(endpoint: string, delivery: WebhookDelivery): Promise { + const body = JSON.stringify(delivery); const url = new URL(endpoint); const transport = url.protocol === 'https:' ? https : http; @@ -53,6 +55,7 @@ export default class WebhookDeliverer { headers: { 'Content-Type': 'application/json', 'User-Agent': 'Hawk-Webhook/1.0', + 'X-Hawk-Notification': delivery.type, 'Content-Length': Buffer.byteLength(body), }, timeout: DELIVERY_TIMEOUT_MS, diff --git a/workers/webhook/src/provider.ts b/workers/webhook/src/provider.ts index 7ec648bc..f894141e 100644 --- a/workers/webhook/src/provider.ts +++ b/workers/webhook/src/provider.ts @@ -1,11 +1,12 @@ import NotificationsProvider from 'hawk-worker-sender/src/provider'; -import { Notification, EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; -import templates from './templates'; -import { WebhookTemplate } from '../types/template'; +import { Notification } from 'hawk-worker-sender/types/template-variables'; +import { toDelivery } from './templates'; import WebhookDeliverer from './deliverer'; /** - * This class provides a 'send' method that renders and sends a webhook notification + * Webhook notification provider. + * Supports all notification types via a single generic serializer — + * type comes from notification.type, payload is sanitized automatically. */ export default class WebhookProvider extends NotificationsProvider { /** @@ -26,16 +27,8 @@ export default class WebhookProvider extends NotificationsProvider { * @param notification - notification with payload and type */ public async send(to: string, notification: Notification): Promise { - let template: WebhookTemplate; + const delivery = toDelivery(notification); - switch (notification.type) { - case 'event': template = templates.EventTpl; break; - case 'several-events': template = templates.SeveralEventsTpl; break; - default: return; - } - - const payload = template(notification.payload as EventsTemplateVariables); - - await this.deliverer.deliver(to, payload); + await this.deliverer.deliver(to, delivery); } } diff --git a/workers/webhook/src/templates/event.ts b/workers/webhook/src/templates/event.ts deleted file mode 100644 index 16fe2e24..00000000 --- a/workers/webhook/src/templates/event.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { EventsTemplateVariables, TemplateEventData } from 'hawk-worker-sender/types/template-variables'; - -/** - * Builds webhook JSON payload for a single event notification. - * Mirrors the same data structure other workers receive, serialized as JSON. - * - * @param tplData - event template data - */ -export default function render(tplData: EventsTemplateVariables): Record { - const eventInfo = tplData.events[0] as TemplateEventData; - const event = eventInfo.event; - const eventURL = tplData.host + '/project/' + tplData.project._id + '/event/' + event._id + '/'; - - return { - project: { - id: tplData.project._id.toString(), - name: tplData.project.name, - }, - event: { - id: event._id?.toString() ?? null, - groupHash: event.groupHash, - totalCount: event.totalCount, - newCount: eventInfo.newCount, - daysRepeated: eventInfo.daysRepeated, - url: eventURL, - payload: event.payload, - }, - period: tplData.period, - }; -} diff --git a/workers/webhook/src/templates/generic.ts b/workers/webhook/src/templates/generic.ts new file mode 100644 index 00000000..4255f8b6 --- /dev/null +++ b/workers/webhook/src/templates/generic.ts @@ -0,0 +1,55 @@ +import { Notification } from 'hawk-worker-sender/types/template-variables'; +import { WebhookDelivery } from '../../types/template'; + +/** + * List of internal fields that should not be exposed in webhook payload + */ +const INTERNAL_FIELDS = new Set(['host', 'hostOfStatic']); + +/** + * Recursively converts MongoDB ObjectIds and other non-JSON-safe values to strings + * + * @param value - any value to sanitize + */ +function sanitize(value: unknown): unknown { + if (value === null || value === undefined) { + return value; + } + + if (typeof value === 'object' && '_bsontype' in (value as Record)) { + return String(value); + } + + if (Array.isArray(value)) { + return value.map(sanitize); + } + + if (typeof value === 'object') { + const result: Record = {}; + + for (const [k, v] of Object.entries(value as Record)) { + if (!INTERNAL_FIELDS.has(k)) { + result[k] = sanitize(v); + } + } + + return result; + } + + return value; +} + +/** + * Generic webhook template — handles any notification type + * by passing through the sanitized payload as-is. + * + * Used as a fallback when no curated template exists for the notification type. + * + * @param notification - notification with type and payload + */ +export default function render(notification: Notification): WebhookDelivery { + return { + type: notification.type, + payload: sanitize(notification.payload) as Record, + }; +} diff --git a/workers/webhook/src/templates/index.ts b/workers/webhook/src/templates/index.ts index 6e23324c..cce548d0 100644 --- a/workers/webhook/src/templates/index.ts +++ b/workers/webhook/src/templates/index.ts @@ -1,7 +1 @@ -import EventTpl from './event'; -import SeveralEventsTpl from './several-events'; - -export default { - EventTpl, - SeveralEventsTpl, -}; +export { default as toDelivery } from './generic'; diff --git a/workers/webhook/src/templates/several-events.ts b/workers/webhook/src/templates/several-events.ts deleted file mode 100644 index 36c861cc..00000000 --- a/workers/webhook/src/templates/several-events.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; - -/** - * Builds webhook JSON payload for a several-events notification. - * Mirrors the same data structure other workers receive, serialized as JSON. - * - * @param tplData - event template data - */ -export default function render(tplData: EventsTemplateVariables): Record { - const projectUrl = tplData.host + '/project/' + tplData.project._id; - - return { - project: { - id: tplData.project._id.toString(), - name: tplData.project.name, - url: projectUrl, - }, - events: tplData.events.map(({ event, newCount, daysRepeated }) => { - const eventURL = tplData.host + '/project/' + tplData.project._id + '/event/' + event._id + '/'; - - return { - id: event._id?.toString() ?? null, - groupHash: event.groupHash, - totalCount: event.totalCount, - newCount, - daysRepeated, - url: eventURL, - payload: event.payload, - }; - }), - period: tplData.period, - }; -} diff --git a/workers/webhook/tests/__mocks__/assignee-notify.ts b/workers/webhook/tests/__mocks__/assignee-notify.ts new file mode 100644 index 00000000..086f557b --- /dev/null +++ b/workers/webhook/tests/__mocks__/assignee-notify.ts @@ -0,0 +1,33 @@ +import { AssigneeNotification } from 'hawk-worker-sender/types/template-variables'; +import { ObjectId } from 'mongodb'; + +/** + * Example of assignee notify template variables + */ +export default { + type: 'assignee', + payload: { + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + event: { + totalCount: 5, + groupHash: 'abc123', + payload: { + title: 'TypeError: Cannot read property', + }, + }, + whoAssigned: { + name: 'John Doe', + email: 'john@example.com', + }, + daysRepeated: 3, + }, +} as AssigneeNotification; diff --git a/workers/webhook/tests/provider.test.ts b/workers/webhook/tests/provider.test.ts index 396bbdda..6d787032 100644 --- a/workers/webhook/tests/provider.test.ts +++ b/workers/webhook/tests/provider.test.ts @@ -1,6 +1,6 @@ -import templates from '../src/templates'; import EventNotifyMock from './__mocks__/event-notify'; import SeveralEventsNotifyMock from './__mocks__/several-events-notify'; +import AssigneeNotifyMock from './__mocks__/assignee-notify'; import WebhookProvider from '../src/provider'; /** @@ -18,9 +18,6 @@ const deliver = jest.fn(); */ jest.mock('./../src/deliverer.ts', () => { return jest.fn().mockImplementation(() => { - /** - * Now we can track calls to 'deliver' - */ return { deliver: deliver, }; @@ -35,48 +32,92 @@ afterEach(() => { }); describe('WebhookProvider', () => { - /** - * Check that the 'send' method works without errors - */ - it('The "send" method should render and deliver message', async () => { + it('should deliver a message with { type, payload } structure', async () => { const provider = new WebhookProvider(); await provider.send(webhookEndpointSample, EventNotifyMock); expect(deliver).toHaveBeenCalledTimes(1); - expect(deliver).toHaveBeenCalledWith(webhookEndpointSample, expect.anything()); + expect(deliver).toHaveBeenCalledWith(webhookEndpointSample, expect.objectContaining({ + type: 'event', + payload: expect.any(Object), + })); }); - /** - * Logic for select the template depended on events count - */ - describe('Select correct template', () => { - /** - * If there is a single event in payload, use the 'event' template - */ - it('Select the event template if there is a single event in notify payload', async () => { - const provider = new WebhookProvider(); - const EventTpl = jest.spyOn(templates, 'EventTpl'); - const SeveralEventsTpl = jest.spyOn(templates, 'SeveralEventsTpl'); - - await provider.send(webhookEndpointSample, EventNotifyMock); - - expect(EventTpl).toHaveBeenCalledTimes(1); - expect(SeveralEventsTpl).toHaveBeenCalledTimes(0); - }); - - /** - * If there are several events in payload, use the 'several-events' template - */ - it('Select the several-events template if there are several events in notify payload', async () => { - const provider = new WebhookProvider(); - const EventTpl = jest.spyOn(templates, 'EventTpl'); - const SeveralEventsTpl = jest.spyOn(templates, 'SeveralEventsTpl'); - - await provider.send(webhookEndpointSample, SeveralEventsNotifyMock); - - expect(EventTpl).toHaveBeenCalledTimes(0); - expect(SeveralEventsTpl).toHaveBeenCalledTimes(1); - }); + it('should preserve notification type in delivery', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, EventNotifyMock); + expect(deliver.mock.calls[0][1].type).toBe('event'); + + deliver.mockClear(); + + await provider.send(webhookEndpointSample, SeveralEventsNotifyMock); + expect(deliver.mock.calls[0][1].type).toBe('several-events'); + + deliver.mockClear(); + + await provider.send(webhookEndpointSample, AssigneeNotifyMock); + expect(deliver.mock.calls[0][1].type).toBe('assignee'); + }); + + it('should strip internal fields (host, hostOfStatic) from payload', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'payment-failed', + payload: { + host: 'https://garage.hawk.so', + hostOfStatic: 'https://api.hawk.so', + workspace: { name: 'Workspace' }, + reason: 'Insufficient funds', + }, + } as any); + + const delivery = deliver.mock.calls[0][1]; + + expect(delivery.payload).not.toHaveProperty('host'); + expect(delivery.payload).not.toHaveProperty('hostOfStatic'); + expect(delivery.payload).toHaveProperty('reason', 'Insufficient funds'); + }); + + it('should handle all known notification types without throwing', async () => { + const provider = new WebhookProvider(); + + const types = [ + 'event', + 'several-events', + 'assignee', + 'block-workspace', + 'blocked-workspace-reminder', + 'payment-failed', + 'payment-success', + 'days-limit-almost-reached', + 'events-limit-almost-reached', + 'sign-up', + 'password-reset', + 'workspace-invite', + ]; + + for (const type of types) { + await expect( + provider.send(webhookEndpointSample, { + type, + payload: { host: 'h', hostOfStatic: 's' }, + } as any) + ).resolves.toBeUndefined(); + } + + expect(deliver).toHaveBeenCalledTimes(types.length); + }); + + it('should only have { type, payload } keys at root level', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, EventNotifyMock); + + const delivery = deliver.mock.calls[0][1]; + + expect(Object.keys(delivery).sort()).toEqual(['payload', 'type']); }); }); diff --git a/workers/webhook/types/template.d.ts b/workers/webhook/types/template.d.ts index 13d4286c..a2d06046 100644 --- a/workers/webhook/types/template.d.ts +++ b/workers/webhook/types/template.d.ts @@ -1,14 +1,11 @@ -import type { EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; - /** - * Webhook templates should implement this interface. - * Returns a JSON-serializable representation of EventsTemplateVariables. + * Unified root-level structure for all webhook deliveries. + * Every webhook POST body has the same shape: { type, payload }. */ -export interface WebhookTemplate { - /** - * Rendering method that accepts tpl args and returns a JSON-serializable object - * - * @param tplData - template variables - */ - (tplData: EventsTemplateVariables): Record; +export interface WebhookDelivery { + /** Notification type (e.g. 'event', 'several-events', 'assignee', 'payment-failed', ...) */ + type: string; + + /** Notification-specific payload — structure depends on the type */ + payload: Record; } From c89717b6f68d56b6b457c44644089955f9cb95ed Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:31:16 +0300 Subject: [PATCH 07/16] feat(webhook): expand internal fields filtering in webhook payload --- workers/webhook/src/templates/generic.ts | 13 +++++++++-- workers/webhook/tests/provider.test.ts | 29 ++++++++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/workers/webhook/src/templates/generic.ts b/workers/webhook/src/templates/generic.ts index 4255f8b6..4de47ca6 100644 --- a/workers/webhook/src/templates/generic.ts +++ b/workers/webhook/src/templates/generic.ts @@ -2,9 +2,18 @@ import { Notification } from 'hawk-worker-sender/types/template-variables'; import { WebhookDelivery } from '../../types/template'; /** - * List of internal fields that should not be exposed in webhook payload + * Internal/sensitive fields stripped from webhook payload at any nesting level */ -const INTERNAL_FIELDS = new Set(['host', 'hostOfStatic']); +const INTERNAL_FIELDS = new Set([ + 'host', + 'hostOfStatic', + 'token', + 'notifications', + 'integrationId', + 'notificationRuleId', + 'visitedBy', + 'uidAdded', +]); /** * Recursively converts MongoDB ObjectIds and other non-JSON-safe values to strings diff --git a/workers/webhook/tests/provider.test.ts b/workers/webhook/tests/provider.test.ts index 6d787032..2e07fbd3 100644 --- a/workers/webhook/tests/provider.test.ts +++ b/workers/webhook/tests/provider.test.ts @@ -61,16 +61,28 @@ describe('WebhookProvider', () => { expect(deliver.mock.calls[0][1].type).toBe('assignee'); }); - it('should strip internal fields (host, hostOfStatic) from payload', async () => { + it('should strip all internal/sensitive fields from payload', async () => { const provider = new WebhookProvider(); await provider.send(webhookEndpointSample, { - type: 'payment-failed', + type: 'event', payload: { host: 'https://garage.hawk.so', hostOfStatic: 'https://api.hawk.so', - workspace: { name: 'Workspace' }, - reason: 'Insufficient funds', + notificationRuleId: '123', + project: { + name: 'My Project', + token: 'secret-token', + integrationId: 'uuid', + uidAdded: 'user-id', + notifications: [{ rule: 'data' }], + }, + events: [{ + event: { + groupHash: 'abc', + visitedBy: ['user1'], + }, + }], }, } as any); @@ -78,7 +90,14 @@ describe('WebhookProvider', () => { expect(delivery.payload).not.toHaveProperty('host'); expect(delivery.payload).not.toHaveProperty('hostOfStatic'); - expect(delivery.payload).toHaveProperty('reason', 'Insufficient funds'); + expect(delivery.payload).not.toHaveProperty('notificationRuleId'); + expect(delivery.payload.project).not.toHaveProperty('token'); + expect(delivery.payload.project).not.toHaveProperty('integrationId'); + expect(delivery.payload.project).not.toHaveProperty('uidAdded'); + expect(delivery.payload.project).not.toHaveProperty('notifications'); + expect(delivery.payload.project).toHaveProperty('name', 'My Project'); + expect((delivery.payload.events as any[])[0].event).not.toHaveProperty('visitedBy'); + expect((delivery.payload.events as any[])[0].event).toHaveProperty('groupHash', 'abc'); }); it('should handle all known notification types without throwing', async () => { From 8acee1459de64d6e24222e4331658aa3ec5f954c Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:28:00 +0300 Subject: [PATCH 08/16] feat(webhook): add private IP checks and DNS validation for webhook delivery --- workers/webhook/src/deliverer.ts | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/workers/webhook/src/deliverer.ts b/workers/webhook/src/deliverer.ts index d2f3cbce..1ebee800 100644 --- a/workers/webhook/src/deliverer.ts +++ b/workers/webhook/src/deliverer.ts @@ -1,5 +1,6 @@ import https from 'https'; import http from 'http'; +import dns from 'dns'; import { createLogger, format, Logger, transports } from 'winston'; import { WebhookDelivery } from '../types/template'; @@ -13,6 +14,36 @@ const DELIVERY_TIMEOUT_MS = 10000; */ const HTTP_ERROR_STATUS = 400; +/** + * Checks whether an IPv4 or IPv6 address belongs to a private/reserved range. + * Blocks loopback, link-local, RFC1918, metadata IPs and IPv6 equivalents. + */ +function isPrivateIP(ip: string): boolean { + const parts = ip.split('.').map(Number); + + if (parts.length === 4 && parts.every((p) => p >= 0 && p <= 255)) { + return ( + parts[0] === 127 || + parts[0] === 10 || + parts[0] === 0 || + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 169 && parts[1] === 254) || + (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) + ); + } + + const lower = ip.toLowerCase(); + + return ( + lower === '::1' || + lower.startsWith('fe80') || + lower.startsWith('fc') || + lower.startsWith('fd') || + lower === '::') + ; +} + /** * Deliverer sends JSON POST requests to external webhook endpoints */ @@ -45,6 +76,35 @@ export default class WebhookDeliverer { public async deliver(endpoint: string, delivery: WebhookDelivery): Promise { const body = JSON.stringify(delivery); const url = new URL(endpoint); + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + this.logger.log('error', `Webhook blocked — unsupported protocol: ${url.protocol} for ${endpoint}`); + + return; + } + + const hostname = url.hostname; + + if (isPrivateIP(hostname)) { + this.logger.log('error', `Webhook blocked — private IP in URL: ${endpoint}`); + + return; + } + + try { + const { address } = await dns.promises.lookup(hostname); + + if (isPrivateIP(address)) { + this.logger.log('error', `Webhook blocked — ${hostname} resolves to private IP ${address}`); + + return; + } + } catch (e) { + this.logger.log('error', `Webhook blocked — DNS lookup failed for ${hostname}: ${(e as Error).message}`); + + return; + } + const transport = url.protocol === 'https:' ? https : http; return new Promise((resolve) => { From 8973e26f6d23c0b119174fade9f0d67af1281212 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:41:22 +0300 Subject: [PATCH 09/16] feat(webhook): implement regex-based private IP checks and enhance notification type handling in tests --- workers/webhook/src/deliverer.ts | 47 ++++--- workers/webhook/tests/deliverer.test.ts | 41 ++++++ workers/webhook/tests/provider.test.ts | 161 +++++++++++++++--------- 3 files changed, 169 insertions(+), 80 deletions(-) create mode 100644 workers/webhook/tests/deliverer.test.ts diff --git a/workers/webhook/src/deliverer.ts b/workers/webhook/src/deliverer.ts index 1ebee800..f5c191d5 100644 --- a/workers/webhook/src/deliverer.ts +++ b/workers/webhook/src/deliverer.ts @@ -14,34 +14,33 @@ const DELIVERY_TIMEOUT_MS = 10000; */ const HTTP_ERROR_STATUS = 400; +/** + * Regex patterns matching private/reserved IP ranges. + * Covers: 0.x, 10.x, 127.x, 169.254.x, 172.16-31.x, 192.168.x, 100.64-127.x (IPv4) + * and ::1, ::, fe80::, fc/fd ULA (IPv6). + */ +const PRIVATE_IP_PATTERNS: RegExp[] = [ + /^0\./, + /^10\./, + /^127\./, + /^169\.254\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^192\.168\./, + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, + /^::1$/, + /^::$/, + /^fe80/i, + /^f[cd]/i, +]; + /** * Checks whether an IPv4 or IPv6 address belongs to a private/reserved range. * Blocks loopback, link-local, RFC1918, metadata IPs and IPv6 equivalents. + * + * @param ip - IP address string (v4 or v6) */ -function isPrivateIP(ip: string): boolean { - const parts = ip.split('.').map(Number); - - if (parts.length === 4 && parts.every((p) => p >= 0 && p <= 255)) { - return ( - parts[0] === 127 || - parts[0] === 10 || - parts[0] === 0 || - (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || - (parts[0] === 192 && parts[1] === 168) || - (parts[0] === 169 && parts[1] === 254) || - (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) - ); - } - - const lower = ip.toLowerCase(); - - return ( - lower === '::1' || - lower.startsWith('fe80') || - lower.startsWith('fc') || - lower.startsWith('fd') || - lower === '::') - ; +export function isPrivateIP(ip: string): boolean { + return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(ip)); } /** diff --git a/workers/webhook/tests/deliverer.test.ts b/workers/webhook/tests/deliverer.test.ts new file mode 100644 index 00000000..56679ce2 --- /dev/null +++ b/workers/webhook/tests/deliverer.test.ts @@ -0,0 +1,41 @@ +import { isPrivateIP } from '../src/deliverer'; + +describe('isPrivateIP', () => { + it.each([ + ['127.0.0.1', true], + ['127.255.255.255', true], + ['10.0.0.1', true], + ['10.255.255.255', true], + ['0.0.0.0', true], + ['172.16.0.1', true], + ['172.31.255.255', true], + ['192.168.0.1', true], + ['192.168.255.255', true], + ['169.254.1.1', true], + ['169.254.169.254', true], + ['100.64.0.1', true], + ['100.127.255.255', true], + ['::1', true], + ['::', true], + ['fe80::1', true], + ['fc00::1', true], + ['fd12:3456::1', true], + ])('should block private/reserved IP %s', (ip: string, expected: boolean) => { + expect(isPrivateIP(ip)).toBe(expected); + }); + + it.each([ + ['8.8.8.8', false], + ['1.1.1.1', false], + ['93.184.216.34', false], + ['172.32.0.1', false], + ['172.15.255.255', false], + ['192.169.0.1', false], + ['100.128.0.1', false], + ['100.63.255.255', false], + ['169.255.0.1', false], + ['2001:db8::1', false], + ])('should allow public IP %s', (ip: string, expected: boolean) => { + expect(isPrivateIP(ip)).toBe(expected); + }); +}); diff --git a/workers/webhook/tests/provider.test.ts b/workers/webhook/tests/provider.test.ts index 2e07fbd3..33afe11e 100644 --- a/workers/webhook/tests/provider.test.ts +++ b/workers/webhook/tests/provider.test.ts @@ -2,6 +2,7 @@ import EventNotifyMock from './__mocks__/event-notify'; import SeveralEventsNotifyMock from './__mocks__/several-events-notify'; import AssigneeNotifyMock from './__mocks__/assignee-notify'; import WebhookProvider from '../src/provider'; +import { Notification } from 'hawk-worker-sender/types/template-variables'; /** * The sample of a webhook endpoint @@ -17,11 +18,19 @@ const deliver = jest.fn(); * Webhook Deliverer mock */ jest.mock('./../src/deliverer.ts', () => { - return jest.fn().mockImplementation(() => { + const original = jest.requireActual('./../src/deliverer.ts'); + + const MockDeliverer = jest.fn().mockImplementation(() => { return { deliver: deliver, }; }); + + return { + __esModule: true, + default: MockDeliverer, + isPrivateIP: original.isPrivateIP, + }; }); /** @@ -31,6 +40,24 @@ afterEach(() => { jest.clearAllMocks(); }); +/** + * All notification types supported by the system + */ +const ALL_NOTIFICATION_TYPES = [ + 'event', + 'several-events', + 'assignee', + 'block-workspace', + 'blocked-workspace-reminder', + 'payment-failed', + 'payment-success', + 'days-limit-almost-reached', + 'events-limit-almost-reached', + 'sign-up', + 'password-reset', + 'workspace-invite', +] as const; + describe('WebhookProvider', () => { it('should deliver a message with { type, payload } structure', async () => { const provider = new WebhookProvider(); @@ -44,21 +71,44 @@ describe('WebhookProvider', () => { })); }); - it('should preserve notification type in delivery', async () => { + it('should only have { type, payload } keys at root level', async () => { const provider = new WebhookProvider(); await provider.send(webhookEndpointSample, EventNotifyMock); - expect(deliver.mock.calls[0][1].type).toBe('event'); - deliver.mockClear(); + const delivery = deliver.mock.calls[0][1]; + + expect(Object.keys(delivery).sort()).toEqual(['payload', 'type']); + }); - await provider.send(webhookEndpointSample, SeveralEventsNotifyMock); - expect(deliver.mock.calls[0][1].type).toBe('several-events'); + it.each([ + ['event', EventNotifyMock], + ['several-events', SeveralEventsNotifyMock], + ['assignee', AssigneeNotifyMock], + ] as const)('should preserve type "%s" from mock notification', async (expectedType, mock) => { + const provider = new WebhookProvider(); - deliver.mockClear(); + await provider.send(webhookEndpointSample, mock); - await provider.send(webhookEndpointSample, AssigneeNotifyMock); - expect(deliver.mock.calls[0][1].type).toBe('assignee'); + expect(deliver.mock.calls[0][1].type).toBe(expectedType); + }); + + it.each( + ALL_NOTIFICATION_TYPES.map((type) => [type]) + )('should handle "%s" notification without throwing', async (type) => { + const provider = new WebhookProvider(); + + await expect( + provider.send(webhookEndpointSample, { + type, + payload: { projectName: 'Test' }, + } as unknown as Notification) + ).resolves.toBeUndefined(); + + const delivery = deliver.mock.calls[0][1]; + + expect(delivery.type).toBe(type); + expect(delivery.payload).toBeDefined(); }); it('should strip all internal/sensitive fields from payload', async () => { @@ -84,59 +134,58 @@ describe('WebhookProvider', () => { }, }], }, - } as any); + } as unknown as Notification); const delivery = deliver.mock.calls[0][1]; - - expect(delivery.payload).not.toHaveProperty('host'); - expect(delivery.payload).not.toHaveProperty('hostOfStatic'); - expect(delivery.payload).not.toHaveProperty('notificationRuleId'); - expect(delivery.payload.project).not.toHaveProperty('token'); - expect(delivery.payload.project).not.toHaveProperty('integrationId'); - expect(delivery.payload.project).not.toHaveProperty('uidAdded'); - expect(delivery.payload.project).not.toHaveProperty('notifications'); - expect(delivery.payload.project).toHaveProperty('name', 'My Project'); - expect((delivery.payload.events as any[])[0].event).not.toHaveProperty('visitedBy'); - expect((delivery.payload.events as any[])[0].event).toHaveProperty('groupHash', 'abc'); + const payload = delivery.payload as Record; + const project = payload.project as Record; + const events = payload.events as Array>>; + + expect(payload).not.toHaveProperty('host'); + expect(payload).not.toHaveProperty('hostOfStatic'); + expect(payload).not.toHaveProperty('notificationRuleId'); + expect(project).not.toHaveProperty('token'); + expect(project).not.toHaveProperty('integrationId'); + expect(project).not.toHaveProperty('uidAdded'); + expect(project).not.toHaveProperty('notifications'); + expect(project).toHaveProperty('name', 'My Project'); + expect(events[0].event).not.toHaveProperty('visitedBy'); + expect(events[0].event).toHaveProperty('groupHash', 'abc'); }); - it('should handle all known notification types without throwing', async () => { + it('should strip internal fields from all notification types', async () => { const provider = new WebhookProvider(); + const sensitivePayload = { + host: 'h', + hostOfStatic: 's', + token: 'secret', + notifications: [], + integrationId: 'id', + notificationRuleId: 'rid', + visitedBy: ['u1'], + uidAdded: 'uid', + safeField: 'keep-me', + }; - const types = [ - 'event', - 'several-events', - 'assignee', - 'block-workspace', - 'blocked-workspace-reminder', - 'payment-failed', - 'payment-success', - 'days-limit-almost-reached', - 'events-limit-almost-reached', - 'sign-up', - 'password-reset', - 'workspace-invite', - ]; - - for (const type of types) { - await expect( - provider.send(webhookEndpointSample, { - type, - payload: { host: 'h', hostOfStatic: 's' }, - } as any) - ).resolves.toBeUndefined(); + for (const type of ALL_NOTIFICATION_TYPES) { + deliver.mockClear(); + + await provider.send(webhookEndpointSample, { + type, + payload: { ...sensitivePayload }, + } as unknown as Notification); + + const payload = deliver.mock.calls[0][1].payload as Record; + + expect(payload).not.toHaveProperty('host'); + expect(payload).not.toHaveProperty('hostOfStatic'); + expect(payload).not.toHaveProperty('token'); + expect(payload).not.toHaveProperty('notifications'); + expect(payload).not.toHaveProperty('integrationId'); + expect(payload).not.toHaveProperty('notificationRuleId'); + expect(payload).not.toHaveProperty('visitedBy'); + expect(payload).not.toHaveProperty('uidAdded'); + expect(payload).toHaveProperty('safeField', 'keep-me'); } - - expect(deliver).toHaveBeenCalledTimes(types.length); - }); - - it('should only have { type, payload } keys at root level', async () => { - const provider = new WebhookProvider(); - - await provider.send(webhookEndpointSample, EventNotifyMock); - - const delivery = deliver.mock.calls[0][1]; - - expect(Object.keys(delivery).sort()).toEqual(['payload', 'type']); }); }); From 746efc11ffa01c3e50b2321929f24547d76a9903 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:43:37 +0300 Subject: [PATCH 10/16] refactor(webhook): simplify mock implementation for deliverer in tests --- workers/webhook/tests/provider.test.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/workers/webhook/tests/provider.test.ts b/workers/webhook/tests/provider.test.ts index 33afe11e..183d831c 100644 --- a/workers/webhook/tests/provider.test.ts +++ b/workers/webhook/tests/provider.test.ts @@ -18,19 +18,11 @@ const deliver = jest.fn(); * Webhook Deliverer mock */ jest.mock('./../src/deliverer.ts', () => { - const original = jest.requireActual('./../src/deliverer.ts'); - - const MockDeliverer = jest.fn().mockImplementation(() => { + return jest.fn().mockImplementation(() => { return { deliver: deliver, }; }); - - return { - __esModule: true, - default: MockDeliverer, - isPrivateIP: original.isPrivateIP, - }; }); /** From ca2cf91445f7214be1b2b199d22d788bb88260db Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:54:00 +0300 Subject: [PATCH 11/16] feat(webhook): implement SSRF mitigations and enhance private IP checks for webhook delivery --- workers/webhook/src/deliverer.ts | 145 ++++++++++++++++++--- workers/webhook/tests/deliverer.test.ts | 160 ++++++++++++++++++------ 2 files changed, 249 insertions(+), 56 deletions(-) diff --git a/workers/webhook/src/deliverer.ts b/workers/webhook/src/deliverer.ts index f5c191d5..a731b4fe 100644 --- a/workers/webhook/src/deliverer.ts +++ b/workers/webhook/src/deliverer.ts @@ -15,9 +15,41 @@ const DELIVERY_TIMEOUT_MS = 10000; const HTTP_ERROR_STATUS = 400; /** - * Regex patterns matching private/reserved IP ranges. - * Covers: 0.x, 10.x, 127.x, 169.254.x, 172.16-31.x, 192.168.x, 100.64-127.x (IPv4) - * and ::1, ::, fe80::, fc/fd ULA (IPv6). + * HTTP status codes indicating a redirect + */ +const REDIRECT_STATUS_MIN = 300; +const REDIRECT_STATUS_MAX = 399; + +/** + * Only these ports are allowed for webhook delivery + */ +const ALLOWED_PORTS: Record = { + 'http:': 80, + 'https:': 443, +}; + +/** + * Hostnames blocked regardless of DNS resolution + */ +const BLOCKED_HOSTNAMES: RegExp[] = [ + /^localhost$/i, + /\.local$/i, + /\.internal$/i, + /\.lan$/i, + /\.localdomain$/i, +]; + +/** + * Regex patterns matching private/reserved IP ranges: + * + * IPv4: 0.x (current-network), 10.x, 172.16-31.x, 192.168.x (RFC1918), + * 127.x (loopback), 169.254.x (link-local/metadata), 100.64-127.x (CGN/RFC6598), + * 255.255.255.255 (broadcast), 224-239.x (multicast), + * 192.0.2.x, 198.51.100.x, 203.0.113.x (documentation), 198.18-19.x (benchmarking). + * + * IPv6: ::1, ::, fe80 (link-local), fc/fd (ULA), ff (multicast). + * + * Also handles IPv4-mapped IPv6 (::ffff:A.B.C.D) and zone IDs (fe80::1%lo0). */ const PRIVATE_IP_PATTERNS: RegExp[] = [ /^0\./, @@ -27,24 +59,72 @@ const PRIVATE_IP_PATTERNS: RegExp[] = [ /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./, /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, + /^255\.255\.255\.255$/, + /^2(2[4-9]|3\d)\./, + /^192\.0\.2\./, + /^198\.51\.100\./, + /^203\.0\.113\./, + /^198\.1[89]\./, /^::1$/, /^::$/, /^fe80/i, /^f[cd]/i, + /^ff[0-9a-f]{2}:/i, + /^::ffff:(0\.|10\.|127\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/i, ]; /** * Checks whether an IPv4 or IPv6 address belongs to a private/reserved range. - * Blocks loopback, link-local, RFC1918, metadata IPs and IPv6 equivalents. + * Handles plain IPv4, IPv6, and IPv4-mapped IPv6 (::ffff:x.x.x.x). * * @param ip - IP address string (v4 or v6) */ export function isPrivateIP(ip: string): boolean { - return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(ip)); + const bare = ip.split('%')[0]; + + return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(bare)); } /** - * Deliverer sends JSON POST requests to external webhook endpoints + * Checks whether a hostname is in the blocked list + * + * @param hostname - hostname to check + */ +function isBlockedHostname(hostname: string): boolean { + return BLOCKED_HOSTNAMES.some((pattern) => pattern.test(hostname)); +} + +/** + * Resolves hostname to all IPs, validates every one is public, + * and returns the first safe address to pin the request to. + * Throws if any address is private or DNS fails. + * + * @param hostname - hostname to resolve + */ +async function resolveAndValidate(hostname: string): Promise { + const results = await dns.promises.lookup(hostname, { all: true }); + + for (const { address } of results) { + if (isPrivateIP(address)) { + throw new Error(`resolves to private IP ${address}`); + } + } + + return results[0].address; +} + +/** + * Deliverer sends JSON POST requests to external webhook endpoints. + * + * SSRF mitigations: + * - Protocol whitelist (http/https only) + * - Port whitelist (80/443 only) + * - Hostname blocklist (localhost, *.local, *.internal, *.lan) + * - Private IP detection for raw IPs in URL + * - DNS resolution with `all: true` — every A/AAAA record checked + * - Request pinned to resolved IP (prevents DNS rebinding) + * - SNI preserved via `servername` for HTTPS + * - Redirects explicitly rejected (3xx + Location) */ export default class WebhookDeliverer { /** @@ -67,7 +147,7 @@ export default class WebhookDeliverer { /** * Sends webhook delivery to the endpoint via HTTP POST. - * Adds X-Hawk-Notification header with the notification type (similar to GitHub's X-GitHub-Event). + * Pins the connection to a validated IP to prevent DNS rebinding. * * @param endpoint - URL to POST to * @param delivery - webhook delivery { type, payload } @@ -82,24 +162,34 @@ export default class WebhookDeliverer { return; } - const hostname = url.hostname; + const requestedPort = url.port ? Number(url.port) : ALLOWED_PORTS[url.protocol]; - if (isPrivateIP(hostname)) { - this.logger.log('error', `Webhook blocked — private IP in URL: ${endpoint}`); + if (requestedPort !== ALLOWED_PORTS[url.protocol]) { + this.logger.log('error', `Webhook blocked — port ${requestedPort} not allowed for ${endpoint}`); return; } - try { - const { address } = await dns.promises.lookup(hostname); + const originalHostname = url.hostname; + + if (isBlockedHostname(originalHostname)) { + this.logger.log('error', `Webhook blocked — hostname "${originalHostname}" is in blocklist`); + + return; + } + + if (isPrivateIP(originalHostname)) { + this.logger.log('error', `Webhook blocked — private IP in URL: ${endpoint}`); + + return; + } - if (isPrivateIP(address)) { - this.logger.log('error', `Webhook blocked — ${hostname} resolves to private IP ${address}`); + let pinnedAddress: string; - return; - } + try { + pinnedAddress = await resolveAndValidate(originalHostname); } catch (e) { - this.logger.log('error', `Webhook blocked — DNS lookup failed for ${hostname}: ${(e as Error).message}`); + this.logger.log('error', `Webhook blocked — ${originalHostname} ${(e as Error).message}`); return; } @@ -108,22 +198,37 @@ export default class WebhookDeliverer { return new Promise((resolve) => { const req = transport.request( - url, { + hostname: pinnedAddress, + port: requestedPort, + path: url.pathname + url.search, method: 'POST', headers: { + 'Host': originalHostname, 'Content-Type': 'application/json', 'User-Agent': 'Hawk-Webhook/1.0', 'X-Hawk-Notification': delivery.type, 'Content-Length': Buffer.byteLength(body), }, timeout: DELIVERY_TIMEOUT_MS, + ...(url.protocol === 'https:' + ? { servername: originalHostname, rejectUnauthorized: true } + : {}), }, (res) => { res.resume(); - if (res.statusCode && res.statusCode >= HTTP_ERROR_STATUS) { - this.logger.log('error', `Webhook delivery failed: ${res.statusCode} ${res.statusMessage} for ${endpoint}`); + const status = res.statusCode || 0; + + if (status >= REDIRECT_STATUS_MIN && status <= REDIRECT_STATUS_MAX) { + this.logger.log('error', `Webhook blocked — redirect ${status} to ${res.headers.location} from ${endpoint}`); + resolve(); + + return; + } + + if (status >= HTTP_ERROR_STATUS) { + this.logger.log('error', `Webhook delivery failed: ${status} ${res.statusMessage} for ${endpoint}`); } resolve(); diff --git a/workers/webhook/tests/deliverer.test.ts b/workers/webhook/tests/deliverer.test.ts index 56679ce2..2d736d7e 100644 --- a/workers/webhook/tests/deliverer.test.ts +++ b/workers/webhook/tests/deliverer.test.ts @@ -1,41 +1,129 @@ import { isPrivateIP } from '../src/deliverer'; describe('isPrivateIP', () => { - it.each([ - ['127.0.0.1', true], - ['127.255.255.255', true], - ['10.0.0.1', true], - ['10.255.255.255', true], - ['0.0.0.0', true], - ['172.16.0.1', true], - ['172.31.255.255', true], - ['192.168.0.1', true], - ['192.168.255.255', true], - ['169.254.1.1', true], - ['169.254.169.254', true], - ['100.64.0.1', true], - ['100.127.255.255', true], - ['::1', true], - ['::', true], - ['fe80::1', true], - ['fc00::1', true], - ['fd12:3456::1', true], - ])('should block private/reserved IP %s', (ip: string, expected: boolean) => { - expect(isPrivateIP(ip)).toBe(expected); - }); - - it.each([ - ['8.8.8.8', false], - ['1.1.1.1', false], - ['93.184.216.34', false], - ['172.32.0.1', false], - ['172.15.255.255', false], - ['192.169.0.1', false], - ['100.128.0.1', false], - ['100.63.255.255', false], - ['169.255.0.1', false], - ['2001:db8::1', false], - ])('should allow public IP %s', (ip: string, expected: boolean) => { - expect(isPrivateIP(ip)).toBe(expected); + describe('should block private/reserved IPv4', () => { + it.each([ + ['127.0.0.1'], + ['127.255.255.255'], + ['10.0.0.1'], + ['10.255.255.255'], + ['0.0.0.0'], + ['172.16.0.1'], + ['172.31.255.255'], + ['192.168.0.1'], + ['192.168.255.255'], + ['169.254.1.1'], + ['169.254.169.254'], + ['100.64.0.1'], + ['100.127.255.255'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block broadcast and multicast IPv4', () => { + it.each([ + ['255.255.255.255'], + ['224.0.0.1'], + ['239.255.255.255'], + ['230.1.2.3'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block documentation and benchmarking IPv4', () => { + it.each([ + ['192.0.2.1'], + ['198.51.100.1'], + ['203.0.113.1'], + ['198.18.0.1'], + ['198.19.255.255'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block private/reserved IPv6', () => { + it.each([ + ['::1'], + ['::'], + ['fe80::1'], + ['FE80::abc'], + ['fc00::1'], + ['fd12:3456::1'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block IPv6 multicast', () => { + it.each([ + ['ff02::1'], + ['ff05::2'], + ['FF0E::1'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block IPv6 with zone ID', () => { + it.each([ + ['fe80::1%lo0'], + ['fe80::1%eth0'], + ['::1%lo0'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block IPv4-mapped IPv6', () => { + it.each([ + ['::ffff:127.0.0.1'], + ['::ffff:10.0.0.1'], + ['::ffff:192.168.1.1'], + ['::ffff:172.16.0.1'], + ['::ffff:169.254.169.254'], + ['::ffff:100.64.0.1'], + ['::ffff:0.0.0.0'], + ['::FFFF:127.0.0.1'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should allow public IPv4', () => { + it.each([ + ['8.8.8.8'], + ['1.1.1.1'], + ['93.184.216.34'], + ['172.32.0.1'], + ['172.15.255.255'], + ['192.169.0.1'], + ['100.128.0.1'], + ['100.63.255.255'], + ['169.255.0.1'], + ['223.255.255.255'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(false); + }); + }); + + describe('should allow public IPv6', () => { + it.each([ + ['2001:db8::1'], + ['2606:4700::1'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(false); + }); + }); + + describe('should allow public IPv4-mapped IPv6', () => { + it.each([ + ['::ffff:8.8.8.8'], + ['::ffff:93.184.216.34'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(false); + }); }); }); From a12b3980e35758ba3ebc92d4b5b908d080529a76 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:35:48 +0300 Subject: [PATCH 12/16] refactor(webhook): streamline webhook deliverer by removing private IP checks and enhancing payload sanitization --- workers/webhook/src/deliverer.ts | 172 +---------- workers/webhook/src/templates/generic.ts | 187 +++++++++--- workers/webhook/tests/deliverer.test.ts | 129 -------- workers/webhook/tests/provider.test.ts | 373 +++++++++++++++++++---- 4 files changed, 465 insertions(+), 396 deletions(-) delete mode 100644 workers/webhook/tests/deliverer.test.ts diff --git a/workers/webhook/src/deliverer.ts b/workers/webhook/src/deliverer.ts index a731b4fe..0430bc34 100644 --- a/workers/webhook/src/deliverer.ts +++ b/workers/webhook/src/deliverer.ts @@ -1,130 +1,19 @@ import https from 'https'; import http from 'http'; -import dns from 'dns'; import { createLogger, format, Logger, transports } from 'winston'; import { WebhookDelivery } from '../types/template'; +import { HttpStatusCode, MS_IN_SEC } from '../../../lib/utils/consts'; /** * Timeout for webhook delivery in milliseconds */ -const DELIVERY_TIMEOUT_MS = 10000; - -/** - * HTTP status code threshold for error responses - */ -const HTTP_ERROR_STATUS = 400; - -/** - * HTTP status codes indicating a redirect - */ -const REDIRECT_STATUS_MIN = 300; -const REDIRECT_STATUS_MAX = 399; - -/** - * Only these ports are allowed for webhook delivery - */ -const ALLOWED_PORTS: Record = { - 'http:': 80, - 'https:': 443, -}; - -/** - * Hostnames blocked regardless of DNS resolution - */ -const BLOCKED_HOSTNAMES: RegExp[] = [ - /^localhost$/i, - /\.local$/i, - /\.internal$/i, - /\.lan$/i, - /\.localdomain$/i, -]; - -/** - * Regex patterns matching private/reserved IP ranges: - * - * IPv4: 0.x (current-network), 10.x, 172.16-31.x, 192.168.x (RFC1918), - * 127.x (loopback), 169.254.x (link-local/metadata), 100.64-127.x (CGN/RFC6598), - * 255.255.255.255 (broadcast), 224-239.x (multicast), - * 192.0.2.x, 198.51.100.x, 203.0.113.x (documentation), 198.18-19.x (benchmarking). - * - * IPv6: ::1, ::, fe80 (link-local), fc/fd (ULA), ff (multicast). - * - * Also handles IPv4-mapped IPv6 (::ffff:A.B.C.D) and zone IDs (fe80::1%lo0). - */ -const PRIVATE_IP_PATTERNS: RegExp[] = [ - /^0\./, - /^10\./, - /^127\./, - /^169\.254\./, - /^172\.(1[6-9]|2\d|3[01])\./, - /^192\.168\./, - /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, - /^255\.255\.255\.255$/, - /^2(2[4-9]|3\d)\./, - /^192\.0\.2\./, - /^198\.51\.100\./, - /^203\.0\.113\./, - /^198\.1[89]\./, - /^::1$/, - /^::$/, - /^fe80/i, - /^f[cd]/i, - /^ff[0-9a-f]{2}:/i, - /^::ffff:(0\.|10\.|127\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/i, -]; - -/** - * Checks whether an IPv4 or IPv6 address belongs to a private/reserved range. - * Handles plain IPv4, IPv6, and IPv4-mapped IPv6 (::ffff:x.x.x.x). - * - * @param ip - IP address string (v4 or v6) - */ -export function isPrivateIP(ip: string): boolean { - const bare = ip.split('%')[0]; - - return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(bare)); -} - -/** - * Checks whether a hostname is in the blocked list - * - * @param hostname - hostname to check - */ -function isBlockedHostname(hostname: string): boolean { - return BLOCKED_HOSTNAMES.some((pattern) => pattern.test(hostname)); -} - -/** - * Resolves hostname to all IPs, validates every one is public, - * and returns the first safe address to pin the request to. - * Throws if any address is private or DNS fails. - * - * @param hostname - hostname to resolve - */ -async function resolveAndValidate(hostname: string): Promise { - const results = await dns.promises.lookup(hostname, { all: true }); - - for (const { address } of results) { - if (isPrivateIP(address)) { - throw new Error(`resolves to private IP ${address}`); - } - } - - return results[0].address; -} +const DELIVERY_TIMEOUT_MS = MS_IN_SEC * 10; /** * Deliverer sends JSON POST requests to external webhook endpoints. * - * SSRF mitigations: - * - Protocol whitelist (http/https only) - * - Port whitelist (80/443 only) - * - Hostname blocklist (localhost, *.local, *.internal, *.lan) - * - Private IP detection for raw IPs in URL - * - DNS resolution with `all: true` — every A/AAAA record checked - * - Request pinned to resolved IP (prevents DNS rebinding) - * - SNI preserved via `servername` for HTTPS - * - Redirects explicitly rejected (3xx + Location) + * SSRF validation is performed at the API layer when the endpoint is saved — + * this class trusts the stored URL and only handles delivery. */ export default class WebhookDeliverer { /** @@ -147,7 +36,7 @@ export default class WebhookDeliverer { /** * Sends webhook delivery to the endpoint via HTTP POST. - * Pins the connection to a validated IP to prevent DNS rebinding. + * Adds X-Hawk-Notification header with the notification type (similar to GitHub's X-GitHub-Event). * * @param endpoint - URL to POST to * @param delivery - webhook delivery { type, payload } @@ -155,79 +44,34 @@ export default class WebhookDeliverer { public async deliver(endpoint: string, delivery: WebhookDelivery): Promise { const body = JSON.stringify(delivery); const url = new URL(endpoint); - - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - this.logger.log('error', `Webhook blocked — unsupported protocol: ${url.protocol} for ${endpoint}`); - - return; - } - - const requestedPort = url.port ? Number(url.port) : ALLOWED_PORTS[url.protocol]; - - if (requestedPort !== ALLOWED_PORTS[url.protocol]) { - this.logger.log('error', `Webhook blocked — port ${requestedPort} not allowed for ${endpoint}`); - - return; - } - - const originalHostname = url.hostname; - - if (isBlockedHostname(originalHostname)) { - this.logger.log('error', `Webhook blocked — hostname "${originalHostname}" is in blocklist`); - - return; - } - - if (isPrivateIP(originalHostname)) { - this.logger.log('error', `Webhook blocked — private IP in URL: ${endpoint}`); - - return; - } - - let pinnedAddress: string; - - try { - pinnedAddress = await resolveAndValidate(originalHostname); - } catch (e) { - this.logger.log('error', `Webhook blocked — ${originalHostname} ${(e as Error).message}`); - - return; - } - const transport = url.protocol === 'https:' ? https : http; return new Promise((resolve) => { const req = transport.request( + url, { - hostname: pinnedAddress, - port: requestedPort, - path: url.pathname + url.search, method: 'POST', headers: { - 'Host': originalHostname, 'Content-Type': 'application/json', 'User-Agent': 'Hawk-Webhook/1.0', 'X-Hawk-Notification': delivery.type, 'Content-Length': Buffer.byteLength(body), }, timeout: DELIVERY_TIMEOUT_MS, - ...(url.protocol === 'https:' - ? { servername: originalHostname, rejectUnauthorized: true } - : {}), }, (res) => { res.resume(); const status = res.statusCode || 0; - if (status >= REDIRECT_STATUS_MIN && status <= REDIRECT_STATUS_MAX) { + if (status >= HttpStatusCode.MultipleChoices && status <= HttpStatusCode.PermanentRedirect) { this.logger.log('error', `Webhook blocked — redirect ${status} to ${res.headers.location} from ${endpoint}`); resolve(); return; } - if (status >= HTTP_ERROR_STATUS) { + if (status >= HttpStatusCode.BadRequest) { this.logger.log('error', `Webhook delivery failed: ${status} ${res.statusMessage} for ${endpoint}`); } diff --git a/workers/webhook/src/templates/generic.ts b/workers/webhook/src/templates/generic.ts index 4de47ca6..ce41538b 100644 --- a/workers/webhook/src/templates/generic.ts +++ b/workers/webhook/src/templates/generic.ts @@ -2,63 +2,174 @@ import { Notification } from 'hawk-worker-sender/types/template-variables'; import { WebhookDelivery } from '../../types/template'; /** - * Internal/sensitive fields stripped from webhook payload at any nesting level + * Converts ObjectId (or any BSON value) to string, passes primitives through + * + * @param value - value to stringify */ -const INTERNAL_FIELDS = new Set([ - 'host', - 'hostOfStatic', - 'token', - 'notifications', - 'integrationId', - 'notificationRuleId', - 'visitedBy', - 'uidAdded', -]); +function str(value: unknown): string { + return String(value); +} /** - * Recursively converts MongoDB ObjectIds and other non-JSON-safe values to strings + * Safe property accessor — returns undefined for missing nested paths * - * @param value - any value to sanitize + * @param obj - source object + * @param key - property name */ -function sanitize(value: unknown): unknown { - if (value === null || value === undefined) { - return value; - } +function get(obj: Record, key: string): unknown { + return obj?.[key]; +} - if (typeof value === 'object' && '_bsontype' in (value as Record)) { - return String(value); - } +/* ---------- Shared DTO projectors ---------- */ - if (Array.isArray(value)) { - return value.map(sanitize); - } +function projectDTO(p: Record): Record { + return { + id: str(get(p, '_id')), + name: get(p, 'name') ?? null, + workspaceId: get(p, 'workspaceId') ? str(get(p, 'workspaceId')) : null, + image: get(p, 'image') ?? null, + }; +} - if (typeof value === 'object') { - const result: Record = {}; +function workspaceDTO(w: Record): Record { + return { + id: str(get(w, '_id')), + name: get(w, 'name') ?? null, + image: get(w, 'image') ?? null, + }; +} - for (const [k, v] of Object.entries(value as Record)) { - if (!INTERNAL_FIELDS.has(k)) { - result[k] = sanitize(v); - } - } +function userDTO(u: Record): Record { + return { + id: str(get(u, '_id')), + name: get(u, 'name') ?? null, + email: get(u, 'email') ?? null, + image: get(u, 'image') ?? null, + }; +} + +function eventDTO(e: Record): Record { + const payload = (e.payload ?? {}) as Record; + const backtrace = (payload.backtrace ?? []) as Array>; - return result; - } + return { + id: e._id ? str(e._id) : null, + groupHash: e.groupHash ?? null, + totalCount: e.totalCount ?? null, + catcherType: e.catcherType ?? null, + timestamp: e.timestamp ?? null, + usersAffected: e.usersAffected ?? null, + title: payload.title ?? null, + type: payload.type ?? null, + backtrace: backtrace.map((f) => ({ + file: f.file ?? null, + line: f.line ?? null, + column: f.column ?? null, + function: f.function ?? null, + })), + }; +} + +function templateEventDataDTO(item: Record): Record { + const event = (item.event ?? {}) as Record; + + return { + event: eventDTO(event), + newCount: item.newCount ?? null, + daysRepeated: item.daysRepeated ?? null, + usersAffected: item.usersAffected ?? null, + repetitionId: item.repetitionId ? str(item.repetitionId) : null, + }; +} - return value; +function planDTO(p: Record): Record { + return { + id: str(get(p, '_id')), + name: get(p, 'name') ?? null, + eventsLimit: get(p, 'eventsLimit') ?? null, + monthlyCharge: get(p, 'monthlyCharge') ?? null, + }; } +type PayloadProjector = (payload: Record) => Record; + +const projectors: Record = { + 'event': (p) => ({ + project: projectDTO((p.project ?? {}) as Record), + events: ((p.events ?? []) as Array>).map(templateEventDataDTO), + period: p.period ?? null, + }), + + 'several-events': (p) => ({ + project: projectDTO((p.project ?? {}) as Record), + events: ((p.events ?? []) as Array>).map(templateEventDataDTO), + period: p.period ?? null, + }), + + 'assignee': (p) => ({ + project: projectDTO((p.project ?? {}) as Record), + event: eventDTO((p.event ?? {}) as Record), + whoAssigned: userDTO((p.whoAssigned ?? {}) as Record), + daysRepeated: p.daysRepeated ?? null, + }), + + 'block-workspace': (p) => ({ + workspace: workspaceDTO((p.workspace ?? {}) as Record), + }), + + 'blocked-workspace-reminder': (p) => ({ + workspace: workspaceDTO((p.workspace ?? {}) as Record), + daysAfterBlock: p.daysAfterBlock ?? null, + }), + + 'days-limit-almost-reached': (p) => ({ + workspace: workspaceDTO((p.workspace ?? {}) as Record), + daysLeft: p.daysLeft ?? null, + }), + + 'events-limit-almost-reached': (p) => ({ + workspace: workspaceDTO((p.workspace ?? {}) as Record), + eventsCount: p.eventsCount ?? null, + eventsLimit: p.eventsLimit ?? null, + }), + + 'payment-failed': (p) => ({ + workspace: workspaceDTO((p.workspace ?? {}) as Record), + reason: p.reason ?? null, + }), + + 'payment-success': (p) => ({ + workspace: workspaceDTO((p.workspace ?? {}) as Record), + plan: planDTO((p.plan ?? {}) as Record), + }), + + 'sign-up': (p) => ({ + email: p.email ?? null, + }), + + 'password-reset': () => ({}), + + 'workspace-invite': (p) => ({ + workspaceName: p.workspaceName ?? null, + inviteLink: p.inviteLink ?? null, + }), +}; + /** - * Generic webhook template — handles any notification type - * by passing through the sanitized payload as-is. - * - * Used as a fallback when no curated template exists for the notification type. + * Whitelist-based webhook template — each notification type has an explicit + * DTO projector that picks only safe, relevant fields. + * Unknown types produce an empty payload (fail-closed). * * @param notification - notification with type and payload */ export default function render(notification: Notification): WebhookDelivery { + const project = projectors[notification.type]; + const payload = project + ? project(notification.payload as unknown as Record) + : {}; + return { type: notification.type, - payload: sanitize(notification.payload) as Record, + payload, }; } diff --git a/workers/webhook/tests/deliverer.test.ts b/workers/webhook/tests/deliverer.test.ts deleted file mode 100644 index 2d736d7e..00000000 --- a/workers/webhook/tests/deliverer.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { isPrivateIP } from '../src/deliverer'; - -describe('isPrivateIP', () => { - describe('should block private/reserved IPv4', () => { - it.each([ - ['127.0.0.1'], - ['127.255.255.255'], - ['10.0.0.1'], - ['10.255.255.255'], - ['0.0.0.0'], - ['172.16.0.1'], - ['172.31.255.255'], - ['192.168.0.1'], - ['192.168.255.255'], - ['169.254.1.1'], - ['169.254.169.254'], - ['100.64.0.1'], - ['100.127.255.255'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(true); - }); - }); - - describe('should block broadcast and multicast IPv4', () => { - it.each([ - ['255.255.255.255'], - ['224.0.0.1'], - ['239.255.255.255'], - ['230.1.2.3'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(true); - }); - }); - - describe('should block documentation and benchmarking IPv4', () => { - it.each([ - ['192.0.2.1'], - ['198.51.100.1'], - ['203.0.113.1'], - ['198.18.0.1'], - ['198.19.255.255'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(true); - }); - }); - - describe('should block private/reserved IPv6', () => { - it.each([ - ['::1'], - ['::'], - ['fe80::1'], - ['FE80::abc'], - ['fc00::1'], - ['fd12:3456::1'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(true); - }); - }); - - describe('should block IPv6 multicast', () => { - it.each([ - ['ff02::1'], - ['ff05::2'], - ['FF0E::1'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(true); - }); - }); - - describe('should block IPv6 with zone ID', () => { - it.each([ - ['fe80::1%lo0'], - ['fe80::1%eth0'], - ['::1%lo0'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(true); - }); - }); - - describe('should block IPv4-mapped IPv6', () => { - it.each([ - ['::ffff:127.0.0.1'], - ['::ffff:10.0.0.1'], - ['::ffff:192.168.1.1'], - ['::ffff:172.16.0.1'], - ['::ffff:169.254.169.254'], - ['::ffff:100.64.0.1'], - ['::ffff:0.0.0.0'], - ['::FFFF:127.0.0.1'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(true); - }); - }); - - describe('should allow public IPv4', () => { - it.each([ - ['8.8.8.8'], - ['1.1.1.1'], - ['93.184.216.34'], - ['172.32.0.1'], - ['172.15.255.255'], - ['192.169.0.1'], - ['100.128.0.1'], - ['100.63.255.255'], - ['169.255.0.1'], - ['223.255.255.255'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(false); - }); - }); - - describe('should allow public IPv6', () => { - it.each([ - ['2001:db8::1'], - ['2606:4700::1'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(false); - }); - }); - - describe('should allow public IPv4-mapped IPv6', () => { - it.each([ - ['::ffff:8.8.8.8'], - ['::ffff:93.184.216.34'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(false); - }); - }); -}); diff --git a/workers/webhook/tests/provider.test.ts b/workers/webhook/tests/provider.test.ts index 183d831c..5544141b 100644 --- a/workers/webhook/tests/provider.test.ts +++ b/workers/webhook/tests/provider.test.ts @@ -3,6 +3,7 @@ import SeveralEventsNotifyMock from './__mocks__/several-events-notify'; import AssigneeNotifyMock from './__mocks__/assignee-notify'; import WebhookProvider from '../src/provider'; import { Notification } from 'hawk-worker-sender/types/template-variables'; +import { ObjectId } from 'mongodb'; /** * The sample of a webhook endpoint @@ -50,6 +51,13 @@ const ALL_NOTIFICATION_TYPES = [ 'workspace-invite', ] as const; +/** + * Helper to extract delivered payload + */ +function getDeliveredPayload(): Record { + return deliver.mock.calls[0][1].payload as Record; +} + describe('WebhookProvider', () => { it('should deliver a message with { type, payload } structure', async () => { const provider = new WebhookProvider(); @@ -103,81 +111,316 @@ describe('WebhookProvider', () => { expect(delivery.payload).toBeDefined(); }); - it('should strip all internal/sensitive fields from payload', async () => { - const provider = new WebhookProvider(); + describe('whitelist DTO behavior', () => { + it('should only include whitelisted project fields for "event" type', async () => { + const provider = new WebhookProvider(); - await provider.send(webhookEndpointSample, { - type: 'event', - payload: { - host: 'https://garage.hawk.so', - hostOfStatic: 'https://api.hawk.so', - notificationRuleId: '123', - project: { - name: 'My Project', - token: 'secret-token', - integrationId: 'uuid', - uidAdded: 'user-id', - notifications: [{ rule: 'data' }], - }, - events: [{ - event: { - groupHash: 'abc', - visitedBy: ['user1'], + await provider.send(webhookEndpointSample, { + type: 'event', + payload: { + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + name: 'My Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + image: 'img.png', + token: 'secret-token', + notifications: [{ rule: 'data' }], + integrationId: 'uuid', + uidAdded: 'user-id', }, - }], - }, - } as unknown as Notification); + events: [], + period: 60, + host: 'https://garage.hawk.so', + hostOfStatic: 'https://api.hawk.so', + }, + } as unknown as Notification); - const delivery = deliver.mock.calls[0][1]; - const payload = delivery.payload as Record; - const project = payload.project as Record; - const events = payload.events as Array>>; - - expect(payload).not.toHaveProperty('host'); - expect(payload).not.toHaveProperty('hostOfStatic'); - expect(payload).not.toHaveProperty('notificationRuleId'); - expect(project).not.toHaveProperty('token'); - expect(project).not.toHaveProperty('integrationId'); - expect(project).not.toHaveProperty('uidAdded'); - expect(project).not.toHaveProperty('notifications'); - expect(project).toHaveProperty('name', 'My Project'); - expect(events[0].event).not.toHaveProperty('visitedBy'); - expect(events[0].event).toHaveProperty('groupHash', 'abc'); - }); + const payload = getDeliveredPayload(); + const project = payload.project as Record; - it('should strip internal fields from all notification types', async () => { - const provider = new WebhookProvider(); - const sensitivePayload = { - host: 'h', - hostOfStatic: 's', - token: 'secret', - notifications: [], - integrationId: 'id', - notificationRuleId: 'rid', - visitedBy: ['u1'], - uidAdded: 'uid', - safeField: 'keep-me', - }; + expect(Object.keys(project).sort()).toEqual(['id', 'image', 'name', 'workspaceId']); + expect(project.name).toBe('My Project'); + expect(project.id).toBe('5d206f7f9aaf7c0071d64596'); + expect(project.image).toBe('img.png'); + }); - for (const type of ALL_NOTIFICATION_TYPES) { - deliver.mockClear(); + it('should strip sourceCode from event backtrace', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, EventNotifyMock); + + const payload = getDeliveredPayload(); + const events = payload.events as Array>; + const firstEvent = events[0].event as Record; + const backtrace = firstEvent.backtrace as Array>; + + expect(backtrace[0]).not.toHaveProperty('sourceCode'); + expect(backtrace[0]).toHaveProperty('file', 'file'); + expect(backtrace[0]).toHaveProperty('line', 1); + }); + + it('should never leak host/hostOfStatic from event payload', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, EventNotifyMock); + + const payload = getDeliveredPayload(); + + expect(payload).not.toHaveProperty('host'); + expect(payload).not.toHaveProperty('hostOfStatic'); + }); + + it('should return only email for "sign-up" (no password)', async () => { + const provider = new WebhookProvider(); await provider.send(webhookEndpointSample, { - type, - payload: { ...sensitivePayload }, + type: 'sign-up', + payload: { + email: 'john@example.com', + password: 'super-secret-password', + host: 'https://garage.hawk.so', + }, } as unknown as Notification); - const payload = deliver.mock.calls[0][1].payload as Record; + const payload = getDeliveredPayload(); + expect(payload).toEqual({ email: 'john@example.com' }); + expect(payload).not.toHaveProperty('password'); expect(payload).not.toHaveProperty('host'); - expect(payload).not.toHaveProperty('hostOfStatic'); - expect(payload).not.toHaveProperty('token'); - expect(payload).not.toHaveProperty('notifications'); - expect(payload).not.toHaveProperty('integrationId'); - expect(payload).not.toHaveProperty('notificationRuleId'); - expect(payload).not.toHaveProperty('visitedBy'); - expect(payload).not.toHaveProperty('uidAdded'); - expect(payload).toHaveProperty('safeField', 'keep-me'); - } + }); + + it('should return empty payload for "password-reset"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'password-reset', + payload: { + password: 'new-secret', + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toEqual({}); + }); + + it('should return empty payload for unknown notification type', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'totally-unknown-type', + payload: { + secret: 'should-not-leak', + token: 'also-secret', + }, + } as unknown as Notification); + + const delivery = deliver.mock.calls[0][1]; + + expect(delivery.type).toBe('totally-unknown-type'); + expect(delivery.payload).toEqual({}); + }); + + it('should include workspace DTO for "block-workspace"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'block-workspace', + payload: { + workspace: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + name: 'My Workspace', + image: 'ws.png', + balance: 1000, + accountId: 'acc-123', + }, + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + const workspace = payload.workspace as Record; + + expect(Object.keys(workspace).sort()).toEqual(['id', 'image', 'name']); + expect(workspace.name).toBe('My Workspace'); + expect(workspace).not.toHaveProperty('balance'); + expect(workspace).not.toHaveProperty('accountId'); + }); + + it('should include workspace and plan DTOs for "payment-success"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'payment-success', + payload: { + workspace: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + name: 'Ws', + }, + plan: { + _id: new ObjectId('5d206f7f9aaf7c0071d64597'), + name: 'Pro', + eventsLimit: 50000, + monthlyCharge: 999, + }, + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toHaveProperty('workspace'); + expect(payload).toHaveProperty('plan'); + + const plan = payload.plan as Record; + + expect(plan).toEqual({ + id: '5d206f7f9aaf7c0071d64597', + name: 'Pro', + eventsLimit: 50000, + monthlyCharge: 999, + }); + }); + + it('should include whoAssigned DTO for "assignee"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, AssigneeNotifyMock); + + const payload = getDeliveredPayload(); + const who = payload.whoAssigned as Record; + + expect(who).toHaveProperty('name', 'John Doe'); + expect(who).toHaveProperty('email', 'john@example.com'); + expect(payload).toHaveProperty('daysRepeated', 3); + }); + + it('should include inviteLink and workspaceName for "workspace-invite"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'workspace-invite', + payload: { + workspaceName: 'My Team', + inviteLink: 'https://hawk.so/invite/abc', + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toEqual({ + workspaceName: 'My Team', + inviteLink: 'https://hawk.so/invite/abc', + }); + }); + + it('should include daysAfterBlock for "blocked-workspace-reminder"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'blocked-workspace-reminder', + payload: { + workspace: { _id: new ObjectId(), name: 'Ws' }, + daysAfterBlock: 7, + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toHaveProperty('daysAfterBlock', 7); + expect(payload).toHaveProperty('workspace'); + }); + + it('should include eventsCount and eventsLimit for "events-limit-almost-reached"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'events-limit-almost-reached', + payload: { + workspace: { _id: new ObjectId(), name: 'Ws' }, + eventsCount: 9500, + eventsLimit: 10000, + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toHaveProperty('eventsCount', 9500); + expect(payload).toHaveProperty('eventsLimit', 10000); + }); + + it('should include reason for "payment-failed"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'payment-failed', + payload: { + workspace: { _id: new ObjectId(), name: 'Ws' }, + reason: 'Insufficient funds', + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toHaveProperty('reason', 'Insufficient funds'); + }); + + it('should include daysLeft for "days-limit-almost-reached"', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'days-limit-almost-reached', + payload: { + workspace: { _id: new ObjectId(), name: 'Ws' }, + daysLeft: 3, + host: 'https://garage.hawk.so', + }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).toHaveProperty('daysLeft', 3); + }); + + it('should never include sensitive fields regardless of notification type', async () => { + const provider = new WebhookProvider(); + const toxicFields = { + host: 'h', + hostOfStatic: 's', + token: 'secret', + notifications: [], + integrationId: 'id', + notificationRuleId: 'rid', + visitedBy: ['u1'], + uidAdded: 'uid', + password: 'pass', + }; + + for (const type of ALL_NOTIFICATION_TYPES) { + deliver.mockClear(); + + await provider.send(webhookEndpointSample, { + type, + payload: { ...toxicFields }, + } as unknown as Notification); + + const payload = getDeliveredPayload(); + + expect(payload).not.toHaveProperty('host'); + expect(payload).not.toHaveProperty('hostOfStatic'); + expect(payload).not.toHaveProperty('token'); + expect(payload).not.toHaveProperty('notifications'); + expect(payload).not.toHaveProperty('integrationId'); + expect(payload).not.toHaveProperty('notificationRuleId'); + expect(payload).not.toHaveProperty('visitedBy'); + expect(payload).not.toHaveProperty('uidAdded'); + expect(payload).not.toHaveProperty('password'); + } + }); }); }); From da94019aa5124195de924f059b636e1ea2ba3771 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:52:42 +0300 Subject: [PATCH 13/16] refactor(webhook): replace magic number with constant for delivery timeout in webhook deliverer --- workers/webhook/src/deliverer.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/workers/webhook/src/deliverer.ts b/workers/webhook/src/deliverer.ts index 0430bc34..cae459f5 100644 --- a/workers/webhook/src/deliverer.ts +++ b/workers/webhook/src/deliverer.ts @@ -4,10 +4,15 @@ import { createLogger, format, Logger, transports } from 'winston'; import { WebhookDelivery } from '../types/template'; import { HttpStatusCode, MS_IN_SEC } from '../../../lib/utils/consts'; +/** + * How many seconds to wait for a webhook response before aborting + */ +const DELIVERY_TIMEOUT_SEC = 10; + /** * Timeout for webhook delivery in milliseconds */ -const DELIVERY_TIMEOUT_MS = MS_IN_SEC * 10; +const DELIVERY_TIMEOUT_MS = MS_IN_SEC * DELIVERY_TIMEOUT_SEC; /** * Deliverer sends JSON POST requests to external webhook endpoints. From 77d1f32d956783391926b45961bdfec724abadd9 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:25:00 +0300 Subject: [PATCH 14/16] refactor(webhook): enhance data projection by using specific DB schemes for project, workspace, user, event, and plan DTOs --- workers/webhook/src/templates/generic.ts | 163 ++++++++++++----------- 1 file changed, 87 insertions(+), 76 deletions(-) diff --git a/workers/webhook/src/templates/generic.ts b/workers/webhook/src/templates/generic.ts index ce41538b..8814db62 100644 --- a/workers/webhook/src/templates/generic.ts +++ b/workers/webhook/src/templates/generic.ts @@ -1,93 +1,104 @@ -import { Notification } from 'hawk-worker-sender/types/template-variables'; +import { Notification, TemplateEventData } from 'hawk-worker-sender/types/template-variables'; +import { + ProjectDBScheme, + WorkspaceDBScheme, + UserDBScheme, + DecodedGroupedEvent, + PlanDBScheme, +} from '@hawk.so/types'; import { WebhookDelivery } from '../../types/template'; /** - * Converts ObjectId (or any BSON value) to string, passes primitives through + * Projects safe public fields from a project document * - * @param value - value to stringify + * @param p - project DB record */ -function str(value: unknown): string { - return String(value); +function projectDTO(p: ProjectDBScheme): Record { + return { + id: String(p._id), + name: p.name, + workspaceId: String(p.workspaceId), + image: p.image ?? null, + }; } /** - * Safe property accessor — returns undefined for missing nested paths + * Projects safe public fields from a workspace document * - * @param obj - source object - * @param key - property name + * @param w - workspace DB record */ -function get(obj: Record, key: string): unknown { - return obj?.[key]; -} - -/* ---------- Shared DTO projectors ---------- */ - -function projectDTO(p: Record): Record { - return { - id: str(get(p, '_id')), - name: get(p, 'name') ?? null, - workspaceId: get(p, 'workspaceId') ? str(get(p, 'workspaceId')) : null, - image: get(p, 'image') ?? null, - }; -} - -function workspaceDTO(w: Record): Record { +function workspaceDTO(w: WorkspaceDBScheme): Record { return { - id: str(get(w, '_id')), - name: get(w, 'name') ?? null, - image: get(w, 'image') ?? null, + id: String(w._id), + name: w.name, + image: w.image ?? null, }; } -function userDTO(u: Record): Record { +/** + * Projects safe public fields from a user document (no password, no bank cards) + * + * @param u - user DB record + */ +function userDTO(u: UserDBScheme): Record { return { - id: str(get(u, '_id')), - name: get(u, 'name') ?? null, - email: get(u, 'email') ?? null, - image: get(u, 'image') ?? null, + id: String(u._id), + name: u.name ?? null, + email: u.email ?? null, + image: u.image ?? null, }; } -function eventDTO(e: Record): Record { - const payload = (e.payload ?? {}) as Record; - const backtrace = (payload.backtrace ?? []) as Array>; - +/** + * Projects safe public fields from a grouped event (no sourceCode, no breadcrumbs, no addons) + * + * @param e - decoded grouped event + */ +function eventDTO(e: DecodedGroupedEvent): Record { return { - id: e._id ? str(e._id) : null, - groupHash: e.groupHash ?? null, - totalCount: e.totalCount ?? null, - catcherType: e.catcherType ?? null, - timestamp: e.timestamp ?? null, - usersAffected: e.usersAffected ?? null, - title: payload.title ?? null, - type: payload.type ?? null, - backtrace: backtrace.map((f) => ({ - file: f.file ?? null, - line: f.line ?? null, + id: e._id ? String(e._id) : null, + groupHash: e.groupHash, + totalCount: e.totalCount, + catcherType: e.catcherType, + timestamp: e.timestamp, + usersAffected: e.usersAffected, + title: e.payload.title, + type: e.payload.type ?? null, + backtrace: (e.payload.backtrace ?? []).map((f) => ({ + file: f.file, + line: f.line, column: f.column ?? null, function: f.function ?? null, })), }; } -function templateEventDataDTO(item: Record): Record { - const event = (item.event ?? {}) as Record; - +/** + * Projects event list item with its metadata (newCount, daysRepeated, etc.) + * + * @param item - template event data from sender worker + */ +function templateEventDataDTO(item: TemplateEventData): Record { return { - event: eventDTO(event), - newCount: item.newCount ?? null, - daysRepeated: item.daysRepeated ?? null, + event: eventDTO(item.event), + newCount: item.newCount, + daysRepeated: item.daysRepeated, usersAffected: item.usersAffected ?? null, - repetitionId: item.repetitionId ? str(item.repetitionId) : null, + repetitionId: item.repetitionId ? String(item.repetitionId) : null, }; } -function planDTO(p: Record): Record { +/** + * Projects safe public fields from a plan document + * + * @param p - plan DB record + */ +function planDTO(p: PlanDBScheme): Record { return { - id: str(get(p, '_id')), - name: get(p, 'name') ?? null, - eventsLimit: get(p, 'eventsLimit') ?? null, - monthlyCharge: get(p, 'monthlyCharge') ?? null, + id: String(p._id), + name: p.name, + eventsLimit: p.eventsLimit, + monthlyCharge: p.monthlyCharge, }; } @@ -95,52 +106,52 @@ type PayloadProjector = (payload: Record) => Record = { 'event': (p) => ({ - project: projectDTO((p.project ?? {}) as Record), - events: ((p.events ?? []) as Array>).map(templateEventDataDTO), + project: p.project ? projectDTO(p.project as ProjectDBScheme) : null, + events: ((p.events ?? []) as TemplateEventData[]).map(templateEventDataDTO), period: p.period ?? null, }), 'several-events': (p) => ({ - project: projectDTO((p.project ?? {}) as Record), - events: ((p.events ?? []) as Array>).map(templateEventDataDTO), + project: p.project ? projectDTO(p.project as ProjectDBScheme) : null, + events: ((p.events ?? []) as TemplateEventData[]).map(templateEventDataDTO), period: p.period ?? null, }), 'assignee': (p) => ({ - project: projectDTO((p.project ?? {}) as Record), - event: eventDTO((p.event ?? {}) as Record), - whoAssigned: userDTO((p.whoAssigned ?? {}) as Record), + project: p.project ? projectDTO(p.project as ProjectDBScheme) : null, + event: p.event ? eventDTO(p.event as DecodedGroupedEvent) : null, + whoAssigned: p.whoAssigned ? userDTO(p.whoAssigned as UserDBScheme) : null, daysRepeated: p.daysRepeated ?? null, }), 'block-workspace': (p) => ({ - workspace: workspaceDTO((p.workspace ?? {}) as Record), + workspace: p.workspace ? workspaceDTO(p.workspace as WorkspaceDBScheme) : null, }), 'blocked-workspace-reminder': (p) => ({ - workspace: workspaceDTO((p.workspace ?? {}) as Record), + workspace: p.workspace ? workspaceDTO(p.workspace as WorkspaceDBScheme) : null, daysAfterBlock: p.daysAfterBlock ?? null, }), 'days-limit-almost-reached': (p) => ({ - workspace: workspaceDTO((p.workspace ?? {}) as Record), + workspace: p.workspace ? workspaceDTO(p.workspace as WorkspaceDBScheme) : null, daysLeft: p.daysLeft ?? null, }), 'events-limit-almost-reached': (p) => ({ - workspace: workspaceDTO((p.workspace ?? {}) as Record), + workspace: p.workspace ? workspaceDTO(p.workspace as WorkspaceDBScheme) : null, eventsCount: p.eventsCount ?? null, eventsLimit: p.eventsLimit ?? null, }), 'payment-failed': (p) => ({ - workspace: workspaceDTO((p.workspace ?? {}) as Record), + workspace: p.workspace ? workspaceDTO(p.workspace as WorkspaceDBScheme) : null, reason: p.reason ?? null, }), 'payment-success': (p) => ({ - workspace: workspaceDTO((p.workspace ?? {}) as Record), - plan: planDTO((p.plan ?? {}) as Record), + workspace: p.workspace ? workspaceDTO(p.workspace as WorkspaceDBScheme) : null, + plan: p.plan ? planDTO(p.plan as PlanDBScheme) : null, }), 'sign-up': (p) => ({ @@ -163,9 +174,9 @@ const projectors: Record = { * @param notification - notification with type and payload */ export default function render(notification: Notification): WebhookDelivery { - const project = projectors[notification.type]; - const payload = project - ? project(notification.payload as unknown as Record) + const projector = projectors[notification.type]; + const payload = projector + ? projector(notification.payload as unknown as Record) : {}; return { From ef403757b4db2a84d4b2b24a3beefcfb41adfbbf Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:46:08 +0300 Subject: [PATCH 15/16] feat(webhook): add assignee information to webhook payload and update related tests --- workers/sender/src/index.ts | 1 + .../sender/types/template-variables/assignee.ts | 5 +++++ workers/webhook/src/deliverer.ts | 17 ++++++++++++++++- workers/webhook/src/templates/generic.ts | 3 ++- .../webhook/tests/__mocks__/assignee-notify.ts | 4 ++++ workers/webhook/tests/provider.test.ts | 14 +++++++++----- 6 files changed, 37 insertions(+), 7 deletions(-) diff --git a/workers/sender/src/index.ts b/workers/sender/src/index.ts index c4c243b9..242d2da8 100644 --- a/workers/sender/src/index.ts +++ b/workers/sender/src/index.ts @@ -253,6 +253,7 @@ export default abstract class SenderWorker extends Worker { project, event, whoAssigned, + assignee, daysRepeated, }, } as AssigneeNotification); diff --git a/workers/sender/types/template-variables/assignee.ts b/workers/sender/types/template-variables/assignee.ts index 9f4a29ae..b4af9125 100644 --- a/workers/sender/types/template-variables/assignee.ts +++ b/workers/sender/types/template-variables/assignee.ts @@ -21,6 +21,11 @@ export interface AssigneeTemplateVariables extends CommonTemplateVariables { */ whoAssigned: UserDBScheme; + /** + * User who was assigned to resolve the event + */ + assignee: UserDBScheme; + /** * Number of event repetitions */ diff --git a/workers/webhook/src/deliverer.ts b/workers/webhook/src/deliverer.ts index cae459f5..41fa7516 100644 --- a/workers/webhook/src/deliverer.ts +++ b/workers/webhook/src/deliverer.ts @@ -47,8 +47,23 @@ export default class WebhookDeliverer { * @param delivery - webhook delivery { type, payload } */ public async deliver(endpoint: string, delivery: WebhookDelivery): Promise { + let url: URL; + + try { + url = new URL(endpoint); + } catch { + this.logger.log('error', `Webhook delivery skipped — invalid URL: ${endpoint}`); + + return; + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + this.logger.log('error', `Webhook delivery skipped — unsupported protocol: ${url.protocol}`); + + return; + } + const body = JSON.stringify(delivery); - const url = new URL(endpoint); const transport = url.protocol === 'https:' ? https : http; return new Promise((resolve) => { diff --git a/workers/webhook/src/templates/generic.ts b/workers/webhook/src/templates/generic.ts index 8814db62..f2fcabf3 100644 --- a/workers/webhook/src/templates/generic.ts +++ b/workers/webhook/src/templates/generic.ts @@ -120,7 +120,8 @@ const projectors: Record = { 'assignee': (p) => ({ project: p.project ? projectDTO(p.project as ProjectDBScheme) : null, event: p.event ? eventDTO(p.event as DecodedGroupedEvent) : null, - whoAssigned: p.whoAssigned ? userDTO(p.whoAssigned as UserDBScheme) : null, + assignedBy: p.whoAssigned ? userDTO(p.whoAssigned as UserDBScheme) : null, + assignee: p.assignee ? userDTO(p.assignee as UserDBScheme) : null, daysRepeated: p.daysRepeated ?? null, }), diff --git a/workers/webhook/tests/__mocks__/assignee-notify.ts b/workers/webhook/tests/__mocks__/assignee-notify.ts index 086f557b..2ce8ce3e 100644 --- a/workers/webhook/tests/__mocks__/assignee-notify.ts +++ b/workers/webhook/tests/__mocks__/assignee-notify.ts @@ -28,6 +28,10 @@ export default { name: 'John Doe', email: 'john@example.com', }, + assignee: { + name: 'Jane Smith', + email: 'jane@example.com', + }, daysRepeated: 3, }, } as AssigneeNotification; diff --git a/workers/webhook/tests/provider.test.ts b/workers/webhook/tests/provider.test.ts index 5544141b..d147d534 100644 --- a/workers/webhook/tests/provider.test.ts +++ b/workers/webhook/tests/provider.test.ts @@ -283,16 +283,20 @@ describe('WebhookProvider', () => { }); }); - it('should include whoAssigned DTO for "assignee"', async () => { + it('should include assignedBy, assignee and event DTOs for "assignee"', async () => { const provider = new WebhookProvider(); await provider.send(webhookEndpointSample, AssigneeNotifyMock); const payload = getDeliveredPayload(); - const who = payload.whoAssigned as Record; - - expect(who).toHaveProperty('name', 'John Doe'); - expect(who).toHaveProperty('email', 'john@example.com'); + const assignedBy = payload.assignedBy as Record; + const assignee = payload.assignee as Record; + + expect(assignedBy).toHaveProperty('name', 'John Doe'); + expect(assignedBy).toHaveProperty('email', 'john@example.com'); + expect(assignee).toHaveProperty('name', 'Jane Smith'); + expect(assignee).toHaveProperty('email', 'jane@example.com'); + expect(payload).toHaveProperty('event'); expect(payload).toHaveProperty('daysRepeated', 3); }); From 9a2c1a4888ea6e287f2dd53d71bed04de8d1d211 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:51:21 +0300 Subject: [PATCH 16/16] refactor(webhook): improve redirect handling in webhook deliverer and update test mocks --- workers/email/tests/provider.test.ts | 7 ++++++- workers/webhook/package.json | 2 +- workers/webhook/src/deliverer.ts | 10 +++++++++- workers/webhook/tests/provider.test.ts | 2 +- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/workers/email/tests/provider.test.ts b/workers/email/tests/provider.test.ts index d9fe9f40..f83a59d0 100644 --- a/workers/email/tests/provider.test.ts +++ b/workers/email/tests/provider.test.ts @@ -10,7 +10,7 @@ nodemailerMock.createTransport = jest.fn(() => ({ jest.mock('nodemailer', () => nodemailerMock); -import { DecodedGroupedEvent, ProjectDBScheme } from '@hawk.so/types'; +import { DecodedGroupedEvent, ProjectDBScheme, UserDBScheme } from '@hawk.so/types'; import '../src/env'; import EmailProvider from '../src/provider'; import Templates from '../src/templates/names'; @@ -187,6 +187,11 @@ describe('EmailProvider', () => { password: '$argon2i$v=19$m=4096,t=3,p=1$QOo3u8uEor0t+nqCLpEW3g$aTCDEaHht9ro9VZDD1yZGpaTi+g1OWsfHbYd5TQBRPs', name: 'Hahahawk', }, + assignee: { + _id: new ObjectId('5ec3ffe769c0030022f88f25'), + email: 'assignee@email.ru', + name: 'Assignee User', + } as UserDBScheme, project: { _id: new ObjectId('5d206f7f9aaf7c0071d64596'), token: 'project-token', diff --git a/workers/webhook/package.json b/workers/webhook/package.json index 167692d6..91c8035d 100644 --- a/workers/webhook/package.json +++ b/workers/webhook/package.json @@ -6,6 +6,6 @@ "license": "MIT", "workerType": "sender/webhook", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jest" } } diff --git a/workers/webhook/src/deliverer.ts b/workers/webhook/src/deliverer.ts index 41fa7516..5e5a104b 100644 --- a/workers/webhook/src/deliverer.ts +++ b/workers/webhook/src/deliverer.ts @@ -84,7 +84,15 @@ export default class WebhookDeliverer { const status = res.statusCode || 0; - if (status >= HttpStatusCode.MultipleChoices && status <= HttpStatusCode.PermanentRedirect) { + const isRedirect = ( + status === HttpStatusCode.MovedPermanently || + status === HttpStatusCode.Found || + status === HttpStatusCode.SeeOther || + status === HttpStatusCode.TemporaryRedirect || + status === HttpStatusCode.PermanentRedirect + ) && res.headers.location; + + if (isRedirect) { this.logger.log('error', `Webhook blocked — redirect ${status} to ${res.headers.location} from ${endpoint}`); resolve(); diff --git a/workers/webhook/tests/provider.test.ts b/workers/webhook/tests/provider.test.ts index d147d534..878d2335 100644 --- a/workers/webhook/tests/provider.test.ts +++ b/workers/webhook/tests/provider.test.ts @@ -18,7 +18,7 @@ const deliver = jest.fn(); /** * Webhook Deliverer mock */ -jest.mock('./../src/deliverer.ts', () => { +jest.mock('../src/deliverer', () => { return jest.fn().mockImplementation(() => { return { deliver: deliver,