Skip to content

feat(triggers): verify HMAC signatures on inbound webhooks - #86

Open
antiv wants to merge 1 commit into
mainfrom
claude/issue-83-trigger-signatures
Open

feat(triggers): verify HMAC signatures on inbound webhooks#86
antiv wants to merge 1 commit into
mainfrom
claude/issue-83-trigger-signatures

Conversation

@antiv

@antiv antiv commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Closes #83

Problem

POST /triggers/{id}/fire authenticates with a shared fire key. This mattered less when the body was discarded. Since #75 the body is interpolated into the agent's prompt through {{ payload }}, so whoever can present the fire key now controls text that goes straight into an LLM prompt — on an endpoint designed to be called by third parties.

A bearer secret cannot prove who composed a body. Anyone who has ever seen the key — a proxy log, a CI variable, a screenshot of the one-time banner — can forge any payload. And ?key= puts the secret in the URL, where it lands in access logs, proxy logs and browser history.

Change

Optional per-trigger signature verification, off by default so existing callers keep working.

Schema (V029__trigger_signatures.sql, all three databases): agent_triggers gains signing_secret and require_signature. A webhook trigger mints a signing secret alongside its fire key, shown once in the same dashboard banner. The secret is stored in the clear — verifying a signature means recomputing it, so a hash could not do — and to_dict() never returns it, only has_signing_secret and require_signature.

Verification (TriggerRunner.verify_signature): HMAC-SHA256 over the raw body, accepting both sha256=<hex> in X-Hub-Signature-256 (GitHub/GitLab) and a bare hex digest in X-MATE-Signature. Mirrors the existing Slack verifier at server/slack_routes.py:93hmac.compare_digest, minus the timestamp window.

Fire endpoint: reads the raw body before anything parses it (re-serialising parsed JSON would not reproduce the signed bytes), then checks the signature after fire-key auth. When required, a missing signature is 401 and a wrong one is 401 — a valid fire key on its own is refused. The existing 403 for a bad fire key is unchanged.

UI: a Require signed requests checkbox and a Regenerate Signing Secret button in the webhook section of the trigger modal; the one-time banner now carries both secrets, showing whichever was issued.

Docs: new "Verifying signatures" section in documents/TRIGGERS.md beside the payload section, with an openssl dgst -hmac example and GitHub/GitLab setup. ?key= is marked deprecated there and now logs a warning when used — it still works.

Deliberately out of scope, per the issue: replay protection (timestamp/nonce windows) and per-sender key rotation.

Tests

New shared/test/test_trigger_signature.py (22 tests): helper-level checks on both header shapes, wrong body, wrong secret, empty body, and that hmac.compare_digest is what compares; endpoint-level checks that a correct signature passes, a wrong or absent one is 401, a valid fire key without a signature is 401, verification runs over raw bytes before parsing, a digest over re-serialised JSON does not pass, and that a trigger with verification off behaves exactly as before; plus that to_dict() never leaks the secret.

One fixture line in test_trigger_payload.py gained require_signature=False — a bare MagicMock returns a truthy attribute for the new field.

Full suite: 747 tests, all passing.

Verified end-to-end against a running server, on a real trigger created through the API:

fire key + correct X-Hub-Signature-256 200
fire key + correct X-MATE-Signature 200
valid fire key, no signature 401
valid fire key, wrong signature 401
wrong fire key, correct signature 403
verification off, unsigned 200 (unchanged)
?key= 200 + deprecation warning logged

Migration applies cleanly; both columns confirmed present on the SQLite schema.

🤖 Generated with Claude Code

POST /triggers/{id}/fire authenticates with a shared fire key. This mattered
less when the body was discarded; since #75 the body is interpolated into
the agent's prompt through {{ payload }}, so whoever can present the fire
key controls text that goes straight into an LLM — a prompt-injection
surface on an endpoint designed to be called by third parties.

A bearer secret cannot prove who composed a body. Anyone who has ever seen
the key — a proxy log, a CI variable, a screenshot of the one-time banner —
can forge any payload. A signature binds the body to the sender; a shared
key does not. This is why GitHub, GitLab, Jira and Stripe all sign.

Add optional per-trigger signature verification, off by default so existing
callers keep working. A webhook trigger now carries a signing secret
alongside its fire key, and can require the body to be signed with
HMAC-SHA256 sent as X-Hub-Signature-256 (GitHub/GitLab) or X-MATE-Signature.
Verification runs over the raw bytes before JSON parsing, and compares with
hmac.compare_digest — the same shape as the Slack verifier in
server/slack_routes.py, minus the timestamp window.

When a trigger requires a signature, a missing or wrong one is rejected 401
and a valid fire key alone is not sufficient. The secret is stored in the
clear (verifying means recomputing it, so a hash could not do) and is never
returned by to_dict() — only has_signing_secret and require_signature are.

?key= is now deprecated: the secret lands in access logs, proxy logs and
browser history. It still works, and now logs a warning saying so.

Replay protection (timestamp/nonce windows) is deliberately out of scope;
signature verification is the prerequisite for it.

Closes #83

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: verify HMAC signatures on inbound trigger webhooks

1 participant