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:
committed by
Abhimanyu Saharan
parent
ce18fe4f0c
commit
528a2483b7
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user