feat(triggers): verify HMAC signatures on inbound webhooks - #86
Open
antiv wants to merge 1 commit into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #83
Problem
POST /triggers/{id}/fireauthenticates 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_triggersgainssigning_secretandrequire_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 — andto_dict()never returns it, onlyhas_signing_secretandrequire_signature.Verification (
TriggerRunner.verify_signature): HMAC-SHA256 over the raw body, accepting bothsha256=<hex>inX-Hub-Signature-256(GitHub/GitLab) and a bare hex digest inX-MATE-Signature. Mirrors the existing Slack verifier atserver/slack_routes.py:93—hmac.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.mdbeside the payload section, with anopenssl dgst -hmacexample 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 thathmac.compare_digestis 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 thatto_dict()never leaks the secret.One fixture line in
test_trigger_payload.pygainedrequire_signature=False— a bareMagicMockreturns 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:
X-Hub-Signature-256X-MATE-Signature?key=Migration applies cleanly; both columns confirmed present on the SQLite schema.
🤖 Generated with Claude Code