Skip to content

Commit 24b5477

Browse files
authored
feat: allow specifying webhook request headers (#1285)
Address #1078 Ref: HDX-2225 <img width="842" height="688" alt="image" src="https://github.com/user-attachments/assets/66d8279c-d596-4d63-af05-14d74a1d7b54" />
1 parent 0325416 commit 24b5477

File tree

5 files changed

+330
-9
lines changed

5 files changed

+330
-9
lines changed

.changeset/kind-hotels-arrive.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@hyperdx/api": patch
3+
"@hyperdx/app": patch
4+
---
5+
6+
feat: allow specifying webhook request headers

packages/api/src/routers/api/__tests__/webhooks.test.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,226 @@ describe('webhooks router', () => {
180180

181181
await agent.delete('/webhooks/invalid-id').expect(400);
182182
});
183+
184+
describe('Header validation', () => {
185+
it('POST / - accepts valid header names', async () => {
186+
const { agent } = await getLoggedInAgent(server);
187+
188+
const validHeaders = {
189+
'Content-Type': 'application/json',
190+
Authorization: 'Bearer token',
191+
'X-Custom-Header': 'value',
192+
'User-Agent': 'test',
193+
'x-api-key': 'secret',
194+
'custom!header#test': 'value',
195+
};
196+
197+
const response = await agent
198+
.post('/webhooks')
199+
.send({
200+
...MOCK_WEBHOOK,
201+
url: 'https://example.com/valid-headers',
202+
headers: validHeaders,
203+
})
204+
.expect(200);
205+
206+
expect(response.body.data.headers).toMatchObject(validHeaders);
207+
});
208+
209+
it('POST / - rejects header names starting with numbers', async () => {
210+
const { agent } = await getLoggedInAgent(server);
211+
212+
const response = await agent
213+
.post('/webhooks')
214+
.send({
215+
...MOCK_WEBHOOK,
216+
url: 'https://example.com/invalid-header-name',
217+
headers: {
218+
'123Invalid': 'value',
219+
},
220+
})
221+
.expect(400);
222+
223+
expect(Array.isArray(response.body)).toBe(true);
224+
expect(response.body[0].type).toBe('Body');
225+
expect(response.body[0].errors).toBeDefined();
226+
});
227+
228+
it('POST / - rejects empty header names', async () => {
229+
const { agent } = await getLoggedInAgent(server);
230+
231+
const response = await agent
232+
.post('/webhooks')
233+
.send({
234+
...MOCK_WEBHOOK,
235+
url: 'https://example.com/empty-header-name',
236+
headers: {
237+
'': 'value',
238+
},
239+
})
240+
.expect(400);
241+
242+
expect(Array.isArray(response.body)).toBe(true);
243+
expect(response.body[0].type).toBe('Body');
244+
expect(response.body[0].errors).toBeDefined();
245+
});
246+
247+
it('POST / - rejects header names with invalid characters', async () => {
248+
const { agent } = await getLoggedInAgent(server);
249+
250+
const invalidHeaderNames = [
251+
{ 'Header Name': 'value' }, // space
252+
{ 'Header\nName': 'value' }, // newline
253+
{ 'Header\rName': 'value' }, // carriage return
254+
{ 'Header\tName': 'value' }, // tab
255+
{ 'Header@Name': 'value' }, // @ not allowed
256+
{ 'Header[Name]': 'value' }, // brackets not allowed
257+
];
258+
259+
for (const headers of invalidHeaderNames) {
260+
const response = await agent
261+
.post('/webhooks')
262+
.send({
263+
...MOCK_WEBHOOK,
264+
url: `https://example.com/invalid-header-${Math.random()}`,
265+
headers,
266+
})
267+
.expect(400);
268+
269+
expect(Array.isArray(response.body)).toBe(true);
270+
expect(response.body[0].type).toBe('Body');
271+
expect(response.body[0].errors).toBeDefined();
272+
}
273+
});
274+
275+
it('POST / - accepts valid header values', async () => {
276+
const { agent } = await getLoggedInAgent(server);
277+
278+
const validHeaders = {
279+
Authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
280+
'Content-Type': 'application/json; charset=utf-8',
281+
'X-Api-Key': 'abc123-def456-ghi789',
282+
'User-Agent': 'Mozilla/5.0 (compatible; TestBot/1.0)',
283+
'Custom-Header': 'value with spaces and special chars: !@#$%^&*()',
284+
};
285+
286+
const response = await agent
287+
.post('/webhooks')
288+
.send({
289+
...MOCK_WEBHOOK,
290+
url: 'https://example.com/valid-header-values',
291+
headers: validHeaders,
292+
})
293+
.expect(200);
294+
295+
expect(response.body.data.headers).toMatchObject(validHeaders);
296+
});
297+
298+
it('POST / - rejects header values with CRLF injection', async () => {
299+
const { agent } = await getLoggedInAgent(server);
300+
301+
const response = await agent
302+
.post('/webhooks')
303+
.send({
304+
...MOCK_WEBHOOK,
305+
url: 'https://example.com/crlf-injection',
306+
headers: {
307+
'X-Custom-Header': 'value\r\nX-Injected-Header: malicious',
308+
},
309+
})
310+
.expect(400);
311+
312+
expect(Array.isArray(response.body)).toBe(true);
313+
expect(response.body[0].type).toBe('Body');
314+
expect(response.body[0].errors).toBeDefined();
315+
});
316+
317+
it('POST / - rejects header values with tab characters', async () => {
318+
const { agent } = await getLoggedInAgent(server);
319+
320+
const response = await agent
321+
.post('/webhooks')
322+
.send({
323+
...MOCK_WEBHOOK,
324+
url: 'https://example.com/tab-injection',
325+
headers: {
326+
'X-Custom-Header': 'value\twith\ttabs',
327+
},
328+
})
329+
.expect(400);
330+
331+
expect(Array.isArray(response.body)).toBe(true);
332+
expect(response.body[0].type).toBe('Body');
333+
expect(response.body[0].errors).toBeDefined();
334+
});
335+
336+
it('POST / - rejects header values with control characters', async () => {
337+
const { agent } = await getLoggedInAgent(server);
338+
339+
// Test various control characters
340+
const controlCharTests = [
341+
'\x00', // null
342+
'\x01', // start of heading
343+
'\x0B', // vertical tab
344+
'\x0C', // form feed
345+
'\x1F', // unit separator
346+
'\x7F', // delete
347+
];
348+
349+
for (const controlChar of controlCharTests) {
350+
const response = await agent
351+
.post('/webhooks')
352+
.send({
353+
...MOCK_WEBHOOK,
354+
url: `https://example.com/control-char-${Math.random()}`,
355+
headers: {
356+
'X-Custom-Header': `value${controlChar}test`,
357+
},
358+
})
359+
.expect(400);
360+
361+
expect(Array.isArray(response.body)).toBe(true);
362+
expect(response.body[0].type).toBe('Body');
363+
expect(response.body[0].errors).toBeDefined();
364+
}
365+
});
366+
367+
it('POST / - rejects header values with newline characters', async () => {
368+
const { agent } = await getLoggedInAgent(server);
369+
370+
const response = await agent
371+
.post('/webhooks')
372+
.send({
373+
...MOCK_WEBHOOK,
374+
url: 'https://example.com/newline-injection',
375+
headers: {
376+
'X-Custom-Header': 'value\nwith\nnewlines',
377+
},
378+
})
379+
.expect(400);
380+
381+
expect(Array.isArray(response.body)).toBe(true);
382+
expect(response.body[0].type).toBe('Body');
383+
expect(response.body[0].errors).toBeDefined();
384+
});
385+
386+
it('POST / - rejects header values with carriage return characters', async () => {
387+
const { agent } = await getLoggedInAgent(server);
388+
389+
const response = await agent
390+
.post('/webhooks')
391+
.send({
392+
...MOCK_WEBHOOK,
393+
url: 'https://example.com/carriage-return-injection',
394+
headers: {
395+
'X-Custom-Header': 'value\rwith\rcarriage\rreturns',
396+
},
397+
})
398+
.expect(400);
399+
400+
expect(Array.isArray(response.body)).toBe(true);
401+
expect(response.body[0].type).toBe('Body');
402+
expect(response.body[0].errors).toBeDefined();
403+
});
404+
});
183405
});

packages/api/src/routers/api/webhooks.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,32 @@ router.get(
3737
},
3838
);
3939

40+
const httpHeaderNameValidator = z
41+
.string()
42+
.min(1, 'Header name cannot be empty')
43+
.regex(
44+
/^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/,
45+
"Invalid header name. Only alphanumeric characters and !#$%&'*+-.^_`|~ are allowed",
46+
)
47+
.refine(name => !name.match(/^\d/), 'Header name cannot start with a number');
48+
49+
// Validation for header values: no control characters allowed
50+
const httpHeaderValueValidator = z
51+
.string()
52+
// eslint-disable-next-line no-control-regex
53+
.refine(val => !/[\r\n\t\x00-\x1F\x7F]/.test(val), {
54+
message: 'Header values cannot contain control characters',
55+
});
56+
4057
router.post(
4158
'/',
4259
validateRequest({
4360
body: z.object({
4461
body: z.string().optional(),
4562
description: z.string().optional(),
46-
headers: z.record(z.string()).optional(),
63+
headers: z
64+
.record(httpHeaderNameValidator, httpHeaderValueValidator)
65+
.optional(),
4766
name: z.string(),
4867
queryParams: z.record(z.string()).optional(),
4968
service: z.nativeEnum(WebhookService),

packages/api/src/tasks/__tests__/checkAlerts.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,7 +1149,11 @@ describe('checkAlerts', () => {
11491149
body: JSON.stringify({
11501150
text: '{{link}} | {{title}}',
11511151
}),
1152-
headers: { 'Content-Type': 'application/json' },
1152+
headers: {
1153+
'Content-Type': 'application/json',
1154+
'X-Custom-Header': 'custom-value',
1155+
Authorization: 'Bearer test-token',
1156+
},
11531157
}).save();
11541158
const webhooks = await Webhook.find({});
11551159
const teamWebhooksById = new Map<string, typeof webhook>(
@@ -1330,14 +1334,16 @@ describe('checkAlerts', () => {
13301334
expect(history2.counts).toBe(0);
13311335
expect(history2.createdAt).toEqual(new Date('2023-11-16T22:15:00.000Z'));
13321336

1333-
// check if generic webhook was triggered, injected, and parsed, and sent correctly
1337+
// check if generic webhook was triggered, injected, and parsed, and sent correctly with custom headers
13341338
expect(fetchMock).toHaveBeenCalledWith('https://webhook.site/123', {
13351339
method: 'POST',
13361340
body: JSON.stringify({
13371341
text: `http://app:8080/dashboards/${dashboard.id}?from=1700170200000&granularity=5+minute&to=1700174700000 | Alert for "Logs Count" in "My Dashboard" - 3 exceeds 1`,
13381342
}),
13391343
headers: {
13401344
'Content-Type': 'application/json',
1345+
'X-Custom-Header': 'custom-value',
1346+
Authorization: 'Bearer test-token',
13411347
},
13421348
});
13431349
});

0 commit comments

Comments
 (0)