From e93783f99950b5fb3ada185db03a663cf738b958 Mon Sep 17 00:00:00 2001 From: Gabrieljr42 Date: Thu, 15 Oct 2026 09:44:58 -0300 Subject: [PATCH 1/3] fix(webhook): trim webhook URL and enforce http/https scheme in schema validation Persist a trimmed webhook URL and reject values that are not http or https or that contain whitespace. Verified with npx tsx src/api/integrations/event/webhook/webhook.url.test.ts, npm run lint, and npm run build. Signed-off-by: Gabrieljr42 --- .../event/webhook/webhook.controller.ts | 5 ++-- .../event/webhook/webhook.schema.ts | 7 +++++- .../event/webhook/webhook.url.test.ts | 25 +++++++++++++++++++ .../integrations/event/webhook/webhook.url.ts | 7 ++++++ 4 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 src/api/integrations/event/webhook/webhook.url.test.ts create mode 100644 src/api/integrations/event/webhook/webhook.url.ts diff --git a/src/api/integrations/event/webhook/webhook.controller.ts b/src/api/integrations/event/webhook/webhook.controller.ts index 7f1dd8dc0e..e11e5c77a6 100644 --- a/src/api/integrations/event/webhook/webhook.controller.ts +++ b/src/api/integrations/event/webhook/webhook.controller.ts @@ -9,6 +9,7 @@ import axios, { AxiosInstance } from 'axios'; import * as jwt from 'jsonwebtoken'; import { EmitData, EventController, EventControllerInterface } from '../event.controller'; +import { persistedWebhookUrl } from './webhook.url'; export class WebhookController extends EventController implements EventControllerInterface { private readonly logger = new Logger('WebhookController'); @@ -37,7 +38,7 @@ export class WebhookController extends EventController implements EventControlle update: { enabled: data.webhook?.enabled, events: data.webhook?.events, - url: data.webhook?.url, + url: persistedWebhookUrl(data.webhook?.url), headers: data.webhook?.headers, webhookBase64: data.webhook.base64, webhookByEvents: data.webhook.byEvents, @@ -46,7 +47,7 @@ export class WebhookController extends EventController implements EventControlle enabled: data.webhook?.enabled, events: data.webhook?.events, instanceId: this.monitor.waInstances[instanceName].instanceId, - url: data.webhook?.url, + url: persistedWebhookUrl(data.webhook?.url), headers: data.webhook?.headers, webhookBase64: data.webhook.base64, webhookByEvents: data.webhook.byEvents, diff --git a/src/api/integrations/event/webhook/webhook.schema.ts b/src/api/integrations/event/webhook/webhook.schema.ts index 5d0f3724d6..d51a9fe8dd 100644 --- a/src/api/integrations/event/webhook/webhook.schema.ts +++ b/src/api/integrations/event/webhook/webhook.schema.ts @@ -2,6 +2,7 @@ import { JSONSchema7 } from 'json-schema'; import { v4 } from 'uuid'; import { EventController } from '../event.controller'; +import { WEBHOOK_HTTP_URL_PATTERN } from './webhook.url'; const isNotEmpty = (...propertyNames: string[]): JSONSchema7 => { const properties = {}; @@ -30,7 +31,11 @@ export const webhookSchema: JSONSchema7 = { type: 'object', properties: { enabled: { type: 'boolean' }, - url: { type: 'string' }, + url: { + type: 'string', + pattern: WEBHOOK_HTTP_URL_PATTERN, + description: 'The "url" must be an http or https URL without whitespace', + }, headers: { type: 'object' }, byEvents: { type: 'boolean' }, base64: { type: 'boolean' }, diff --git a/src/api/integrations/event/webhook/webhook.url.test.ts b/src/api/integrations/event/webhook/webhook.url.test.ts new file mode 100644 index 0000000000..1397588d5b --- /dev/null +++ b/src/api/integrations/event/webhook/webhook.url.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; + +import { validate } from 'jsonschema'; + +import { webhookSchema } from './webhook.schema'; +import { persistedWebhookUrl } from './webhook.url'; + +const padded = { webhook: { enabled: true, url: ' https://example.com/hook ' } }; +const paddedResult = validate(padded, webhookSchema); +assert.equal(paddedResult.valid, false, 'whitespace-padded URL must be rejected'); +console.log('whitespace-padded URL rejected'); + +const ftp = { webhook: { enabled: true, url: 'ftp://example.com/hook' } }; +const ftpResult = validate(ftp, webhookSchema); +assert.equal(ftpResult.valid, false, 'URL without http/https must be rejected'); +console.log('URL without http/https rejected'); + +const https = { webhook: { enabled: true, url: 'https://example.com/hook' } }; +const httpsResult = validate(https, webhookSchema); +assert.equal(httpsResult.valid, true, 'normal https:// URL must be accepted'); +console.log('normal https:// URL accepted'); + +const persisted = persistedWebhookUrl(' https://example.com/hook '); +assert.equal(persisted, 'https://example.com/hook'); +console.log('persisted value is trimmed', persisted); diff --git a/src/api/integrations/event/webhook/webhook.url.ts b/src/api/integrations/event/webhook/webhook.url.ts new file mode 100644 index 0000000000..72adc1027e --- /dev/null +++ b/src/api/integrations/event/webhook/webhook.url.ts @@ -0,0 +1,7 @@ +/** http(s) URL with no whitespace. Anchored so the schema rejects any other scheme or padding. */ +export const WEBHOOK_HTTP_URL_PATTERN = '^https?:\\/\\/\\S+$'; + +/** Value stored for a webhook URL. Callers persist this, not the raw input. */ +export function persistedWebhookUrl(url: string | undefined): string | undefined { + return typeof url === 'string' ? url.trim() : url; +} From 8d1da2623d4bfe14857c7da0df59708fa652362e Mon Sep 17 00:00:00 2001 From: Gabrieljr42 Date: Thu, 15 Oct 2026 10:33:56 -0300 Subject: [PATCH 2/3] fix(webhook): validate nested URLs and test controller persistence --- .github/workflows/check_code_quality.yml | 5 +- package.json | 1 + .../event/webhook/webhook.controller.ts | 9 +- .../event/webhook/webhook.schema.ts | 8 +- .../event/webhook/webhook.url.test.ts | 104 ++++++++++++++---- .../integrations/event/webhook/webhook.url.ts | 30 ++++- src/validate/instance.schema.ts | 6 + 7 files changed, 126 insertions(+), 37 deletions(-) diff --git a/.github/workflows/check_code_quality.yml b/.github/workflows/check_code_quality.yml index df156f2117..e529109c04 100644 --- a/.github/workflows/check_code_quality.yml +++ b/.github/workflows/check_code_quality.yml @@ -35,8 +35,11 @@ jobs: - name: Check linting run: npm run lint:check + - name: Test webhook URL validation and persistence + run: npm run test:webhook + - name: Generate Prisma client run: npm run db:generate - name: Check build - run: npm run build \ No newline at end of file + run: npm run build diff --git a/package.json b/package.json index 56e32fcc8a..c258ae1f36 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "start:prod": "node dist/main", "dev:server": "tsx watch ./src/main.ts", "test": "tsx watch ./test/all.test.ts", + "test:webhook": "tsx --test src/api/integrations/event/webhook/webhook.url.test.ts", "lint": "eslint --fix --ext .ts src", "lint:check": "eslint --ext .ts src", "commit": "cz", diff --git a/src/api/integrations/event/webhook/webhook.controller.ts b/src/api/integrations/event/webhook/webhook.controller.ts index e11e5c77a6..3219ab08ad 100644 --- a/src/api/integrations/event/webhook/webhook.controller.ts +++ b/src/api/integrations/event/webhook/webhook.controller.ts @@ -4,7 +4,6 @@ import { WAMonitoringService } from '@api/services/monitor.service'; import { wa } from '@api/types/wa.types'; import { configService, Log, Webhook } from '@config/env.config'; import { Logger } from '@config/logger.config'; -// import { BadRequestException } from '@exceptions'; import axios, { AxiosInstance } from 'axios'; import * as jwt from 'jsonwebtoken'; @@ -19,9 +18,7 @@ export class WebhookController extends EventController implements EventControlle } override async set(instanceName: string, data: EventDto): Promise { - // if (!/^(https?:\/\/)/.test(data.webhook.url)) { - // throw new BadRequestException('Invalid "url" property'); - // } + const url = persistedWebhookUrl(data.webhook?.url); if (!data.webhook?.enabled) { data.webhook.events = []; @@ -38,7 +35,7 @@ export class WebhookController extends EventController implements EventControlle update: { enabled: data.webhook?.enabled, events: data.webhook?.events, - url: persistedWebhookUrl(data.webhook?.url), + url, headers: data.webhook?.headers, webhookBase64: data.webhook.base64, webhookByEvents: data.webhook.byEvents, @@ -47,7 +44,7 @@ export class WebhookController extends EventController implements EventControlle enabled: data.webhook?.enabled, events: data.webhook?.events, instanceId: this.monitor.waInstances[instanceName].instanceId, - url: persistedWebhookUrl(data.webhook?.url), + url, headers: data.webhook?.headers, webhookBase64: data.webhook.base64, webhookByEvents: data.webhook.byEvents, diff --git a/src/api/integrations/event/webhook/webhook.schema.ts b/src/api/integrations/event/webhook/webhook.schema.ts index d51a9fe8dd..7b1bcbf88a 100644 --- a/src/api/integrations/event/webhook/webhook.schema.ts +++ b/src/api/integrations/event/webhook/webhook.schema.ts @@ -2,7 +2,7 @@ import { JSONSchema7 } from 'json-schema'; import { v4 } from 'uuid'; import { EventController } from '../event.controller'; -import { WEBHOOK_HTTP_URL_PATTERN } from './webhook.url'; +import { webhookUrlSchema } from './webhook.url'; const isNotEmpty = (...propertyNames: string[]): JSONSchema7 => { const properties = {}; @@ -31,11 +31,7 @@ export const webhookSchema: JSONSchema7 = { type: 'object', properties: { enabled: { type: 'boolean' }, - url: { - type: 'string', - pattern: WEBHOOK_HTTP_URL_PATTERN, - description: 'The "url" must be an http or https URL without whitespace', - }, + url: webhookUrlSchema, headers: { type: 'object' }, byEvents: { type: 'boolean' }, base64: { type: 'boolean' }, diff --git a/src/api/integrations/event/webhook/webhook.url.test.ts b/src/api/integrations/event/webhook/webhook.url.test.ts index 1397588d5b..ca6ce45237 100644 --- a/src/api/integrations/event/webhook/webhook.url.test.ts +++ b/src/api/integrations/event/webhook/webhook.url.test.ts @@ -1,25 +1,89 @@ import assert from 'node:assert/strict'; +import { mock, test } from 'node:test'; +import type { PrismaRepository } from '@api/repository/repository.service'; +import type { WAMonitoringService } from '@api/services/monitor.service'; +import type { Prisma } from '@prisma/client'; +import { instanceSchema } from '@validate/instance.schema'; import { validate } from 'jsonschema'; +import { WebhookController } from './webhook.controller'; import { webhookSchema } from './webhook.schema'; -import { persistedWebhookUrl } from './webhook.url'; - -const padded = { webhook: { enabled: true, url: ' https://example.com/hook ' } }; -const paddedResult = validate(padded, webhookSchema); -assert.equal(paddedResult.valid, false, 'whitespace-padded URL must be rejected'); -console.log('whitespace-padded URL rejected'); - -const ftp = { webhook: { enabled: true, url: 'ftp://example.com/hook' } }; -const ftpResult = validate(ftp, webhookSchema); -assert.equal(ftpResult.valid, false, 'URL without http/https must be rejected'); -console.log('URL without http/https rejected'); - -const https = { webhook: { enabled: true, url: 'https://example.com/hook' } }; -const httpsResult = validate(https, webhookSchema); -assert.equal(httpsResult.valid, true, 'normal https:// URL must be accepted'); -console.log('normal https:// URL accepted'); - -const persisted = persistedWebhookUrl(' https://example.com/hook '); -assert.equal(persisted, 'https://example.com/hook'); -console.log('persisted value is trimmed', persisted); + +const validUrls = [ + 'https://example.com/hook', + 'http://localhost:8080/hook', + 'https://[::1]:8443/hook?token=abc', + 'HTTPS://example.com/hook', + 'hTtPs://example.com/hook', +]; + +const invalidUrls = [ + ' https://example.com/hook ', + 'ftp://example.com/hook', + 'https://', + 'https://?token=abc', + 'https://#fragment', + 'https:///hook', + 'https://example.com/hook with spaces', + 'https://example.com/hook\n', + 'https://example.com/hook\r\n', +]; + +for (const [name, schema] of [ + ['webhook settings', webhookSchema], + ['instance creation', instanceSchema], +] as const) { + test(`${name} accepts valid HTTP(S) URLs`, () => { + for (const url of validUrls) { + assert.equal(validate({ webhook: { enabled: true, url } }, schema).valid, true, url); + } + }); + + test(`${name} rejects invalid webhook URLs`, () => { + for (const url of invalidUrls) { + assert.equal(validate({ webhook: { enabled: true, url } }, schema).valid, false, JSON.stringify(url)); + } + }); +} + +test('instance creation only requires a URL when webhook settings are supplied', () => { + assert.equal(validate({ instanceName: 'test-instance' }, instanceSchema).valid, true); + assert.equal(validate({ instanceName: 'test-instance', webhook: {} }, instanceSchema).valid, false); +}); + +test('controller persists trimmed URLs on both create and update', async () => { + const upsert = mock.fn(async (args: Prisma.WebhookUpsertArgs) => args.create); + const prisma = { webhook: { upsert } } as unknown as PrismaRepository; + const monitor = { + waInstances: { 'test-instance': { instanceId: 'test-instance-id' } }, + } as unknown as WAMonitoringService; + const controller = new WebhookController(prisma, monitor); + + await controller.set('test-instance', { + webhook: { enabled: true, events: [], url: ' HTTPS://example.com/hook ' }, + }); + + assert.equal(upsert.mock.callCount(), 1); + const persisted = upsert.mock.calls[0].arguments[0]; + assert.equal(persisted.where.instanceId, 'test-instance-id'); + assert.equal(persisted.create.url, 'https://example.com/hook'); + assert.equal(persisted.update.url, 'https://example.com/hook'); +}); + +test('controller rejects malformed URLs before persistence', async () => { + const upsert = mock.fn(); + const controller = new WebhookController( + { webhook: { upsert } } as unknown as PrismaRepository, + {} as WAMonitoringService, + ); + + for (const url of ['https://?', 'https://example.com:invalid/hook', 'https://[invalid]/hook', 'ftp://example.com']) { + await assert.rejects(controller.set('test-instance', { webhook: { enabled: true, events: [], url } }), { + status: 400, + error: 'Bad Request', + message: ['The "url" must be a valid http or https URL'], + }); + } + assert.equal(upsert.mock.callCount(), 0); +}); diff --git a/src/api/integrations/event/webhook/webhook.url.ts b/src/api/integrations/event/webhook/webhook.url.ts index 72adc1027e..28788fd426 100644 --- a/src/api/integrations/event/webhook/webhook.url.ts +++ b/src/api/integrations/event/webhook/webhook.url.ts @@ -1,7 +1,29 @@ -/** http(s) URL with no whitespace. Anchored so the schema rejects any other scheme or padding. */ -export const WEBHOOK_HTTP_URL_PATTERN = '^https?:\\/\\/\\S+$'; +import type { JSONSchema7 } from 'json-schema'; + +export const WEBHOOK_HTTP_URL_PATTERN = '^[hH][tT][tT][pP][sS]?://[^\\s/?#]+\\S*(?![\\s\\S])'; + +export const webhookUrlSchema: JSONSchema7 = { + type: 'string', + format: 'uri', + pattern: WEBHOOK_HTTP_URL_PATTERN, + description: 'The "url" must be a valid http or https URL without whitespace', +}; + +const webhookUrlPattern = new RegExp(WEBHOOK_HTTP_URL_PATTERN); -/** Value stored for a webhook URL. Callers persist this, not the raw input. */ export function persistedWebhookUrl(url: string | undefined): string | undefined { - return typeof url === 'string' ? url.trim() : url; + if (typeof url !== 'string') { + return url; + } + + const trimmedUrl = url.trim(); + if (!webhookUrlPattern.test(trimmedUrl) || !URL.canParse(trimmedUrl)) { + throw { + status: 400, + error: 'Bad Request', + message: ['The "url" must be a valid http or https URL'], + }; + } + + return trimmedUrl.replace(/^https?:/i, (scheme) => scheme.toLowerCase()); } diff --git a/src/validate/instance.schema.ts b/src/validate/instance.schema.ts index a0553b6667..f00f7486ae 100644 --- a/src/validate/instance.schema.ts +++ b/src/validate/instance.schema.ts @@ -1,3 +1,4 @@ +import { webhookUrlSchema } from '@api/integrations/event/webhook/webhook.url'; import { Integration } from '@api/types/wa.types'; import { JSONSchema7 } from 'json-schema'; import { v4 } from 'uuid'; @@ -51,6 +52,11 @@ export const instanceSchema: JSONSchema7 = { proxyUsername: { type: 'string' }, proxyPassword: { type: 'string' }, // Webhook + webhook: { + type: 'object', + properties: { url: webhookUrlSchema }, + required: ['url'], + }, webhookUrl: { type: 'string' }, webhookByEvents: { type: 'boolean' }, webhookBase64: { type: 'boolean' }, From 93623aca02f9861797dca1a7e2cd7941eb74dbae Mon Sep 17 00:00:00 2001 From: Gabrieljr42 Date: Thu, 15 Oct 2026 10:38:17 -0300 Subject: [PATCH 3/3] fix(webhook): validate parsed URLs at the schema boundary --- .../event/webhook/webhook.url.test.ts | 2 ++ src/api/integrations/event/webhook/webhook.url.ts | 15 +++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/api/integrations/event/webhook/webhook.url.test.ts b/src/api/integrations/event/webhook/webhook.url.test.ts index ca6ce45237..36e9bb46ab 100644 --- a/src/api/integrations/event/webhook/webhook.url.test.ts +++ b/src/api/integrations/event/webhook/webhook.url.test.ts @@ -25,6 +25,8 @@ const invalidUrls = [ 'https://?token=abc', 'https://#fragment', 'https:///hook', + 'https://example.com:invalid/hook', + 'https://[invalid]/hook', 'https://example.com/hook with spaces', 'https://example.com/hook\n', 'https://example.com/hook\r\n', diff --git a/src/api/integrations/event/webhook/webhook.url.ts b/src/api/integrations/event/webhook/webhook.url.ts index 28788fd426..7d97259130 100644 --- a/src/api/integrations/event/webhook/webhook.url.ts +++ b/src/api/integrations/event/webhook/webhook.url.ts @@ -1,23 +1,30 @@ import type { JSONSchema7 } from 'json-schema'; +import { Validator } from 'jsonschema'; export const WEBHOOK_HTTP_URL_PATTERN = '^[hH][tT][tT][pP][sS]?://[^\\s/?#]+\\S*(?![\\s\\S])'; +const webhookUrlPattern = new RegExp(WEBHOOK_HTTP_URL_PATTERN); + +function isWebhookUrl(url: unknown): boolean { + return typeof url === 'string' && webhookUrlPattern.test(url) && URL.canParse(url); +} + +Validator.prototype.customFormats['webhook-url'] = isWebhookUrl; + export const webhookUrlSchema: JSONSchema7 = { type: 'string', - format: 'uri', + format: 'webhook-url', pattern: WEBHOOK_HTTP_URL_PATTERN, description: 'The "url" must be a valid http or https URL without whitespace', }; -const webhookUrlPattern = new RegExp(WEBHOOK_HTTP_URL_PATTERN); - export function persistedWebhookUrl(url: string | undefined): string | undefined { if (typeof url !== 'string') { return url; } const trimmedUrl = url.trim(); - if (!webhookUrlPattern.test(trimmedUrl) || !URL.canParse(trimmedUrl)) { + if (!isWebhookUrl(trimmedUrl)) { throw { status: 400, error: 'Bad Request',