diff --git a/.changeset/kind-hotels-arrive.md b/.changeset/kind-hotels-arrive.md new file mode 100644 index 000000000..63ac2b01a --- /dev/null +++ b/.changeset/kind-hotels-arrive.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/api": patch +"@hyperdx/app": patch +--- + +feat: allow specifying webhook request headers diff --git a/packages/api/src/routers/api/__tests__/webhooks.test.ts b/packages/api/src/routers/api/__tests__/webhooks.test.ts index cfd8d55c5..672380562 100644 --- a/packages/api/src/routers/api/__tests__/webhooks.test.ts +++ b/packages/api/src/routers/api/__tests__/webhooks.test.ts @@ -180,4 +180,226 @@ describe('webhooks router', () => { await agent.delete('/webhooks/invalid-id').expect(400); }); + + describe('Header validation', () => { + it('POST / - accepts valid header names', async () => { + const { agent } = await getLoggedInAgent(server); + + const validHeaders = { + 'Content-Type': 'application/json', + Authorization: 'Bearer token', + 'X-Custom-Header': 'value', + 'User-Agent': 'test', + 'x-api-key': 'secret', + 'custom!header#test': 'value', + }; + + const response = await agent + .post('/webhooks') + .send({ + ...MOCK_WEBHOOK, + url: 'https://example.com/valid-headers', + headers: validHeaders, + }) + .expect(200); + + expect(response.body.data.headers).toMatchObject(validHeaders); + }); + + it('POST / - rejects header names starting with numbers', async () => { + const { agent } = await getLoggedInAgent(server); + + const response = await agent + .post('/webhooks') + .send({ + ...MOCK_WEBHOOK, + url: 'https://example.com/invalid-header-name', + headers: { + '123Invalid': 'value', + }, + }) + .expect(400); + + expect(Array.isArray(response.body)).toBe(true); + expect(response.body[0].type).toBe('Body'); + expect(response.body[0].errors).toBeDefined(); + }); + + it('POST / - rejects empty header names', async () => { + const { agent } = await getLoggedInAgent(server); + + const response = await agent + .post('/webhooks') + .send({ + ...MOCK_WEBHOOK, + url: 'https://example.com/empty-header-name', + headers: { + '': 'value', + }, + }) + .expect(400); + + expect(Array.isArray(response.body)).toBe(true); + expect(response.body[0].type).toBe('Body'); + expect(response.body[0].errors).toBeDefined(); + }); + + it('POST / - rejects header names with invalid characters', async () => { + const { agent } = await getLoggedInAgent(server); + + const invalidHeaderNames = [ + { 'Header Name': 'value' }, // space + { 'Header\nName': 'value' }, // newline + { 'Header\rName': 'value' }, // carriage return + { 'Header\tName': 'value' }, // tab + { 'Header@Name': 'value' }, // @ not allowed + { 'Header[Name]': 'value' }, // brackets not allowed + ]; + + for (const headers of invalidHeaderNames) { + const response = await agent + .post('/webhooks') + .send({ + ...MOCK_WEBHOOK, + url: `https://example.com/invalid-header-${Math.random()}`, + headers, + }) + .expect(400); + + expect(Array.isArray(response.body)).toBe(true); + expect(response.body[0].type).toBe('Body'); + expect(response.body[0].errors).toBeDefined(); + } + }); + + it('POST / - accepts valid header values', async () => { + const { agent } = await getLoggedInAgent(server); + + const validHeaders = { + Authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', + 'Content-Type': 'application/json; charset=utf-8', + 'X-Api-Key': 'abc123-def456-ghi789', + 'User-Agent': 'Mozilla/5.0 (compatible; TestBot/1.0)', + 'Custom-Header': 'value with spaces and special chars: !@#$%^&*()', + }; + + const response = await agent + .post('/webhooks') + .send({ + ...MOCK_WEBHOOK, + url: 'https://example.com/valid-header-values', + headers: validHeaders, + }) + .expect(200); + + expect(response.body.data.headers).toMatchObject(validHeaders); + }); + + it('POST / - rejects header values with CRLF injection', async () => { + const { agent } = await getLoggedInAgent(server); + + const response = await agent + .post('/webhooks') + .send({ + ...MOCK_WEBHOOK, + url: 'https://example.com/crlf-injection', + headers: { + 'X-Custom-Header': 'value\r\nX-Injected-Header: malicious', + }, + }) + .expect(400); + + expect(Array.isArray(response.body)).toBe(true); + expect(response.body[0].type).toBe('Body'); + expect(response.body[0].errors).toBeDefined(); + }); + + it('POST / - rejects header values with tab characters', async () => { + const { agent } = await getLoggedInAgent(server); + + const response = await agent + .post('/webhooks') + .send({ + ...MOCK_WEBHOOK, + url: 'https://example.com/tab-injection', + headers: { + 'X-Custom-Header': 'value\twith\ttabs', + }, + }) + .expect(400); + + expect(Array.isArray(response.body)).toBe(true); + expect(response.body[0].type).toBe('Body'); + expect(response.body[0].errors).toBeDefined(); + }); + + it('POST / - rejects header values with control characters', async () => { + const { agent } = await getLoggedInAgent(server); + + // Test various control characters + const controlCharTests = [ + '\x00', // null + '\x01', // start of heading + '\x0B', // vertical tab + '\x0C', // form feed + '\x1F', // unit separator + '\x7F', // delete + ]; + + for (const controlChar of controlCharTests) { + const response = await agent + .post('/webhooks') + .send({ + ...MOCK_WEBHOOK, + url: `https://example.com/control-char-${Math.random()}`, + headers: { + 'X-Custom-Header': `value${controlChar}test`, + }, + }) + .expect(400); + + expect(Array.isArray(response.body)).toBe(true); + expect(response.body[0].type).toBe('Body'); + expect(response.body[0].errors).toBeDefined(); + } + }); + + it('POST / - rejects header values with newline characters', async () => { + const { agent } = await getLoggedInAgent(server); + + const response = await agent + .post('/webhooks') + .send({ + ...MOCK_WEBHOOK, + url: 'https://example.com/newline-injection', + headers: { + 'X-Custom-Header': 'value\nwith\nnewlines', + }, + }) + .expect(400); + + expect(Array.isArray(response.body)).toBe(true); + expect(response.body[0].type).toBe('Body'); + expect(response.body[0].errors).toBeDefined(); + }); + + it('POST / - rejects header values with carriage return characters', async () => { + const { agent } = await getLoggedInAgent(server); + + const response = await agent + .post('/webhooks') + .send({ + ...MOCK_WEBHOOK, + url: 'https://example.com/carriage-return-injection', + headers: { + 'X-Custom-Header': 'value\rwith\rcarriage\rreturns', + }, + }) + .expect(400); + + expect(Array.isArray(response.body)).toBe(true); + expect(response.body[0].type).toBe('Body'); + expect(response.body[0].errors).toBeDefined(); + }); + }); }); diff --git a/packages/api/src/routers/api/webhooks.ts b/packages/api/src/routers/api/webhooks.ts index 773fa77df..859c67555 100644 --- a/packages/api/src/routers/api/webhooks.ts +++ b/packages/api/src/routers/api/webhooks.ts @@ -37,13 +37,32 @@ router.get( }, ); +const httpHeaderNameValidator = z + .string() + .min(1, 'Header name cannot be empty') + .regex( + /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/, + "Invalid header name. Only alphanumeric characters and !#$%&'*+-.^_`|~ are allowed", + ) + .refine(name => !name.match(/^\d/), 'Header name cannot start with a number'); + +// Validation for header values: no control characters allowed +const httpHeaderValueValidator = z + .string() + // eslint-disable-next-line no-control-regex + .refine(val => !/[\r\n\t\x00-\x1F\x7F]/.test(val), { + message: 'Header values cannot contain control characters', + }); + router.post( '/', validateRequest({ body: z.object({ body: z.string().optional(), description: z.string().optional(), - headers: z.record(z.string()).optional(), + headers: z + .record(httpHeaderNameValidator, httpHeaderValueValidator) + .optional(), name: z.string(), queryParams: z.record(z.string()).optional(), service: z.nativeEnum(WebhookService), diff --git a/packages/api/src/tasks/__tests__/checkAlerts.test.ts b/packages/api/src/tasks/__tests__/checkAlerts.test.ts index 77a7d8fb3..ce211de42 100644 --- a/packages/api/src/tasks/__tests__/checkAlerts.test.ts +++ b/packages/api/src/tasks/__tests__/checkAlerts.test.ts @@ -1149,7 +1149,11 @@ describe('checkAlerts', () => { body: JSON.stringify({ text: '{{link}} | {{title}}', }), - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value', + Authorization: 'Bearer test-token', + }, }).save(); const webhooks = await Webhook.find({}); const teamWebhooksById = new Map( @@ -1330,7 +1334,7 @@ describe('checkAlerts', () => { expect(history2.counts).toBe(0); expect(history2.createdAt).toEqual(new Date('2023-11-16T22:15:00.000Z')); - // check if generic webhook was triggered, injected, and parsed, and sent correctly + // check if generic webhook was triggered, injected, and parsed, and sent correctly with custom headers expect(fetchMock).toHaveBeenCalledWith('https://webhook.site/123', { method: 'POST', body: JSON.stringify({ @@ -1338,6 +1342,8 @@ describe('checkAlerts', () => { }), headers: { 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value', + Authorization: 'Bearer test-token', }, }); }); diff --git a/packages/app/src/TeamPage.tsx b/packages/app/src/TeamPage.tsx index 35c6c4996..de2318bf6 100644 --- a/packages/app/src/TeamPage.tsx +++ b/packages/app/src/TeamPage.tsx @@ -4,6 +4,7 @@ import { HTTPError } from 'ky'; import { Button as BSButton, Modal as BSModal } from 'react-bootstrap'; import { CopyToClipboard } from 'react-copy-to-clipboard'; import { SubmitHandler, useForm } from 'react-hook-form'; +import type { ZodIssue } from 'zod'; import { json, jsonParseLinter } from '@codemirror/lang-json'; import { linter } from '@codemirror/lint'; import { EditorView } from '@codemirror/view'; @@ -692,6 +693,7 @@ type WebhookForm = { service: string; description?: string; body?: string; + headers?: string; }; export function CreateWebhookForm({ @@ -710,8 +712,27 @@ export function CreateWebhookForm({ }); const onSubmit: SubmitHandler = async values => { - const { service, name, url, description, body } = values; + const { service, name, url, description, body, headers } = values; try { + // Parse headers JSON if provided (API will validate the content) + let parsedHeaders: Record | undefined; + if (headers && headers.trim()) { + try { + parsedHeaders = JSON.parse(headers); + } catch (parseError) { + const errorMessage = + parseError instanceof Error + ? parseError.message + : 'Invalid JSON format'; + notifications.show({ + message: `Invalid JSON in headers: ${errorMessage}`, + color: 'red', + autoClose: 5000, + }); + return; + } + } + const response = await saveWebhook.mutateAsync({ service, name, @@ -721,6 +742,7 @@ export function CreateWebhookForm({ service === WebhookService.Generic && !body ? `{"text": "${DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE}"}` : body, + headers: parsedHeaders, }); notifications.show({ color: 'green', @@ -730,9 +752,38 @@ export function CreateWebhookForm({ onClose(); } catch (e) { console.error(e); - const message = - (e instanceof HTTPError ? (await e.response.json())?.message : null) || - 'Something went wrong. Please contact HyperDX team.'; + let message = 'Something went wrong. Please contact HyperDX team.'; + + if (e instanceof HTTPError) { + try { + const errorData = await e.response.json(); + // Handle Zod validation errors from zod-express-middleware + // The library returns errors in format: { error: { issues: [...] } } + if ( + errorData.error?.issues && + Array.isArray(errorData.error.issues) + ) { + // TODO: use a library to format Zod validation errors + // Format Zod validation errors + const validationErrors = errorData.error.issues + .map((issue: ZodIssue) => { + const path = issue.path.join('.'); + return `${path}: ${issue.message}`; + }) + .join(', '); + message = `Validation error: ${validationErrors}`; + } else if (errorData.message) { + message = errorData.message; + } else { + // Fallback: show the entire error object as JSON + message = JSON.stringify(errorData); + } + } catch (parseError) { + console.error('Failed to parse error response:', parseError); + // If parsing fails, use default message + } + } + notifications.show({ message, color: 'red', @@ -796,9 +847,26 @@ export function CreateWebhookForm({ /> {service === WebhookService.Generic && [ ,
+ form.setValue('headers', value)} + /> +
, + , +
, } - key="3" + key="5" className="mb-4" color="gray" >