From 4ba5a4b893d2574fcc75e7c530bbe67cbfe5ddd9 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 22 Mar 2026 16:53:23 +0000 Subject: [PATCH] feat(webhook): add JIRA webhook HMAC-SHA256 signature verification --- src/config/integrationRoles.ts | 6 + src/router/index.ts | 2 + src/router/platformClients/credentials.ts | 6 +- src/router/webhookVerification.ts | 54 +++- src/webhook/signatureVerification.ts | 30 ++- tests/unit/router/webhook-signature.test.ts | 236 ++++++++++++++++++ .../webhook/signatureVerification.test.ts | 58 +++++ 7 files changed, 389 insertions(+), 3 deletions(-) diff --git a/src/config/integrationRoles.ts b/src/config/integrationRoles.ts index 795ade97..b8b88508 100644 --- a/src/config/integrationRoles.ts +++ b/src/config/integrationRoles.ts @@ -24,6 +24,12 @@ export const PROVIDER_CREDENTIAL_ROLES: Record { const adapter = new JiraRouterAdapter(); const result = await processRouterWebhook(adapter, payload, triggerRegistry); diff --git a/src/router/platformClients/credentials.ts b/src/router/platformClients/credentials.ts index aaaa9585..ab117929 100644 --- a/src/router/platformClients/credentials.ts +++ b/src/router/platformClients/credentials.ts @@ -58,16 +58,20 @@ export async function resolveJiraCredentials( * - `'trello'`: resolves the `api_secret` credential from the PM integration. * Trello computes webhook HMAC signatures using the API Secret (shown below the * API Key at https://trello.com/app-key), not the public API Key. + * - `'jira'`: resolves the `webhook_secret` credential from the PM integration. * * Returns `null` if the credential is not configured. */ export async function resolveWebhookSecret( projectId: string, - provider: 'github' | 'trello', + provider: 'github' | 'trello' | 'jira', ): Promise { if (provider === 'github') { return getIntegrationCredentialOrNull(projectId, 'scm', 'webhook_secret'); } + if (provider === 'jira') { + return getIntegrationCredentialOrNull(projectId, 'pm', 'webhook_secret'); + } // Trello signs webhook payloads with the API Secret, not the public API Key. return getIntegrationCredentialOrNull(projectId, 'pm', 'api_secret'); } diff --git a/src/router/webhookVerification.ts b/src/router/webhookVerification.ts index 3115fb88..a7de3306 100644 --- a/src/router/webhookVerification.ts +++ b/src/router/webhookVerification.ts @@ -7,7 +7,11 @@ import type { Context } from 'hono'; import { logger } from '../utils/logging.js'; -import { verifyGitHubSignature, verifyTrelloSignature } from '../webhook/signatureVerification.js'; +import { + verifyGitHubSignature, + verifyJiraSignature, + verifyTrelloSignature, +} from '../webhook/signatureVerification.js'; import { loadProjectConfig, routerConfig } from './config.js'; import { resolveWebhookSecret } from './platformClients/credentials.js'; @@ -146,3 +150,51 @@ export async function verifyGitHubWebhookSignature( ? { valid: true, reason: 'Signature valid' } : { valid: false, reason: 'GitHub signature mismatch' }; } + +/** + * Extract the JIRA project key from a raw webhook payload. + * JIRA sends the project key at `issue.fields.project.key`. + */ +export function extractJiraProjectKey(rawBody: string): string | undefined { + try { + const parsed = JSON.parse(rawBody) as Record; + const issue = parsed?.issue as Record | undefined; + const fields = issue?.fields as Record | undefined; + const project = fields?.project as Record | undefined; + return project?.key as string | undefined; + } catch { + return undefined; + } +} + +/** + * verifySignature callback for the JIRA webhook handler. + * Returns null to skip verification when no secret is configured (backwards compat). + * + * JIRA Cloud sends the signature as `sha256=` in the `X-Hub-Signature` header. + */ +export async function verifyJiraWebhookSignature( + c: Context, + rawBody: string, +): Promise<{ valid: boolean; reason: string } | null> { + const signatureHeader = c.req.header('X-Hub-Signature'); + const jiraProjectKey = extractJiraProjectKey(rawBody); + + if (!jiraProjectKey) return null; + + const { projects } = await loadProjectConfig(); + const project = projects.find((p) => p.jira?.projectKey === jiraProjectKey); + if (!project) return null; + + const secret = await resolveWebhookSecret(project.id, 'jira'); + if (!secret) return null; // No secret configured — skip verification + + if (!signatureHeader) { + return { valid: false, reason: 'Missing signature header' }; + } + + const valid = verifyJiraSignature(rawBody, signatureHeader, secret); + return valid + ? { valid: true, reason: 'Signature valid' } + : { valid: false, reason: 'JIRA signature mismatch' }; +} diff --git a/src/webhook/signatureVerification.ts b/src/webhook/signatureVerification.ts index 2eeab0d7..9fecd393 100644 --- a/src/webhook/signatureVerification.ts +++ b/src/webhook/signatureVerification.ts @@ -1,7 +1,8 @@ /** * HMAC signature verification for webhook payloads. * - * Provides timing-safe verification for GitHub (SHA-256) and Trello (SHA-1) webhooks. + * Provides timing-safe verification for GitHub (SHA-256), Trello (SHA-1), and + * JIRA (SHA-256) webhooks. */ import { createHmac, timingSafeEqual } from 'node:crypto'; @@ -68,3 +69,30 @@ export function verifyTrelloSignature( return timingSafeEqual(expected, actual); } + +/** + * Verify a JIRA webhook signature. + * + * JIRA Cloud signs payloads with HMAC-SHA256 and sends the result as + * `sha256=` in the `X-Hub-Signature` header. + * + * @param rawBody - The raw request body string. + * @param signature - The value of the `X-Hub-Signature` header. + * @param secret - The webhook secret configured in JIRA. + * @returns `true` if the signature is valid, `false` otherwise. + */ +export function verifyJiraSignature(rawBody: string, signature: string, secret: string): boolean { + if (!signature || !signature.startsWith('sha256=')) { + return false; + } + + const expectedHex = createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex'); + const expected = Buffer.from(`sha256=${expectedHex}`, 'utf8'); + const actual = Buffer.from(signature, 'utf8'); + + if (expected.length !== actual.length) { + return false; + } + + return timingSafeEqual(expected, actual); +} diff --git a/tests/unit/router/webhook-signature.test.ts b/tests/unit/router/webhook-signature.test.ts index 997c70f6..d3fe54ae 100644 --- a/tests/unit/router/webhook-signature.test.ts +++ b/tests/unit/router/webhook-signature.test.ts @@ -125,8 +125,10 @@ import { loadProjectConfig, routerConfig } from '../../../src/router/config.js'; import { resolveWebhookSecret } from '../../../src/router/platformClients/credentials.js'; import { buildTrelloCallbackUrl, + extractJiraProjectKey, extractTrelloBoardId, verifyGitHubWebhookSignature, + verifyJiraWebhookSignature, verifyTrelloWebhookSignature, } from '../../../src/router/webhookVerification.js'; import { logger } from '../../../src/utils/logging.js'; @@ -146,6 +148,11 @@ function trelloSignature(body: string, callbackUrl: string, secret: string): str .digest('base64'); } +function jiraSignature(body: string, secret: string): string { + const hex = createHmac('sha256', secret).update(body, 'utf8').digest('hex'); + return `sha256=${hex}`; +} + const GITHUB_PROJECT = { id: 'proj-gh', repo: 'owner/repo', @@ -163,8 +170,20 @@ const TRELLO_PROJECT = { }, }; +const JIRA_PROJECT = { + id: 'proj-jira', + repo: 'owner/repo', + pmType: 'jira' as const, + jira: { + projectKey: 'PROJ', + baseUrl: 'https://example.atlassian.net', + statuses: {}, + }, +}; + const GITHUB_SECRET = 'my-github-webhook-secret'; const TRELLO_SECRET = 'my-trello-api-secret'; +const JIRA_SECRET = 'my-jira-webhook-secret'; const TRELLO_CALLBACK_URL = 'https://example.com/trello/webhook'; // --------------------------------------------------------------------------- @@ -660,3 +679,220 @@ describe('router — Trello webhook signature verification (end-to-end)', () => ); }); }); + +// --------------------------------------------------------------------------- +// Unit tests: extractJiraProjectKey +// --------------------------------------------------------------------------- + +describe('extractJiraProjectKey', () => { + it('extracts project key from issue.fields.project.key', () => { + const body = JSON.stringify({ + webhookEvent: 'jira:issue_updated', + issue: { key: 'PROJ-1', fields: { project: { key: 'PROJ' } } }, + }); + expect(extractJiraProjectKey(body)).toBe('PROJ'); + }); + + it('returns undefined when project key is absent', () => { + const body = JSON.stringify({ webhookEvent: 'jira:issue_updated', issue: { key: 'PROJ-1' } }); + expect(extractJiraProjectKey(body)).toBeUndefined(); + }); + + it('returns undefined for invalid JSON', () => { + expect(extractJiraProjectKey('not json')).toBeUndefined(); + }); + + it('returns undefined when issue field is missing', () => { + const body = JSON.stringify({ webhookEvent: 'jira:issue_updated' }); + expect(extractJiraProjectKey(body)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Unit tests: verifyJiraWebhookSignature (function directly) +// --------------------------------------------------------------------------- + +describe('verifyJiraWebhookSignature — direct function tests', () => { + beforeEach(() => { + vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [JIRA_PROJECT] }); + vi.mocked(resolveWebhookSecret).mockResolvedValue(JIRA_SECRET); + }); + + function makeContext(headers: Record = {}) { + return { + req: { + header: (name: string) => headers[name.toLowerCase()] ?? headers[name], + }, + } as unknown as import('hono').Context; + } + + function buildJiraBody(projectKey = 'PROJ') { + return JSON.stringify({ + webhookEvent: 'jira:issue_updated', + issue: { key: `${projectKey}-1`, fields: { project: { key: projectKey } } }, + }); + } + + it('returns { valid: true } when signature is correct', async () => { + const body = buildJiraBody(); + const sig = jiraSignature(body, JIRA_SECRET); + const result = await verifyJiraWebhookSignature(makeContext({ 'X-Hub-Signature': sig }), body); + expect(result).toEqual({ valid: true, reason: 'Signature valid' }); + }); + + it('returns { valid: false } when signature is wrong', async () => { + const body = buildJiraBody(); + const badSig = jiraSignature(body, 'wrong-secret'); + const result = await verifyJiraWebhookSignature( + makeContext({ 'X-Hub-Signature': badSig }), + body, + ); + expect(result).toEqual({ valid: false, reason: 'JIRA signature mismatch' }); + }); + + it('returns { valid: false, reason: "Missing signature header" } when header absent but secret configured', async () => { + const body = buildJiraBody(); + const result = await verifyJiraWebhookSignature(makeContext({}), body); + expect(result).toEqual({ valid: false, reason: 'Missing signature header' }); + }); + + it('returns null (skip) when no secret configured', async () => { + vi.mocked(resolveWebhookSecret).mockResolvedValue(null); + const body = buildJiraBody(); + const result = await verifyJiraWebhookSignature(makeContext({}), body); + expect(result).toBeNull(); + }); + + it('returns null (skip) when project not found', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [] }); + const body = buildJiraBody('UNKNOWN'); + const result = await verifyJiraWebhookSignature(makeContext({}), body); + expect(result).toBeNull(); + }); + + it('returns null (skip) when project key is missing from payload', async () => { + const body = JSON.stringify({ webhookEvent: 'jira:issue_updated' }); + const result = await verifyJiraWebhookSignature(makeContext({}), body); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests: end-to-end via Hono app — JIRA +// --------------------------------------------------------------------------- + +describe('router — JIRA webhook signature verification (end-to-end)', () => { + let app: Hono; + + beforeEach(async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [JIRA_PROJECT], + }); + vi.mocked(resolveWebhookSecret).mockResolvedValue(JIRA_SECRET); + + const { createWebhookHandler, parseJiraPayload } = await import( + '../../../src/webhook/webhookHandlers.js' + ); + + app = new Hono(); + app.post( + '/jira/webhook', + createWebhookHandler({ + source: 'jira', + parsePayload: parseJiraPayload, + verifySignature: verifyJiraWebhookSignature, + processWebhook: vi.fn().mockResolvedValue({ + processed: true, + projectId: 'proj-jira', + decisionReason: 'matched', + }), + }), + ); + }); + + function buildJiraPayload(projectKey = 'PROJ') { + return JSON.stringify({ + webhookEvent: 'jira:issue_updated', + issue: { key: `${projectKey}-1`, fields: { project: { key: projectKey } } }, + }); + } + + async function post(body: string, headers: Record = {}): Promise { + return app.fetch( + new Request('http://localhost/jira/webhook', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body, + }), + ); + } + + it('returns 200 when signature is valid', async () => { + const body = buildJiraPayload(); + const sig = jiraSignature(body, JIRA_SECRET); + const res = await post(body, { 'X-Hub-Signature': sig }); + expect(res.status).toBe(200); + }); + + it('returns 401 when signature is invalid (wrong secret)', async () => { + const body = buildJiraPayload(); + const badSig = jiraSignature(body, 'wrong-secret'); + const res = await post(body, { 'X-Hub-Signature': badSig }); + expect(res.status).toBe(401); + }); + + it('returns 401 when signature header is missing but secret IS configured', async () => { + const body = buildJiraPayload(); + const res = await post(body); + expect(res.status).toBe(401); + }); + + it('returns 200 (skip verification) when no webhook secret is configured', async () => { + vi.mocked(resolveWebhookSecret).mockResolvedValue(null); + const body = buildJiraPayload(); + const res = await post(body); + expect(res.status).toBe(200); + }); + + it('returns 200 (skip verification) when project is not found', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [] }); + const body = buildJiraPayload('UNKNOWN'); + const res = await post(body); + expect(res.status).toBe(200); + }); + + it('returns 200 (skip verification) when project key is missing from payload', async () => { + const body = JSON.stringify({ webhookEvent: 'jira:issue_updated' }); + const res = await post(body); + expect(res.status).toBe(200); + }); + + it('logs decision reason to webhook_logs on 401', async () => { + const { logWebhookCall } = await import('../../../src/utils/webhookLogger.js'); + const body = buildJiraPayload(); + const badSig = jiraSignature(body, 'wrong-secret'); + await post(body, { 'X-Hub-Signature': badSig }); + expect(logWebhookCall).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 401, + processed: false, + decisionReason: 'JIRA signature mismatch', + }), + ); + }); + + it('logs Missing signature header reason when header absent but secret configured', async () => { + const { logWebhookCall } = await import('../../../src/utils/webhookLogger.js'); + const body = buildJiraPayload(); + await post(body); + expect(logWebhookCall).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 401, + decisionReason: 'Missing signature header', + }), + ); + }); +}); diff --git a/tests/unit/webhook/signatureVerification.test.ts b/tests/unit/webhook/signatureVerification.test.ts index 66bf4a7d..36994ac4 100644 --- a/tests/unit/webhook/signatureVerification.test.ts +++ b/tests/unit/webhook/signatureVerification.test.ts @@ -2,6 +2,7 @@ import { createHmac } from 'node:crypto'; import { describe, expect, it } from 'vitest'; import { verifyGitHubSignature, + verifyJiraSignature, verifyTrelloSignature, } from '../../../src/webhook/signatureVerification.js'; @@ -20,6 +21,11 @@ function trelloSignature(body: string, callbackUrl: string, secret: string): str .digest('base64'); } +function jiraSignature(body: string, secret: string): string { + const hex = createHmac('sha256', secret).update(body, 'utf8').digest('hex'); + return `sha256=${hex}`; +} + // --------------------------------------------------------------------------- // verifyGitHubSignature // --------------------------------------------------------------------------- @@ -130,3 +136,55 @@ describe('verifyTrelloSignature', () => { expect(sig1).not.toBe(sig2); }); }); + +// --------------------------------------------------------------------------- +// verifyJiraSignature +// --------------------------------------------------------------------------- + +describe('verifyJiraSignature', () => { + const secret = 'my-jira-secret'; + const body = '{"webhookEvent":"jira:issue_updated","issue":{"key":"PROJ-1"}}'; + + it('returns true for a valid signature', () => { + const sig = jiraSignature(body, secret); + expect(verifyJiraSignature(body, sig, secret)).toBe(true); + }); + + it('returns false for an empty body with a signature for non-empty body', () => { + const sig = jiraSignature(body, secret); + expect(verifyJiraSignature('', sig, secret)).toBe(false); + }); + + it('returns true for an empty body when the signature matches the empty body', () => { + const sig = jiraSignature('', secret); + expect(verifyJiraSignature('', sig, secret)).toBe(true); + }); + + it('returns false when the signature is an empty string', () => { + expect(verifyJiraSignature(body, '', secret)).toBe(false); + }); + + it('returns false when the signature prefix is missing (raw hex only)', () => { + const rawHex = createHmac('sha256', secret).update(body, 'utf8').digest('hex'); + expect(verifyJiraSignature(body, rawHex, secret)).toBe(false); + }); + + it('returns false when signed with a different secret', () => { + const sig = jiraSignature(body, 'wrong-secret'); + expect(verifyJiraSignature(body, sig, secret)).toBe(false); + }); + + it('returns false when the body has been tampered with', () => { + const sig = jiraSignature(body, secret); + expect(verifyJiraSignature(`${body}tampered`, sig, secret)).toBe(false); + }); + + it('returns false for a completely garbage signature string', () => { + expect(verifyJiraSignature(body, 'not-a-real-signature', secret)).toBe(false); + }); + + it('is timing-safe: the comparison does not short-circuit on length mismatch within prefix', () => { + // Provide a correctly-prefixed but shorter hex to exercise the length branch + expect(verifyJiraSignature(body, 'sha256=abc', secret)).toBe(false); + }); +});