@@ -316,6 +316,43 @@ export function forgeWebhookRoutes(db: Db) {
316316 * POST /api/forge/webhook/gitlab
317317 * Handle incoming GitLab webhook payloads.
318318 */
319+ async function validateGitLabToken (
320+ db : Db ,
321+ projectId : string ,
322+ tokenHeader : string | undefined ,
323+ ) : Promise < boolean > {
324+ if ( ! tokenHeader ) return false ;
325+
326+ const rows = await db
327+ . select ( {
328+ webhookSecret : forgeWebhooks . webhookSecret ,
329+ } )
330+ . from ( forgeWebhooks )
331+ . where (
332+ and (
333+ eq ( forgeWebhooks . projectId , projectId ) ,
334+ eq ( forgeWebhooks . active , true ) ,
335+ ) ,
336+ ) ;
337+
338+ if ( rows . length === 0 ) return false ;
339+
340+ for ( const row of rows ) {
341+ if ( ! row . webhookSecret ) continue ;
342+
343+ const expected = Buffer . from ( row . webhookSecret ) ;
344+ const actual = Buffer . from ( tokenHeader ) ;
345+
346+ if (
347+ expected . length === actual . length &&
348+ crypto . timingSafeEqual ( expected , actual )
349+ ) {
350+ return true ;
351+ }
352+ }
353+
354+ return false ;
355+ }
319356 router . post ( "/forge/webhook/gitlab" , async ( req , res ) => {
320357 try {
321358 const gitlabEvent = req . headers [ "x-gitlab-event" ] as string ;
@@ -326,7 +363,12 @@ export function forgeWebhookRoutes(db: Db) {
326363 res . status ( 200 ) . json ( { ignored : true , reason : "Unhandled event type" } ) ;
327364 return ;
328365 }
329-
366+ const tokenHeader = req . headers [ "x-gitlab-token" ] as string | undefined ;
367+ const valid = await validateGitLabToken ( db , event . projectId , tokenHeader ) ;
368+ if ( ! valid ) {
369+ res . status ( 401 ) . json ( { error : "Invalid webhook token" } ) ;
370+ return ;
371+ }
330372 await storeWebhookDelivery ( db , event . projectId , "gitlab" , req . body as Record < string , unknown > , "received" ) ;
331373 const result = await forgeSync . processEvent ( event ) ;
332374 await storeWebhookDelivery ( db , event . projectId , "gitlab" , req . body as Record < string , unknown > , "processed" ) ;
@@ -355,6 +397,47 @@ export function forgeWebhookRoutes(db: Db) {
355397 * Handle incoming Forgejo webhook payloads.
356398 * Forgejo uses Gitea-compatible webhook format.
357399 */
400+ async function validateForgejoSignature (
401+ db : Db ,
402+ projectId : string ,
403+ rawBody : Buffer ,
404+ signatureHeader : string | undefined ,
405+ ) : Promise < boolean > {
406+ if ( ! signatureHeader ) return false ;
407+
408+ const rows = await db
409+ . select ( { webhookSecret : forgeWebhooks . webhookSecret } )
410+ . from ( forgeWebhooks )
411+ . where (
412+ and (
413+ eq ( forgeWebhooks . projectId , projectId ) ,
414+ eq ( forgeWebhooks . active , true ) ,
415+ ) ,
416+ ) ;
417+
418+ if ( rows . length === 0 || ! rows [ 0 ] . webhookSecret ) {
419+ return false ;
420+ }
421+
422+ const secret = rows [ 0 ] . webhookSecret ;
423+
424+ const hexSig = signatureHeader . startsWith ( "sha256=" )
425+ ? signatureHeader . substring ( 7 )
426+ : signatureHeader ;
427+
428+ const expected = Buffer . from ( hexSig , "hex" ) ;
429+
430+ const actual = crypto
431+ . createHmac ( "sha256" , secret )
432+ . update ( rawBody )
433+ . digest ( ) ;
434+
435+ if ( expected . length !== actual . length ) {
436+ return false ;
437+ }
438+
439+ return crypto . timingSafeEqual ( expected , actual ) ;
440+ }
358441 router . post ( "/forge/webhook/forgejo" , async ( req , res ) => {
359442 try {
360443 const forgejoEvent = req . headers [ "x-forgejo-event" ] ?? req . headers [ "x-gitea-event" ] ;
@@ -366,6 +449,39 @@ export function forgeWebhookRoutes(db: Db) {
366449 return ;
367450 }
368451
452+ const rawBody = (
453+ req as Request & {
454+ rawBody ?: Buffer ;
455+ }
456+ ) . rawBody ;
457+
458+ const signatureHeader = req . headers [ "x-gitea-signature" ] ;
459+
460+ const signatureValue = Array . isArray ( signatureHeader )
461+ ? signatureHeader [ 0 ]
462+ : signatureHeader ;
463+
464+ if ( ! rawBody ) {
465+ res . status ( 400 ) . json ( {
466+ error : "Webhook raw body unavailable" ,
467+ } ) ;
468+ return ;
469+ }
470+
471+ const valid = await validateForgejoSignature (
472+ db ,
473+ event . projectId ,
474+ rawBody ,
475+ signatureValue ,
476+ ) ;
477+
478+ if ( ! valid ) {
479+ res . status ( 401 ) . json ( {
480+ error : "Invalid webhook signature" ,
481+ } ) ;
482+ return ;
483+ }
484+
369485 await storeWebhookDelivery ( db , event . projectId , "forgejo" , req . body as Record < string , unknown > , "received" ) ;
370486 const result = await forgeSync . processEvent ( event ) ;
371487 await storeWebhookDelivery ( db , event . projectId , "forgejo" , req . body as Record < string , unknown > , "processed" ) ;
@@ -529,8 +645,8 @@ function mapGitHubEvent(eventName: string, payload: Record<string, unknown>): Fo
529645 const issue = ( payload as Record < string , unknown > ) . issue as Record < string , unknown > ;
530646 const eventType = action === "opened" ? "issue_opened"
531647 : action === "closed" ? "issue_closed"
532- : action === "reopened" ? "issue_reopened"
533- : null ;
648+ : action === "reopened" ? "issue_reopened"
649+ : null ;
534650 if ( ! eventType || ! issue ) return null ;
535651 return {
536652 ...base ,
@@ -560,8 +676,8 @@ function mapGitHubEvent(eventName: string, payload: Record<string, unknown>): Fo
560676 const pr = ( payload as Record < string , unknown > ) . pull_request as Record < string , unknown > ;
561677 const eventType = action === "opened" ? "pr_opened"
562678 : action === "closed" && ( pr as Record < string , unknown > ) ?. merged ? "pr_merged"
563- : action === "closed" ? "pr_closed"
564- : null ;
679+ : action === "closed" ? "pr_closed"
680+ : null ;
565681 if ( ! eventType || ! pr ) return null ;
566682 return {
567683 ...base ,
@@ -606,8 +722,8 @@ function mapGitLabEvent(eventName: string, payload: Record<string, unknown>): Fo
606722 const action = attrs . action as string ;
607723 const eventType = action === "open" ? "issue_opened"
608724 : action === "close" ? "issue_closed"
609- : action === "reopen" ? "issue_reopened"
610- : null ;
725+ : action === "reopen" ? "issue_reopened"
726+ : null ;
611727 if ( ! eventType ) return null ;
612728 return {
613729 ...base ,
@@ -636,8 +752,8 @@ function mapGitLabEvent(eventName: string, payload: Record<string, unknown>): Fo
636752 const action = attrs . action as string ;
637753 const eventType = action === "open" ? "pr_opened"
638754 : action === "close" ? "pr_closed"
639- : action === "merge" ? "pr_merged"
640- : null ;
755+ : action === "merge" ? "pr_merged"
756+ : null ;
641757 if ( ! eventType ) return null ;
642758 return {
643759 ...base ,
@@ -680,8 +796,8 @@ function mapForgejoEvent(eventName: string, payload: Record<string, unknown>): F
680796 const issue = ( payload as Record < string , unknown > ) . issue as Record < string , unknown > ;
681797 const eventType = action === "opened" ? "issue_opened"
682798 : action === "closed" ? "issue_closed"
683- : action === "reopened" ? "issue_reopened"
684- : null ;
799+ : action === "reopened" ? "issue_reopened"
800+ : null ;
685801 if ( ! eventType || ! issue ) return null ;
686802 return {
687803 ...base ,
@@ -711,8 +827,8 @@ function mapForgejoEvent(eventName: string, payload: Record<string, unknown>): F
711827 const pr = ( payload as Record < string , unknown > ) . pull_request as Record < string , unknown > ;
712828 const eventType = action === "opened" ? "pr_opened"
713829 : action === "closed" && ( pr as Record < string , unknown > ) ?. merged ? "pr_merged"
714- : action === "closed" ? "pr_closed"
715- : null ;
830+ : action === "closed" ? "pr_closed"
831+ : null ;
716832 if ( ! eventType || ! pr ) return null ;
717833 return {
718834 ...base ,
0 commit comments