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
257 changes: 147 additions & 110 deletions src/router/webhookVerification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
import { loadProjectConfig, routerConfig } from './config.js';
import { resolveWebhookSecret } from './platformClients/credentials.js';

/** The set of platforms that have a webhook secret in {@link resolveWebhookSecret}. */
type WebhookPlatform = 'github' | 'trello' | 'jira' | 'sentry';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -65,92 +68,150 @@ export function buildTrelloCallbackUrl(
}

// ---------------------------------------------------------------------------
// verifySignature callbacks
// createWebhookVerifier factory
// ---------------------------------------------------------------------------

/**
* verifySignature callback for the Trello webhook handler.
* Returns null to skip verification when no secret is configured (backwards compat).
* Configuration for {@link createWebhookVerifier}.
*
* @template TProjectId - The type used to identify a project (e.g. string).
*/
export async function verifyTrelloWebhookSignature(
c: Context,
rawBody: string,
): Promise<{ valid: boolean; reason: string } | null> {
const signatureHeader = c.req.header('x-trello-webhook');
const boardId = extractTrelloBoardId(rawBody);
export interface WebhookVerifierConfig<TProjectId = string> {
/**
* Extract the platform identifier (board ID, repo name, project key, …)
* from the raw request body and/or Hono context.
* Return `undefined` to skip verification (no identifier → no project match).
*/
extractIdentifier: (c: Context, rawBody: string) => string | undefined;
/**
* Find the project that owns this webhook by matching the extracted
* identifier. Return `undefined` when no project matches (skip verification).
*/
findProject: (
identifier: string,
projects: Array<Record<string, unknown>>,
) => { id: TProjectId } | undefined;
/** Platform name passed to `resolveWebhookSecret` (e.g. `'github'`). */
platform: WebhookPlatform;
/** Header name that carries the signature. */
headerName: string;
/**
* Verify the raw signature string against the body and secret.
* Return `true` if valid.
*/
verify: (rawBody: string, signatureHeader: string, secret: string, c: Context) => boolean;
/** Human-readable label used in the mismatch error reason (e.g. `'GitHub'`). */
platformLabel: string;
}

if (!boardId) return null;
/**
* Factory that creates a `verifySignature` callback for Hono webhook handlers.
*
* All four platform verifiers follow the same pattern:
* 1. Extract a header value (signature).
* 2. Extract a platform identifier from the body (board ID, repo, project key…).
* 3. Look up the project in config.
* 4. Resolve the webhook secret for that project.
* 5. Verify the signature, returning a structured result.
*
* `createWebhookVerifier` captures steps 1–5 in a single reusable closure,
* parameterised by the small per-platform details supplied in `config`.
*/
export function createWebhookVerifier<TProjectId = string>(
config: WebhookVerifierConfig<TProjectId>,
): (c: Context, rawBody: string) => Promise<{ valid: boolean; reason: string } | null> {
const { extractIdentifier, findProject, platform, headerName, verify, platformLabel } = config;

const { projects } = await loadProjectConfig();
const project = projects.find((p) => p.trello?.boardId === boardId);
if (!project) return null;
return async function verifyWebhookSignature(
c: Context,
rawBody: string,
): Promise<{ valid: boolean; reason: string } | null> {
const signatureHeader = c.req.header(headerName);
const identifier = extractIdentifier(c, rawBody);

const secret = await resolveWebhookSecret(project.id, 'trello');
if (!secret) return null; // No secret configured — skip verification
if (!identifier) return null;

if (!signatureHeader) {
return { valid: false, reason: 'Missing signature header' };
}
const { projects } = await loadProjectConfig();
// Cast is safe: loadProjectConfig returns typed project objects; we only
// need `id` from the result, and findProject may do deeper matching.
const project = findProject(identifier, projects as unknown as Array<Record<string, unknown>>);
if (!project) return null;

const secret = await resolveWebhookSecret(project.id as string, platform);
if (!secret) return null; // No secret configured — skip verification

if (!signatureHeader) {
return { valid: false, reason: 'Missing signature header' };
}

const callbackUrl = buildTrelloCallbackUrl(
c.req.header('host'),
c.req.header('x-forwarded-proto'),
);
const valid = verifyTrelloSignature(rawBody, callbackUrl, signatureHeader, secret);
return valid
? { valid: true, reason: 'Signature valid' }
: { valid: false, reason: 'Trello signature mismatch' };
const valid = verify(rawBody, signatureHeader, secret, c);
return valid
? { valid: true, reason: 'Signature valid' }
: { valid: false, reason: `${platformLabel} signature mismatch` };
};
}

// ---------------------------------------------------------------------------
// verifySignature callbacks (one per platform)
// ---------------------------------------------------------------------------

/**
* verifySignature callback for the GitHub webhook handler.
* verifySignature callback for the Trello webhook handler.
* Returns null to skip verification when no secret is configured (backwards compat).
*/
export async function verifyGitHubWebhookSignature(
c: Context,
rawBody: string,
): Promise<{ valid: boolean; reason: string } | null> {
const signatureHeader = c.req.header('X-Hub-Signature-256');
export const verifyTrelloWebhookSignature = createWebhookVerifier({
headerName: 'x-trello-webhook',
platform: 'trello',
platformLabel: 'Trello',
extractIdentifier: (_c, rawBody) => extractTrelloBoardId(rawBody),
findProject: (boardId, projects) =>
projects.find((p) => (p.trello as Record<string, unknown> | undefined)?.boardId === boardId) as
| { id: string }
| undefined,
verify: (rawBody, sig, secret, c) =>
verifyTrelloSignature(
rawBody,
buildTrelloCallbackUrl(c.req.header('host'), c.req.header('x-forwarded-proto')),
sig,
secret,
),
});

let repoFullName: string | undefined;
try {
// Try JSON first (application/json delivery).
const parsed = JSON.parse(rawBody) as Record<string, unknown>;
repoFullName = (parsed?.repository as Record<string, unknown>)?.full_name as string | undefined;
} catch {
// Not JSON — try application/x-www-form-urlencoded delivery.
// GitHub sends the payload as `payload=<url-encoded JSON>` in that case.
/**
* verifySignature callback for the GitHub webhook handler.
* Returns null to skip verification when no secret is configured (backwards compat).
*/
export const verifyGitHubWebhookSignature = createWebhookVerifier({
headerName: 'X-Hub-Signature-256',
platform: 'github',
platformLabel: 'GitHub',
extractIdentifier: (_c, rawBody) => {
try {
// Try JSON first (application/json delivery).
const parsed = JSON.parse(rawBody) as Record<string, unknown>;
const repoFullName = (parsed?.repository as Record<string, unknown>)?.full_name as
| string
| undefined;
if (repoFullName) return repoFullName;
} catch {
// Not JSON — try application/x-www-form-urlencoded delivery.
}
try {
// GitHub sends the payload as `payload=<url-encoded JSON>` in that case.
const payloadStr = new URLSearchParams(rawBody).get('payload');
if (payloadStr) {
const parsed = JSON.parse(payloadStr) as Record<string, unknown>;
repoFullName = (parsed?.repository as Record<string, unknown>)?.full_name as
| string
| undefined;
return (parsed?.repository as Record<string, unknown>)?.full_name as string | undefined;
}
} catch {
// Unparseable body — fall through to the null return below
// Unparseable body — fall through to undefined
}
}

if (!repoFullName) return null;

const { projects } = await loadProjectConfig();
const project = projects.find((p) => p.repo === repoFullName);
if (!project) return null;

const secret = await resolveWebhookSecret(project.id, 'github');
if (!secret) return null; // No secret configured — skip verification

if (!signatureHeader) {
return { valid: false, reason: 'Missing signature header' };
}

const valid = verifyGitHubSignature(rawBody, signatureHeader, secret);
return valid
? { valid: true, reason: 'Signature valid' }
: { valid: false, reason: 'GitHub signature mismatch' };
}
return undefined;
},
findProject: (repoFullName, projects) =>
projects.find((p) => p.repo === repoFullName) as { id: string } | undefined,
verify: (rawBody, sig, secret) => verifyGitHubSignature(rawBody, sig, secret),
});

/**
* verifySignature callback for the Sentry webhook handler.
Expand All @@ -162,27 +223,17 @@ export async function verifyGitHubWebhookSignature(
* The project ID is taken from the URL path param (`:projectId`),
* which is unambiguous since each Sentry integration gets its own webhook URL.
*/
export async function verifySentryWebhookSignature(
c: Context,
rawBody: string,
): Promise<{ valid: boolean; reason: string } | null> {
const signatureHeader = c.req.header('Sentry-Hook-Signature');
const projectId = c.req.param('projectId');

if (!projectId) return null;

const secret = await resolveWebhookSecret(projectId, 'sentry');
if (!secret) return null; // No secret configured — skip verification

if (!signatureHeader) {
return { valid: false, reason: 'Missing Sentry-Hook-Signature header' };
}

const valid = verifySentrySignature(rawBody, signatureHeader, secret);
return valid
? { valid: true, reason: 'Signature valid' }
: { valid: false, reason: 'Sentry signature mismatch' };
}
export const verifySentryWebhookSignature = createWebhookVerifier({
headerName: 'Sentry-Hook-Signature',
platform: 'sentry',
platformLabel: 'Sentry',
// Sentry uses the URL path param as its identifier (projectId), not the body.
extractIdentifier: (c, _rawBody) => c.req.param('projectId'),
// For Sentry the identifier IS the project ID — find by direct ID match.
findProject: (projectId, projects) =>
projects.find((p) => p.id === projectId) as { id: string } | undefined,
verify: (rawBody, sig, secret) => verifySentrySignature(rawBody, sig, secret),
});

/**
* Extract the JIRA project key from a raw webhook payload.
Expand All @@ -206,28 +257,14 @@ export function extractJiraProjectKey(rawBody: string): string | undefined {
*
* 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' };
}
export const verifyJiraWebhookSignature = createWebhookVerifier({
headerName: 'X-Hub-Signature',
platform: 'jira',
platformLabel: 'JIRA',
extractIdentifier: (_c, rawBody) => extractJiraProjectKey(rawBody),
findProject: (jiraProjectKey, projects) =>
projects.find(
(p) => (p.jira as Record<string, unknown> | undefined)?.projectKey === jiraProjectKey,
) as { id: string } | undefined,
verify: (rawBody, sig, secret) => verifyJiraSignature(rawBody, sig, secret),
});
Loading
Loading