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 <noreply@anthropic.com>
This commit is contained in:
Hugh Brown
2026-03-04 01:35:25 -07:00
committed by Abhimanyu Saharan
parent ce18fe4f0c
commit 528a2483b7
4 changed files with 38 additions and 13 deletions

View File

@@ -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=<hex-digest> (GitHub-style)
X-Webhook-Signature: sha256=<hex-digest>
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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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")