Skip to content

fix(server): authenticate webhook ingress with HMAC + Cedar (ARN-171)#340

Open
rita-aga wants to merge 1 commit into
mainfrom
claude/arn-171-class-b-webhook-ingress
Open

fix(server): authenticate webhook ingress with HMAC + Cedar (ARN-171)#340
rita-aga wants to merge 1 commit into
mainfrom
claude/arn-171-class-b-webhook-ingress

Conversation

@rita-aga

@rita-aga rita-aga commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Closes part of ARN-171 (kernel Class B) under epic ARN-165. Draft for review — do not merge.

Problem

GET|POST /webhooks/{tenant}/{*path} dispatched a spec-declared entity action with security_ctx: Noneno Cedar authorization and no signature check. Tenant came from the URL and entity_id from a client query param (both attacker-chosen), so anyone who could reach the port could drive an arbitrary declared action on an arbitrary entity, unauthenticated. This is the kernel instance of systemic Class B.

Fix (crates/temper-server/src/webhooks/receiver.rs)

Two gates before any dispatch — order: lookup(404) → method(405) → HMAC(401) → entity-id(400) → Cedar(403) → dispatch.

  1. Authenticity — HMAC-SHA256 over the raw body, constant-time compared (subtle::ConstantTimeEq, not ==) against the signature header; secret resolved from the tenant vault via the existing {secret:KEY} resolver; optional sha256= prefix, case-insensitive; fail-closed on missing/unresolvable/wrong secret → 401. Revives the previously-dead Webhook.hmac_secret/hmac_header spec fields.
  2. Authorization — the same authorize_with_context path as OData writes, using a restricted webhook:{name} Agent principal (never System), against the entity's resource snapshot → 403 on deny. Denials are recorded (record_authz_denial) only for HMAC-authenticated callers, so the public route can't be used to amplify pending-decision records.

Tests (receiver_test.rs, split out so receiver.rs stays at 397 lines < 500-line rule)

cargo test -p temper-server --lib webhooks::receiver11/11 pass. Exploit tests captured red on unfixed code (missing signature, invalid signature, Cedar-denied). Covers: missing sig, wrong sig, full-length wrong sig (exercises the constant-time equal-length path), unresolvable secret → 401, Cedar-deny with and without a secret, full signed+authorized happy path.

⚠️ Backward-compat / rollout note

The route is now Cedar-authorized always, and a tenant with a policy set is default-deny. So a live webhook needs a permit for its principal:

permit(principal is Agent, action == Action::"<WebhookAction>", resource is <EntityType>)
when { principal.role == "webhook" };

In-repo enumeration: the only inbound webhook is test-fixtures/specs/gmail_oauth.ioa.toml (oauth_callback) — its DST test drives sim.step(...), not the HTTP route, so no regression. reference-apps/*/integration.toml [[webhook]] blocks are outbound dispatcher configs, unaffected. Out-of-repo production webhooks (TemperPaw OAuth, Katagami) must ship their webhook:* permit in the same deploy — the platform Cedar generator does not auto-emit it today (follow-up).

Reviews

DST review PASS (0 findings); code review PASS (re-confirmed on the final delta). Verified SecurityContext::from_headers(&[]) yields the most-restrictive principal (Customer, never Admin/System).

Residual

(a) fail-closed for no-secret webhooks depends on the tenant having a policy set loaded (parity with OData; documented in ADR-0156); (b) hex + optional sha256= prefix only — base64/Stripe-timestamped schemes and replay windows are explicit non-goals/follow-ups; (c) out-of-repo prod webhooks need permit policies landed with the deploy.

🤖 Generated with Claude Code

Greptile Summary

This PR closes a Class B kernel vulnerability where GET|POST /webhooks/{tenant}/{*path} dispatched entity actions with no authentication and no Cedar authorization — tenant and entity ID were both attacker-supplied. The fix inserts two ordered gates: an HMAC-SHA256 authenticity check (constant-time via subtle::ConstantTimeEq, fail-closed on unresolvable secret) and the same Cedar authorize_with_context path used by the OData write binding, using a restricted webhook:{name} Agent principal.

  • Gate 1 (HMAC): resolves the signing secret from the tenant vault via the existing {secret:KEY} template resolver; rejects with 401 on missing/unresolvable/wrong signature; Ok(false) when no secret is declared (fail-open for OAuth-style callbacks that providers cannot sign, still governed by Gate 2).
  • Gate 2 (Cedar): builds a webhook:{name} principal with an authenticated attribute reflecting HMAC result, evaluates against the entity's resource snapshot; denial records are written only for HMAC-authenticated callers to prevent pending-decision amplification via the public route.
  • Tests: 11 tests including exploit regressions (missing sig, wrong sig, full-length wrong sig, unresolvable secret → 401), Cedar deny with and without a secret, and the full signed+authorized happy path.

Confidence Score: 4/5

The two new security gates are correctly implemented and well-tested; the main thing to resolve before merging is the opaque-vs-revealing 401 error bodies and confirming production webhook permits are landed first per the rollout note.

The HMAC computation, constant-time comparison, fail-closed secret resolution, Cedar principal construction, and denial-record suppression for unauthenticated callers are all correct. The 11 exploit-regression tests cover the critical paths. The only concerns are two P2 items: three distinct 401 response bodies that reveal whether a secret is configured, and the ADR status left as Proposed.

The 401 error body differentiation in receiver.rs (verify_webhook_signature) and the ADR status field in docs/adrs/0156-class-b-webhook-ingress.md both warrant a quick fix before merge.

Important Files Changed

Filename Overview
crates/temper-server/src/webhooks/receiver.rs Core security fix: adds HMAC-SHA256 gate (constant-time via subtle) and Cedar authorization gate before any dispatch. Gate ordering matches ADR-0156. Minor: three distinct 401 error bodies reveal whether a secret is configured and whether a header was present.
crates/temper-server/src/webhooks/receiver_test.rs 11 tests covering exploit regression (missing sig, wrong sig, full-length wrong sig, unresolvable secret → 401), Cedar deny with and without a secret, and the full signed+authorized happy path. Well-structured with a shared vault and seeding helper.
docs/adrs/0156-class-b-webhook-ingress.md Thorough ADR covering both security gates, rollout plan, backward-compat notes, and alternatives. Status field left as Proposed rather than Accepted.
crates/temper-server/Cargo.toml Adds hex = 0.4 and subtle = 2; both are minimal, well-audited crates appropriate for this use case.
Cargo.lock Lock file updated to include hex and subtle; no unexpected transitive dependencies introduced.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller as External Caller
    participant Handler as handle_webhook
    participant Registry as SpecRegistry
    participant Vault as SecretsVault
    participant HMAC as signature_matches()
    participant Cedar as authorize_with_context()
    participant Dispatch as dispatch_tenant_action()

    Caller->>Handler: "GET|POST /webhooks/{tenant}/{*path}"
    Handler->>Registry: find_webhook(tenant, path)
    Registry-->>Handler: Some(entity_type, webhook) or None → 404
    Handler->>Handler: validate HTTP method → 405
    alt hmac_secret declared
        Handler->>Vault: resolve_webhook_secret(tenant, template)
        Vault-->>Handler: resolved secret or None → 401
        Handler->>Handler: read hmac_header → 401 if missing
        Handler->>HMAC: signature_matches(secret, body, provided)
        HMAC-->>Handler: bool via ct_eq → 401 if false
    end
    Handler->>Handler: extract entity_id from query params → 400 if missing
    Handler->>Cedar: load_authz_resource_snapshot(tenant, entity_type, entity_id)
    Cedar-->>Handler: resource_attrs or minimal fallback
    Handler->>Cedar: authorize_with_context(webhook:name principal, action, entity_type, attrs)
    alt Cedar denies
        Cedar-->>Handler: Err(denial)
        alt "authenticated=true"
            Handler->>Handler: record_authz_denial()
            Handler-->>Caller: 403 + decision.id
        else "authenticated=false"
            Handler-->>Caller: 403 (no record)
        end
    else Cedar permits
        Cedar-->>Handler: Ok(())
        Handler->>Dispatch: dispatch_tenant_action(entity_type, entity_id, action, params)
        Dispatch-->>Handler: Ok(response) or Err → 500
        Handler-->>Caller: 200 + JSON response
    end
Loading
%%{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 Caller as External Caller
    participant Handler as handle_webhook
    participant Registry as SpecRegistry
    participant Vault as SecretsVault
    participant HMAC as signature_matches()
    participant Cedar as authorize_with_context()
    participant Dispatch as dispatch_tenant_action()

    Caller->>Handler: "GET|POST /webhooks/{tenant}/{*path}"
    Handler->>Registry: find_webhook(tenant, path)
    Registry-->>Handler: Some(entity_type, webhook) or None → 404
    Handler->>Handler: validate HTTP method → 405
    alt hmac_secret declared
        Handler->>Vault: resolve_webhook_secret(tenant, template)
        Vault-->>Handler: resolved secret or None → 401
        Handler->>Handler: read hmac_header → 401 if missing
        Handler->>HMAC: signature_matches(secret, body, provided)
        HMAC-->>Handler: bool via ct_eq → 401 if false
    end
    Handler->>Handler: extract entity_id from query params → 400 if missing
    Handler->>Cedar: load_authz_resource_snapshot(tenant, entity_type, entity_id)
    Cedar-->>Handler: resource_attrs or minimal fallback
    Handler->>Cedar: authorize_with_context(webhook:name principal, action, entity_type, attrs)
    alt Cedar denies
        Cedar-->>Handler: Err(denial)
        alt "authenticated=true"
            Handler->>Handler: record_authz_denial()
            Handler-->>Caller: 403 + decision.id
        else "authenticated=false"
            Handler-->>Caller: 403 (no record)
        end
    else Cedar permits
        Cedar-->>Handler: Ok(())
        Handler->>Dispatch: dispatch_tenant_action(entity_type, entity_id, action, params)
        Dispatch-->>Handler: Ok(response) or Err → 500
        Handler-->>Caller: 200 + JSON response
    end
Loading

Fix All in Claude Code Fix All in Codex Fix All in Cursor

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
crates/temper-server/src/webhooks/receiver.rs:225-265
**Three distinct 401 bodies leak webhook security configuration**

`verify_webhook_signature` returns three semantically different response bodies under the same 401 status: `"Webhook signing secret is not configured"`, `"Missing webhook signature header '...'"`, and `"Webhook signature verification failed"`. These are all returned verbatim to the caller via `return (status, message)` in `handle_webhook`.

An attacker probing the endpoint can distinguish: (a) whether a webhook has HMAC configured at all, and (b) whether they need to supply a header — before they've presented any credential. This reveals internal webhook security posture. A single opaque message (`"Webhook authentication failed"`) would eliminate the signal without degrading legitimate error handling, since the structured detail is already captured in the `tracing::warn!` log.

### Issue 2 of 2
docs/adrs/0156-class-b-webhook-ingress.md:3
The ADR status should be updated from `Proposed` to `Accepted` before this is merged. A proposed ADR that never transitions to accepted leaves it ambiguous whether the team has ratified the decision.

```suggestion
- Status: Accepted
```

Reviews (2): Last reviewed commit: "fix(server): authenticate webhook ingres..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

The inbound webhook route GET|POST /webhooks/{tenant}/{*path} dispatched a
spec action with security_ctx: None — no Cedar authorization and no signature
verification (Class B, ARN-165). This adds one authenticated ingress:

- HMAC-SHA256 over the raw body, constant-time compared (subtle::ConstantTimeEq)
  against the signature header; secret resolved from the tenant vault via
  {secret:KEY} templates; fail closed on missing/unresolvable secret.
- A restricted webhook:{name} Agent principal authorized through the same
  authorize_with_context Cedar path as OData writes (default-deny per tenant);
  denials for authenticated callers recorded via record_authz_denial.

Order: lookup(404) -> method(405) -> HMAC(401) -> entity-id(400) -> Cedar(403)
-> dispatch. Revives the previously-dead Webhook.hmac_secret/hmac_header spec
fields. See docs/adrs/0156-class-b-webhook-ingress.md (incl. rollout note).

Red-green exploit tests added (11/11 green): missing/invalid/full-length-wrong
signature -> 401, unresolvable secret -> 401, Cedar-denied -> 403, signed+
authorized -> 200.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BM1pdoLhHH7TQgGuiadQiy
@rita-aga rita-aga marked this pull request as ready for review July 7, 2026 02:53
@rita-aga

rita-aga commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile review

action,
resource_type: &entity_type,
resource_id: &entity_id,
resource_attrs: serde_json::to_value(&resource_attrs).unwrap_or_default(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The unwrap_or_default() silently coerces a serialization failure to serde_json::Value::Null, violating the project's "No silent failures" rule. While BTreeMap<String, serde_json::Value> is infallible to serialize, the silent fallback would pass null as resource_attrs to record_authz_denial, potentially corrupting the denial record. An explicit panic with a comment is the appropriate TigerStyle alternative when the failure is provably unreachable.

Suggested change
resource_attrs: serde_json::to_value(&resource_attrs).unwrap_or_default(),
resource_attrs: serde_json::to_value(&resource_attrs)
.expect("BTreeMap<String, Value> serializes infallibly"),
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-server/src/webhooks/receiver.rs
Line: 167

Comment:
The `unwrap_or_default()` silently coerces a serialization failure to `serde_json::Value::Null`, violating the project's "No silent failures" rule. While `BTreeMap<String, serde_json::Value>` is infallible to serialize, the silent fallback would pass `null` as `resource_attrs` to `record_authz_denial`, potentially corrupting the denial record. An explicit panic with a comment is the appropriate TigerStyle alternative when the failure is provably unreachable.

```suggestion
                resource_attrs: serde_json::to_value(&resource_attrs)
                    .expect("BTreeMap<String, Value> serializes infallibly"),
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment on lines +282 to +285
if value.is_empty() || value.contains("{secret:") {
return None;
}
Some(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 False-positive on resolved secrets containing {secret:

The check value.contains("{secret:") is designed to detect unresolved templates (where resolve_secret_templates left the pattern verbatim). However, it is evaluated against the resolved value — i.e., what came back from the vault. If the actual signing secret stored in the vault happens to contain the literal substring {secret: (e.g., an unusual token format), the function incorrectly returns None and rejects every request with 401, even when the secret is fully configured. In practice signing secrets are random hex/base64 and this won't occur, but the check is logically applied to the wrong value. The correct approach is to compare the resolved value against the original template: if they are identical the template was unresolved.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-server/src/webhooks/receiver.rs
Line: 282-285

Comment:
**False-positive on resolved secrets containing `{secret:`**

The check `value.contains("{secret:")` is designed to detect unresolved templates (where `resolve_secret_templates` left the pattern verbatim). However, it is evaluated against the *resolved* value — i.e., what came back from the vault. If the actual signing secret stored in the vault happens to contain the literal substring `{secret:` (e.g., an unusual token format), the function incorrectly returns `None` and rejects every request with 401, even when the secret is fully configured. In practice signing secrets are random hex/base64 and this won't occur, but the check is logically applied to the wrong value. The correct approach is to compare the resolved value against the original template: if they are identical the template was unresolved.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment on lines +225 to +265
tenant: &TenantId,
webhook: &Webhook,
headers: &HeaderMap,
body: &[u8],
) -> Result<bool, (StatusCode, String)> {
let Some(secret_template) = webhook.hmac_secret.as_deref() else {
return Ok(false);
};

let secret = resolve_webhook_secret(state, tenant, secret_template).ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
"Webhook signing secret is not configured".to_string(),
)
})?;

let header_name = webhook
.hmac_header
.as_deref()
.unwrap_or(DEFAULT_SIGNATURE_HEADER);
let provided = headers
.get(header_name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
format!("Missing webhook signature header '{header_name}'"),
)
})?;

if signature_matches(&secret, body, provided) {
Ok(true)
} else {
Err((
StatusCode::UNAUTHORIZED,
"Webhook signature verification failed".to_string(),
))
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Three distinct 401 bodies leak webhook security configuration

verify_webhook_signature returns three semantically different response bodies under the same 401 status: "Webhook signing secret is not configured", "Missing webhook signature header '...'", and "Webhook signature verification failed". These are all returned verbatim to the caller via return (status, message) in handle_webhook.

An attacker probing the endpoint can distinguish: (a) whether a webhook has HMAC configured at all, and (b) whether they need to supply a header — before they've presented any credential. This reveals internal webhook security posture. A single opaque message ("Webhook authentication failed") would eliminate the signal without degrading legitimate error handling, since the structured detail is already captured in the tracing::warn! log.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-server/src/webhooks/receiver.rs
Line: 225-265

Comment:
**Three distinct 401 bodies leak webhook security configuration**

`verify_webhook_signature` returns three semantically different response bodies under the same 401 status: `"Webhook signing secret is not configured"`, `"Missing webhook signature header '...'"`, and `"Webhook signature verification failed"`. These are all returned verbatim to the caller via `return (status, message)` in `handle_webhook`.

An attacker probing the endpoint can distinguish: (a) whether a webhook has HMAC configured at all, and (b) whether they need to supply a header — before they've presented any credential. This reveals internal webhook security posture. A single opaque message (`"Webhook authentication failed"`) would eliminate the signal without degrading legitimate error handling, since the structured detail is already captured in the `tracing::warn!` log.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex Fix in Cursor

@@ -0,0 +1,227 @@
# ADR-0156: Authenticated webhook ingress (Class B)

- Status: Proposed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The ADR status should be updated from Proposed to Accepted before this is merged. A proposed ADR that never transitions to accepted leaves it ambiguous whether the team has ratified the decision.

Suggested change
- Status: Proposed
- Status: Accepted
Prompt To Fix With AI
This is a comment left during a code review.
Path: docs/adrs/0156-class-b-webhook-ingress.md
Line: 3

Comment:
The ADR status should be updated from `Proposed` to `Accepted` before this is merged. A proposed ADR that never transitions to accepted leaves it ambiguous whether the team has ratified the decision.

```suggestion
- Status: Accepted
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex Fix in Cursor

@rita-aga

Copy link
Copy Markdown
Collaborator Author

ARN-165 principle audit — request changes

The HMAC check is useful, but this PR does not yet create an authenticity boundary for ARN-171.

Blocking findings

  1. The signature does not bind the dispatched operation. It authenticates the raw body, while the entity/action routing parameters come from unsigned query data. A captured signed body can be retargeted or replayed against a different operation.
  2. No secret is not an authenticated mode. The no-secret path produces a verification failure, but the handler still constructs a webhook principal and relies on Cedar. Authorization can limit an authenticated principal; it cannot prove that the provider sent the request.
  3. Resource resolution can fail open. Snapshot failure is collapsed to minimal attributes, so policy evaluation may receive a weaker resource than the real target.
  4. Replay/idempotency is absent. A valid request can repeat downstream effects.
  5. The response is a configuration oracle, and the ADR is still Proposed. Authentication configuration should not be exposed through distinguishable behavior before the design is accepted.

Required direction

  • Authenticate a canonical envelope that includes the provider-defined fields or, for a generic scheme, method + canonical path/query + exact raw body + timestamp/delivery ID.
  • Fail closed before principal creation when authentication is absent or invalid.
  • Enforce a freshness window and durable idempotency key before effects.
  • Make resource-snapshot failure fail closed.
  • Add tampered-query, retarget, replay, no-secret, stale-timestamp, snapshot-failure, and live route tests.

Please resolve the ADR and redesign around a signed operation envelope rather than adding another local comparison.

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.

1 participant