ADR-0047: Webhook auth presets, trigger when and map¶
Status: accepted (KAN-1848)
Context¶
POST /webhook took any request. A real sender (GitHub, Slack, Alertmanager) proves itself with a signature
or a token, and the demos in mastra-projects/ each hand-wrote that check. Version 1.1 specs also read the
request as trigger.* (ADR-0027, KAN-1841) and can answer synchronously (respond: sync, KAM-109), so the
auth decision has to sit in front of both. Three quirks from the demos shaped the design: GitHub signs the raw
body as sha256=<hex> in X-Hub-Signature-256; Slack signs v0:<timestamp>:<body> as v0=<hex> in
X-Slack-Signature with the time in X-Slack-Request-Timestamp and refuses anything beyond five minutes;
Alertmanager does not sign at all and sends Authorization: Bearer <token>.
Decision¶
- Shape (version 1.1 only). On a webhook trigger:
auth: none | { type: bearer, secret_env } | { type: hmac, header, algo: sha256|sha1, prefix?, encoding: hex|base64, timestamp_header?, window_s?, payload?, secret_env }, andpreset: github | slack | alertmanager | generic. A preset expands to a concrete rule (resolveWebhookAuth);authbeside a preset supplies thesecret_envand may override any field, but not the type. Noauthand no preset isnone, which is today's behaviour.payloadis the signed text with{body}and{timestamp}(default{body}, or{timestamp}.{body}with a timestamp header; slack isv0:{timestamp}:{body}). - No literal secrets.
secret_envis the name of an environment variable (NAMEor${NAME}, upper case, digits and_). Anything else is a validation error with the line of the field, and the message does not repeat the value. The check is a shape check: an upper-case literal would pass, so it protects against the common mistake, not a determined one. - Raw bytes. The server's JSON and catch-all content-type parsers keep the body as a
Buffer(request.rawBody) and parse from it; signatures are computed over those bytes, so re-formatted JSON, a form body, or non-UTF-8 content verifies exactly as the sender signed it. - Verification.
crypto.createHmacover the template, compared withtimingSafeEqualafter a length guard (a different length is simply unequal). Bearer compares SHA-256 digests of both sides, so lengths never differ. A timestamp must be digits (seconds);|now - ts| > window_sis refused in both directions (default 300 for a timestamped scheme). Hex signatures compare case-insensitively. - One answer for every failure. A bad, missing, stale or malformed credential, and a secret variable that
is not set (fail closed; the server logs which variable, never a value), all return
401 { success: false, error: "Unauthorized" }. Nothing says which check failed. - Order. authenticate, then Slack
url_verification(a signed request withtype: url_verificationon the slack preset is answered200 { challenge }, no run), thenwhen, thenmap, then the server'srespondmode. Auth is first so an unauthenticated caller learns nothing fromwhen,mapor a sync answer, and cannot hold a connection open. when. A JSONata expression overtrigger(trigger.headers.\x-github-event`, the body's fields,trigger.body), evaluated as a condition (must be true or false). False means accepted-but-ignored:200 { success: true, ignored: true, reason }, no run is created, and the server records it (RunManager.recordIgnored, the latest 100 atGET /triggers/ignored). An expression that cannot be evaluated is422`, nothing runs.map.name: <expression>pairs over the sametrigger. The result replaces the body's fields astrigger.*;trigger.headersandtrigger.bodystay the request's (mapmay not set those two names). A field that selects nothing isnull. Credential-looking headers are not intrigger.headers(so the signature header is not readable bywhen/map/a step).RunTrigger.fieldscarries the result and is persisted with a pending approval.- Validation.
whenand eachmapvalue are parsed at spec load, with the line of the field, and may read onlytrigger. - One implementation, three places.
handleWebhookRequest(engine,webhook-trigger.ts) does all of the above and is whatkampong serveand the exporter's generatedsrc/server.tsboth call. The exporter vendors it (ADR-0010; parity-tested) withwebhook-auth.ts, which has a byte-identical copy inpackages/spec/src(asschedule.ts, ADR-0040) so the validator and the server apply the same rules. The export listssecret_envin.env.exampleand the README, and adds a README section.
Consequences¶
- An accepted run is still answered
201with its id (async), as before, not202: changing the status for every existing webhook user was not worth it for this card.202remains for a sync request that times out or waits for approval. Revisit if a sender needs 202 specifically. /slack/interactions(approval buttons) keeps its own fixedSLACK_SIGNING_SECRETcheck; it is not a trigger.- A body larger than Fastify's 1 MiB default is refused before verification, as before.
- Ignored requests are kept in memory only (lost on restart); they are a debugging aid, not an audit log.
- The canvas form for these fields is KAN-1850. Multiple accepted secrets (rotation) and multi-signature
headers (Stripe's
v1=...,v1=...) are not supported.