From 528a2483b72d84016e2f40e8e1f83ec49b543f82 Mon Sep 17 00:00:00 2001 From: Hugh Brown Date: Wed, 4 Mar 2026 01:35:25 -0700 Subject: [PATCH] feat: add configurable signature_header for webhook HMAC verification Not all webhook providers use X-Hub-Signature-256 or X-Webhook-Signature. Add an optional signature_header field so users can specify which header carries the HMAC signature. When set, that exact header is checked; when unset, the existing auto-detect fallback is preserved. The custom header is also excluded from stored/exposed payload headers. Co-Authored-By: Claude Opus 4.6 --- backend/app/api/board_webhooks.py | 34 +++++++++++++------ backend/app/models/board_webhooks.py | 1 + backend/app/schemas/board_webhooks.py | 3 ++ .../a1b2c3d4e5f6_add_webhook_secret.py | 13 +++++-- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/backend/app/api/board_webhooks.py b/backend/app/api/board_webhooks.py index 7b7709de..ebc4f6d1 100644 --- a/backend/app/api/board_webhooks.py +++ b/backend/app/api/board_webhooks.py @@ -71,6 +71,7 @@ def _to_webhook_read(webhook: BoardWebhook) -> BoardWebhookRead: description=webhook.description, enabled=webhook.enabled, has_secret=bool(webhook.secret), + signature_header=webhook.signature_header, endpoint_path=endpoint_path, endpoint_url=_webhook_endpoint_url(endpoint_path), created_at=webhook.created_at, @@ -171,16 +172,21 @@ def _verify_webhook_signature( ) -> None: """Verify HMAC-SHA256 signature if the webhook has a secret configured. - When a secret is set, the sender must include a valid signature in one of: - X-Hub-Signature-256: sha256= (GitHub-style) - X-Webhook-Signature: sha256= - If no secret is configured, signature verification is skipped. + When a secret is set, the sender must include a valid signature header. + The header to check is determined by ``webhook.signature_header`` if set, + otherwise the following well-known headers are tried in order: + X-Hub-Signature-256 (GitHub-style) + X-Webhook-Signature + If no secret is configured, signature verification is skipped entirely. """ if not webhook.secret: return - sig_header = request.headers.get("x-hub-signature-256") or request.headers.get( - "x-webhook-signature" - ) + if webhook.signature_header: + sig_header = request.headers.get(webhook.signature_header.lower()) + else: + sig_header = request.headers.get("x-hub-signature-256") or request.headers.get( + "x-webhook-signature" + ) if not sig_header: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -212,11 +218,18 @@ _REDACTED_HEADERS = frozenset( ) -def _captured_headers(request: Request) -> dict[str, str] | None: +def _captured_headers( + request: Request, + *, + extra_redacted: str | None = None, +) -> dict[str, str] | None: + redacted = _REDACTED_HEADERS + if extra_redacted: + redacted = redacted | {extra_redacted.lower()} captured: dict[str, str] = {} for header, value in request.headers.items(): normalized = header.lower() - if normalized in _REDACTED_HEADERS: + if normalized in redacted: continue if normalized in {"content-type", "user-agent"} or normalized.startswith("x-"): captured[normalized] = value @@ -362,6 +375,7 @@ async def create_board_webhook( description=payload.description, enabled=payload.enabled, secret=payload.secret, + signature_header=payload.signature_header, ) await crud.save(session, webhook) return _to_webhook_read(webhook) @@ -544,7 +558,7 @@ async def ingest_board_webhook( _verify_webhook_signature(webhook, raw_body, request) content_type = request.headers.get("content-type") - headers = _captured_headers(request) + headers = _captured_headers(request, extra_redacted=webhook.signature_header) payload_value = _decode_payload( raw_body, content_type=content_type, diff --git a/backend/app/models/board_webhooks.py b/backend/app/models/board_webhooks.py index 99982903..38251cce 100644 --- a/backend/app/models/board_webhooks.py +++ b/backend/app/models/board_webhooks.py @@ -24,5 +24,6 @@ class BoardWebhook(QueryModel, table=True): description: str enabled: bool = Field(default=True, index=True) secret: str | None = Field(default=None) + signature_header: str | None = Field(default=None) created_at: datetime = Field(default_factory=utcnow) updated_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/schemas/board_webhooks.py b/backend/app/schemas/board_webhooks.py index 3c917925..2cf2b08e 100644 --- a/backend/app/schemas/board_webhooks.py +++ b/backend/app/schemas/board_webhooks.py @@ -32,6 +32,7 @@ class BoardWebhookCreate(SQLModel): enabled: bool = True agent_id: UUID | None = None secret: NormalizedSecret = None + signature_header: str | None = None class BoardWebhookUpdate(SQLModel): @@ -41,6 +42,7 @@ class BoardWebhookUpdate(SQLModel): enabled: bool | None = None agent_id: UUID | None = None secret: NormalizedSecret = None + signature_header: str | None = None class BoardWebhookRead(SQLModel): @@ -52,6 +54,7 @@ class BoardWebhookRead(SQLModel): description: str enabled: bool has_secret: bool = False + signature_header: str | None = None endpoint_path: str endpoint_url: str | None = None created_at: datetime diff --git a/backend/migrations/versions/a1b2c3d4e5f6_add_webhook_secret.py b/backend/migrations/versions/a1b2c3d4e5f6_add_webhook_secret.py index 2a741ae5..813b737f 100644 --- a/backend/migrations/versions/a1b2c3d4e5f6_add_webhook_secret.py +++ b/backend/migrations/versions/a1b2c3d4e5f6_add_webhook_secret.py @@ -1,4 +1,4 @@ -"""Add optional secret column to board_webhooks for HMAC signature verification. +"""Add secret and signature_header columns to board_webhooks for HMAC verification. Revision ID: a1b2c3d4e5f6 Revises: f1b2c3d4e5a6 @@ -19,7 +19,7 @@ depends_on = None def upgrade() -> None: - """Add secret column to board_webhooks table.""" + """Add secret and signature_header columns to board_webhooks table.""" bind = op.get_bind() inspector = sa.inspect(bind) columns = {c["name"] for c in inspector.get_columns("board_webhooks")} @@ -28,12 +28,19 @@ def upgrade() -> None: "board_webhooks", sa.Column("secret", sa.String(), nullable=True), ) + if "signature_header" not in columns: + op.add_column( + "board_webhooks", + sa.Column("signature_header", sa.String(), nullable=True), + ) def downgrade() -> None: - """Remove secret column from board_webhooks table.""" + """Remove secret and signature_header columns from board_webhooks table.""" bind = op.get_bind() inspector = sa.inspect(bind) columns = {c["name"] for c in inspector.get_columns("board_webhooks")} + if "signature_header" in columns: + op.drop_column("board_webhooks", "signature_header") if "secret" in columns: op.drop_column("board_webhooks", "secret")