Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/kind-hotels-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hyperdx/api": patch
"@hyperdx/app": patch
---

feat: allow specifying webhook request headers
222 changes: 222 additions & 0 deletions packages/api/src/routers/api/__tests__/webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
21 changes: 20 additions & 1 deletion packages/api/src/routers/api/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 8 additions & 2 deletions packages/api/src/tasks/__tests__/checkAlerts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, typeof webhook>(
Expand Down Expand Up @@ -1330,14 +1334,16 @@ 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({
text: `http://app:8080/dashboards/${dashboard.id}?from=1700170200000&granularity=5+minute&to=1700174700000 | Alert for "Logs Count" in "My Dashboard" - 3 exceeds 1`,
}),
headers: {
'Content-Type': 'application/json',
'X-Custom-Header': 'custom-value',
Authorization: 'Bearer test-token',
},
});
});
Expand Down
Loading
Loading