fix(paw-ingest): real webhook HMAC verification, fail-closed (ARN-168) - #451
fix(paw-ingest): real webhook HMAC verification, fail-closed (ARN-168)#451rita-aga wants to merge 2 commits into
Conversation
The `/triggers/webhook/{route_key}` endpoint is public, so the request
signature is the only thing authenticating an inbound webhook. The
validate_webhook WASM integration only checked that the signature *header
was present* — it never computed or compared HMAC-SHA256(secret, body) —
and returned hmac_verified:"true" whenever the header existed, dispatching
the payload regardless. Any body with a bogus X-Hub-Signature-256 was
accepted and routed into the scout/SRE/heal/patrol agents (Class B).
Fix (mirrors the kernel counterpart for ARN-171):
- Compute HMAC-SHA256(secret, raw_payload) and constant-time compare it
(subtle::ConstantTimeEq, not ==) against the signature header.
- Accept an optional `sha256=` prefix, case-insensitively; header lookup
is case-insensitive.
- Resolve the signing secret from the route: literal value or {secret:KEY}
template resolved from the host secret store.
- Fail closed: a route that declares a secret but has a missing,
unresolvable, or mismatched signature transitions the WebhookEvent to
ValidationFailed and is never routed/processed. Routes with no secret
keep the existing "skipped" carve-out.
Verification logic is factored into pure, host-free helpers
(classify_secret, extract_header, verify_signature, signature_matches)
covered by a red→green unit suite: a forged signature (previously accepted
as "true") and a tampered body / wrong secret / missing header are now
rejected, a correctly signed payload is accepted. Adds hmac, sha2, hex,
subtle (pure-Rust, compile for wasm32-unknown-unknown). See
os-apps/paw-ingest/adrs/001-webhook-hmac-verification.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@greptile review |
ARN-165 / ARN-168 principle audit — request changesThe raw-byte HMAC primitive is improved, but this PR has no secure, usable production configuration and does not close ARN-168. Blocking findings
Required directionRequire an explicit provider auth scheme and nonempty governed secret reference for every public route; migrate all existing routes and fail readiness if missing. Authenticate/admit before creating The two unresolved The PR is currently conflicting and its body says “do not merge” while GitHub marks it ready. Please return it to draft. |
|
@greptile review Fresh review requested for head 37c0f98. The prior validator and ct_eq implementation were removed. Current admission verifies decoded HMAC bytes with Mac::verify_slice at the raw HTTP boundary, uses an injected tenant-vault resolver, binds replay identity to authoritative stored payload/route fingerprints, rejects malformed and ambiguous requests before persistence, and has a passing live E2E proof. Please review the entire current diff against main. |
Closes ARN-168 (CRITICAL) — TemperPaw Class B webhook authentication. Draft for review; do not merge.
Ownership
claude/arn-168-webhook-hmac)Security model
The public webhook trigger is the single admission owner:
Mac::verify_slice.Receivedenvelope; downstream WASM never re-reads mutable route state.Changed content under a consumed delivery ID returns 409 without dispatch. Exact replay returns the original event as
duplicate. A crash-leftCreatedevent retries only while its stored route snapshot still matches current governance.Removed unsafe/duplicate paths
validate_webhookWASM verifier.Verification
cargo test -p paw-transport: 45 passedpaw-transport+temperpaw: passedduplicateProof report:
.proofs/081-arn-168-authenticated-webhook-admission.mdLocal proof bundle:
/tmp/paw-patrol-webhook-smoke-proof-4531-56005Residual
The per-route rate counter is process-local (expired entries are pruned and tracked routes are capped at 4,096). Replay integrity is durable in the shared entity store. A horizontally scaled trigger should move rate accounting into a shared Temper admission primitive.
No production deploy was performed because this PR must remain open/draft.
Greptile Summary
This PR replaces a placeholder webhook authentication path (empty secrets, WASM-side hex-string
ct_eq) with real fail-closed HMAC-SHA256 admission at the raw HTTP boundary:Mac::verify_sliceover decoded hex on the exact request bytes, secret resolved exclusively through an injected tenant-scoped vault closure that never touches HTTP, and an atomic get-or-create replay guard that binds delivery identity to payload and route-snapshot digests before any dispatch.principalaccess toWebhookRouteandWebhookEventis replaced by module-scopedAgentgrants; the deletedvalidate_webhookmodule is removed from all capability lists.dedup_window_minutesroute parameter is plumbed through the envelope but thecheck_dedupquery inroute_webhookdoes not apply a time-window filter, and the dedup key only matchesalert_id/idfields — two functional gaps that are currently dormant because all seeded routes ship withdedup_enabled = "false".Confidence Score: 5/5
Safe to merge — the core admission path is correctly hardened and the two findings are in the optional WASM dedup feature that ships disabled in all seeded routes.
The admission boundary is solid: HMAC verification uses decoded bytes with Mac::verify_slice, the secret resolver never crosses an HTTP boundary, replay is guarded atomically, and rate budget is consumed only after authentication. The only gaps are in the WASM-level content dedup (unbounded time window, limited key extraction) which is currently inert because every seeded route has dedup_enabled = false. No issues affect the authentication, authorization, or dispatch paths introduced by this PR.
os-apps/paw-ingest/wasm/route_webhook/src/lib.rs — the check_dedup function does not enforce dedup_window_minutes and only extracts alert_id/id as the dedup key; worth addressing before dedup is enabled on any route.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Sender as Webhook Sender participant Trigger as HTTP Trigger(paw-transport) participant Vault as SecretResolver(in-process) participant Store as Temper Entity Store participant WASM as route_webhook WASM Sender->>Trigger: "POST /triggers/webhook/{route_key} + signature + delivery_id headers + body" Note over Trigger: Check in-flight semaphore (32) Trigger->>Store: "query_entities(WebhookRoutes, route_key eq ... and Status eq Active, top=2)" Store-->>Trigger: RouteSnapshot or empty Note over Trigger: Validate body size <= route budget. Extract signature + delivery_id headers (reject duplicates) Trigger->>Vault: resolve(secret_ref) Vault-->>Trigger: signing secret (in-process, never HTTP) Note over Trigger: HMAC-SHA256(secret, raw_bytes) Mac::verify_slice(provided_decoded_hex) 401 on mismatch Note over Trigger: Consume rate window (only after auth) Note over Trigger: Validate UTF-8 + JSON object Note over Trigger: Compute deterministic event_id = SHA256(v1 + tenant + route_id + delivery_id) Note over Trigger: Compute payload_digest + route_snapshot_digest Trigger->>Store: "create_entity(WebhookEvents, {Id: event_id, identity fields})" Store-->>Trigger: created or existing entity (atomic get-or-create) alt Stable identity mismatch (changed content, same delivery_id) Trigger-->>Sender: 409 CONFLICT else Entity not in Created state Trigger-->>Sender: 200 status duplicate else Route snapshot changed since delivery reservation Trigger-->>Sender: 409 CONFLICT else Fresh Created entity Trigger->>Store: dispatch_action(WebhookEvents, event_id, TemperPaw.Ingest.Received, immutable_envelope) Store-->>WASM: Received trigger Store-->>Trigger: OK Trigger-->>Sender: 200 status accepted WASM->>Store: create target entity + dispatch Routed end%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Sender as Webhook Sender participant Trigger as HTTP Trigger(paw-transport) participant Vault as SecretResolver(in-process) participant Store as Temper Entity Store participant WASM as route_webhook WASM Sender->>Trigger: "POST /triggers/webhook/{route_key} + signature + delivery_id headers + body" Note over Trigger: Check in-flight semaphore (32) Trigger->>Store: "query_entities(WebhookRoutes, route_key eq ... and Status eq Active, top=2)" Store-->>Trigger: RouteSnapshot or empty Note over Trigger: Validate body size <= route budget. Extract signature + delivery_id headers (reject duplicates) Trigger->>Vault: resolve(secret_ref) Vault-->>Trigger: signing secret (in-process, never HTTP) Note over Trigger: HMAC-SHA256(secret, raw_bytes) Mac::verify_slice(provided_decoded_hex) 401 on mismatch Note over Trigger: Consume rate window (only after auth) Note over Trigger: Validate UTF-8 + JSON object Note over Trigger: Compute deterministic event_id = SHA256(v1 + tenant + route_id + delivery_id) Note over Trigger: Compute payload_digest + route_snapshot_digest Trigger->>Store: "create_entity(WebhookEvents, {Id: event_id, identity fields})" Store-->>Trigger: created or existing entity (atomic get-or-create) alt Stable identity mismatch (changed content, same delivery_id) Trigger-->>Sender: 409 CONFLICT else Entity not in Created state Trigger-->>Sender: 200 status duplicate else Route snapshot changed since delivery reservation Trigger-->>Sender: 409 CONFLICT else Fresh Created entity Trigger->>Store: dispatch_action(WebhookEvents, event_id, TemperPaw.Ingest.Received, immutable_envelope) Store-->>WASM: Received trigger Store-->>Trigger: OK Trigger-->>Sender: 200 status accepted WASM->>Store: create target entity + dispatch Routed endReviews (3): Last reviewed commit: "fix(paw-ingest): enforce authenticated w..." | Re-trigger Greptile