1- import { defineEventHandler , readBody , getHeader , createError } from "h3" ;
1+ import { createHmac , timingSafeEqual } from "node:crypto" ;
2+ import { defineEventHandler , readRawBody , getHeader , createError } from "h3" ;
23import { env } from "../../../env.js" ;
34import { createAdapters } from "../../lib/adapters.js" ;
45import { dispatchTicket } from "../../lib/dispatch.js" ;
@@ -8,17 +9,25 @@ import { logger } from "../../lib/logger.js";
89 * Jira webhook handler — triggers the same dispatch logic as the cron poller.
910 *
1011 * Configure in Jira (Settings → System → Webhooks) with:
11- * URL: https://<your-domain>/webhooks/jira
12- * Headers: Authorization: Bearer <JIRA_WEBHOOK_SECRET>
13- * Events: Issue updated
12+ * URL: https://<your-domain>/webhooks/jira
13+ * Secret: <JIRA_WEBHOOK_SECRET>
14+ * Events: Issue updated
15+ *
16+ * Jira signs the payload with HMAC-SHA256 and sends it in the
17+ * X-Hub-Signature header (format: "sha256=<hex>").
1418 *
1519 * The webhook fires immediately when a ticket is moved to the AI column,
1620 * eliminating the up-to-1-minute polling delay.
1721 */
1822export default defineEventHandler ( async ( event ) => {
19- verifyWebhookAuth ( event ) ;
23+ const rawBody = await readRawBody ( event , "utf8" ) ;
24+
25+ verifyWebhookSignature (
26+ rawBody ?? "" ,
27+ getHeader ( event , "x-hub-signature" ) ,
28+ ) ;
2029
21- const body = await readBody ( event ) ;
30+ const body = rawBody ? JSON . parse ( rawBody ) : { } ;
2231
2332 const ticketKey = extractTicketKey ( body ) ;
2433 if ( ! ticketKey ) {
@@ -62,16 +71,42 @@ export default defineEventHandler(async (event) => {
6271} ) ;
6372
6473// ---------------------------------------------------------------------------
65- // Auth
74+ // Auth — HMAC-SHA256 signature verification
6675// ---------------------------------------------------------------------------
6776
68- function verifyWebhookAuth ( event : Parameters < typeof getHeader > [ 0 ] ) : void {
77+ /**
78+ * Verify the X-Hub-Signature header sent by Jira Cloud.
79+ *
80+ * Jira computes HMAC-SHA256 of the raw request body using the webhook
81+ * secret and sends it as "sha256=<hex>" in the X-Hub-Signature header.
82+ *
83+ * When JIRA_WEBHOOK_SECRET is not set, verification is skipped (open access).
84+ */
85+ function verifyWebhookSignature (
86+ rawBody : string ,
87+ signatureHeader : string | undefined ,
88+ ) : void {
6989 if ( ! env . JIRA_WEBHOOK_SECRET ) return ;
7090
71- const headerSecret = getHeader ( event , "authorization" ) ?. replace ( / ^ B e a r e r \s + / i, "" ) ;
72- if ( headerSecret === env . JIRA_WEBHOOK_SECRET ) return ;
91+ if ( ! signatureHeader ) {
92+ throw createError ( { statusCode : 401 , statusMessage : "Missing X-Hub-Signature header" } ) ;
93+ }
94+
95+ const [ method , receivedSig ] = signatureHeader . split ( "=" , 2 ) ;
96+ if ( ! method || ! receivedSig ) {
97+ throw createError ( { statusCode : 401 , statusMessage : "Malformed X-Hub-Signature header" } ) ;
98+ }
7399
74- throw createError ( { statusCode : 401 , statusMessage : "Unauthorized" } ) ;
100+ const expectedSig = createHmac ( method , env . JIRA_WEBHOOK_SECRET )
101+ . update ( rawBody , "utf8" )
102+ . digest ( "hex" ) ;
103+
104+ const a = Buffer . from ( receivedSig , "hex" ) ;
105+ const b = Buffer . from ( expectedSig , "hex" ) ;
106+
107+ if ( a . length !== b . length || ! timingSafeEqual ( a , b ) ) {
108+ throw createError ( { statusCode : 401 , statusMessage : "Invalid webhook signature" } ) ;
109+ }
75110}
76111
77112// ---------------------------------------------------------------------------
0 commit comments