@@ -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} ) ;
0 commit comments