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 7f1dd8dc0e..3219ab08ad 100644 --- a/src/api/integrations/event/webhook/webhook.controller.ts +++ b/src/api/integrations/event/webhook/webhook.controller.ts @@ -4,11 +4,11 @@ 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'; 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'); @@ -18,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 = []; @@ -37,7 +35,7 @@ export class WebhookController extends EventController implements EventControlle update: { enabled: data.webhook?.enabled, events: data.webhook?.events, - url: data.webhook?.url, + url, headers: data.webhook?.headers, webhookBase64: data.webhook.base64, webhookByEvents: data.webhook.byEvents, @@ -46,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: 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 5d0f3724d6..7b1bcbf88a 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 { webhookUrlSchema } from './webhook.url'; const isNotEmpty = (...propertyNames: string[]): JSONSchema7 => { const properties = {}; @@ -30,7 +31,7 @@ export const webhookSchema: JSONSchema7 = { type: 'object', properties: { enabled: { type: 'boolean' }, - url: { type: 'string' }, + 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 new file mode 100644 index 0000000000..36e9bb46ab --- /dev/null +++ b/src/api/integrations/event/webhook/webhook.url.test.ts @@ -0,0 +1,91 @@ +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'; + +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:invalid/hook', + 'https://[invalid]/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 new file mode 100644 index 0000000000..7d97259130 --- /dev/null +++ b/src/api/integrations/event/webhook/webhook.url.ts @@ -0,0 +1,36 @@ +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: 'webhook-url', + pattern: WEBHOOK_HTTP_URL_PATTERN, + description: 'The "url" must be a valid http or https URL without whitespace', +}; + +export function persistedWebhookUrl(url: string | undefined): string | undefined { + if (typeof url !== 'string') { + return url; + } + + const trimmedUrl = url.trim(); + if (!isWebhookUrl(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' },