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 src/config/integrationRoles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export const PROVIDER_CREDENTIAL_ROLES: Record<IntegrationProvider, CredentialRo
jira: [
{ role: 'email', label: 'Email', envVarKey: 'JIRA_EMAIL' },
{ role: 'api_token', label: 'API Token', envVarKey: 'JIRA_API_TOKEN' },
{
role: 'webhook_secret',
label: 'Webhook Secret',
envVarKey: 'JIRA_WEBHOOK_SECRET',
optional: true,
},
],
github: [
{
Expand Down
2 changes: 2 additions & 0 deletions src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { getQueueStats } from './queue.js';
import { processRouterWebhook } from './webhook-processor.js';
import {
verifyGitHubWebhookSignature,
verifyJiraWebhookSignature,
verifyTrelloWebhookSignature,
} from './webhookVerification.js';
import {
Expand Down Expand Up @@ -128,6 +129,7 @@ app.post(
createWebhookHandler({
source: 'jira',
parsePayload: parseJiraPayload,
verifySignature: verifyJiraWebhookSignature,
processWebhook: async (payload) => {
const adapter = new JiraRouterAdapter();
const result = await processRouterWebhook(adapter, payload, triggerRegistry);
Expand Down
6 changes: 5 additions & 1 deletion src/router/platformClients/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
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');
}
Expand Down
54 changes: 53 additions & 1 deletion src/router/webhookVerification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, unknown>;
const issue = parsed?.issue as Record<string, unknown> | undefined;
const fields = issue?.fields as Record<string, unknown> | undefined;
const project = fields?.project as Record<string, unknown> | 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=<hex>` 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' };
}
30 changes: 29 additions & 1 deletion src/webhook/signatureVerification.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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=<hex>` 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);
}
Loading
Loading