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, description=webhook.description,
enabled=webhook.enabled, enabled=webhook.enabled,
has_secret=bool(webhook.secret), has_secret=bool(webhook.secret),
signature_header=webhook.signature_header,
endpoint_path=endpoint_path, endpoint_path=endpoint_path,
endpoint_url=_webhook_endpoint_url(endpoint_path), endpoint_url=_webhook_endpoint_url(endpoint_path),
created_at=webhook.created_at, created_at=webhook.created_at,
@@ -171,16 +172,21 @@ def _verify_webhook_signature(
) -> None: ) -> None:
"""Verify HMAC-SHA256 signature if the webhook has a secret configured. """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: When a secret is set, the sender must include a valid signature header.
X-Hub-Signature-256: sha256=<hex-digest> (GitHub-style) The header to check is determined by ``webhook.signature_header`` if set,
X-Webhook-Signature: sha256=<hex-digest> otherwise the following well-known headers are tried in order:
If no secret is configured, signature verification is skipped. X-Hub-Signature-256 (GitHub-style)
X-Webhook-Signature
If no secret is configured, signature verification is skipped entirely.
""" """
if not webhook.secret: if not webhook.secret:
return return
sig_header = request.headers.get("x-hub-signature-256") or request.headers.get( if webhook.signature_header:
"x-webhook-signature" 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: if not sig_header:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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] = {} captured: dict[str, str] = {}
for header, value in request.headers.items(): for header, value in request.headers.items():
normalized = header.lower() normalized = header.lower()
if normalized in _REDACTED_HEADERS: if normalized in redacted:
continue continue
if normalized in {"content-type", "user-agent"} or normalized.startswith("x-"): if normalized in {"content-type", "user-agent"} or normalized.startswith("x-"):
captured[normalized] = value captured[normalized] = value
@@ -362,6 +375,7 @@ async def create_board_webhook(
description=payload.description, description=payload.description,
enabled=payload.enabled, enabled=payload.enabled,
secret=payload.secret, secret=payload.secret,
signature_header=payload.signature_header,
) )
await crud.save(session, webhook) await crud.save(session, webhook)
return _to_webhook_read(webhook) return _to_webhook_read(webhook)
@@ -544,7 +558,7 @@ async def ingest_board_webhook(
_verify_webhook_signature(webhook, raw_body, request) _verify_webhook_signature(webhook, raw_body, request)
content_type = request.headers.get("content-type") content_type = request.headers.get("content-type")
headers = _captured_headers(request) headers = _captured_headers(request, extra_redacted=webhook.signature_header)
payload_value = _decode_payload( payload_value = _decode_payload(
raw_body, raw_body,
content_type=content_type, content_type=content_type,

View File

@@ -24,5 +24,6 @@ class BoardWebhook(QueryModel, table=True):
description: str description: str
enabled: bool = Field(default=True, index=True) enabled: bool = Field(default=True, index=True)
secret: str | None = Field(default=None) secret: str | None = Field(default=None)
signature_header: str | None = Field(default=None)
created_at: datetime = Field(default_factory=utcnow) created_at: datetime = Field(default_factory=utcnow)
updated_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 enabled: bool = True
agent_id: UUID | None = None agent_id: UUID | None = None
secret: NormalizedSecret = None secret: NormalizedSecret = None
signature_header: str | None = None
class BoardWebhookUpdate(SQLModel): class BoardWebhookUpdate(SQLModel):
@@ -41,6 +42,7 @@ class BoardWebhookUpdate(SQLModel):
enabled: bool | None = None enabled: bool | None = None
agent_id: UUID | None = None agent_id: UUID | None = None
secret: NormalizedSecret = None secret: NormalizedSecret = None
signature_header: str | None = None
class BoardWebhookRead(SQLModel): class BoardWebhookRead(SQLModel):
@@ -52,6 +54,7 @@ class BoardWebhookRead(SQLModel):
description: str description: str
enabled: bool enabled: bool
has_secret: bool = False has_secret: bool = False
signature_header: str | None = None
endpoint_path: str endpoint_path: str
endpoint_url: str | None = None endpoint_url: str | None = None
created_at: datetime 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 Revision ID: a1b2c3d4e5f6
Revises: f1b2c3d4e5a6 Revises: f1b2c3d4e5a6
@@ -19,7 +19,7 @@ depends_on = None
def upgrade() -> 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() bind = op.get_bind()
inspector = sa.inspect(bind) inspector = sa.inspect(bind)
columns = {c["name"] for c in inspector.get_columns("board_webhooks")} columns = {c["name"] for c in inspector.get_columns("board_webhooks")}
@@ -28,12 +28,19 @@ def upgrade() -> None:
"board_webhooks", "board_webhooks",
sa.Column("secret", sa.String(), nullable=True), 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: def downgrade() -> None:
"""Remove secret column from board_webhooks table.""" """Remove secret and signature_header columns from board_webhooks table."""
bind = op.get_bind() bind = op.get_bind()
inspector = sa.inspect(bind) inspector = sa.inspect(bind)
columns = {c["name"] for c in inspector.get_columns("board_webhooks")} 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: if "secret" in columns:
op.drop_column("board_webhooks", "secret") op.drop_column("board_webhooks", "secret")