# Webhook security
The runtime daemon's inbound surface is HMAC-signed Standard Webhooks
v1. This page documents the threat model and the per-step mitigations.
## Wire framing
| `webhook-id` | Yes | Unique per delivery. Replay-cache key. |
| `webhook-timestamp` | Yes | Unix epoch seconds. ±5 min skew enforced. |
| `webhook-signature` | Yes | `v1,<base64-of-HMAC-SHA256>`. Multiple sigs supported during key rotation. |
| `Content-Type` | Yes | `application/json`. |
| `X-OpenLatch-Provider-Id` | Yes | `prv_…` |
| `X-OpenLatch-Binding-Id` | Yes | `bnd_…` — used to resolve the localhost route. |
| `X-OpenLatch-Event-Id` | Yes | `evt_…` (UUIDv7). Idempotency key. |
| `X-OpenLatch-Deadline-Ms` | Yes | Hard wall-clock budget. |
| `X-OpenLatch-Schema-Version` | Yes | Currently `1`. |
The verification rule:
```
HMAC-SHA256(whsec_live_…, "<webhook-id>.<webhook-timestamp>.<raw_body_bytes>")
```
## Threats and mitigations
| Forged webhook | HMAC-SHA256 over **raw body bytes** (never re-serialized). |
| Constant-time signature compare | `subtle::ConstantTimeEq` — no timing-oracle on the digest compare. |
| Replay attack (captured + delayed) | Timestamp ±5 min skew + LRU `webhook-id` cache (size 1000, TTL 5 min). Cache hit returns the previously-cached verdict (idempotent), NOT a 4xx. |
| JSON canonicalization bypass | Sign raw bytes, never parse-then-re-serialize. |
| SSRF via provider's `endpoint_url` | Probe enforces public-IP-only at register-time; runtime never follows redirects on outbound localhost calls. |
| Malicious vendor response | Strict size cap (250 KB); `serde_json` with no eval; verdict shape validated against `schemas/platform/provider-call.schema.json`. |
| Body parsing before verification | **Forbidden by design.** Verify-then-parse is enforced in `runtime/server.rs::handle_event` and called out in `.claude/rules/envelope-format.md`. |
## Replay-cache semantics
Inbound `webhook-id` already seen inside the 5-min window? Return the
**previously-cached signed verdict**, byte-identical to the original
response. This makes the runtime idempotent under retry storms:
- The platform's retry on a transient timeout sees the same response bytes.
- The cached signature was computed over the original timestamp, so
verification on the platform side stays correct.
- An attacker replaying a 6-min-old request lands a `OL-4226`
timestamp-skew rejection before the cache is even consulted.
Cache size = 1000 entries. On a 4-core host receiving 500 events/sec
that's ~1 second of history before LRU pressure — plenty for normal
retry behaviour without unbounded growth.
## Outbound verdict signing
The runtime signs every outbound verdict with the SAME `whsec_live_…`
secret used for the inbound signature, framed identically:
```
webhook-id: <generated UUIDv7>
webhook-timestamp: <unix>
webhook-signature: v1,<base64-hmac>
content-type: application/json
content-length: <≤ 250 KB>
```
The platform aggregator verifies against the same secret. Body size
cap: **250 KB** per [[OpenRouter of Security]] Decision 35.
## Secret rotation
```bash
openlatch-provider bindings rotate-secret <bnd_id>
```
Atomic rotation — platform-side and locally — with a one-time plaintext
reveal. Old secret is invalidated immediately (no overlap window in
v1; rotation-overlap is on the v0.2 roadmap).
## SSRF defense
The runtime calls localhost only. The provider's `endpoint_url` is
validated at register-time:
- Must be HTTPS (`OL-4240`).
- Must be a public IP at probe time, not RFC1918 / loopback / cloud
metadata IP (`OL-4241`, `OL-4246`, `OL-4247`).
- TLS minimum version 1.2 (`OL-4242`).
- Probe does not follow redirects (`OL-4243`).
- DNS resolved once at probe time; runtime connects to the resolved
IP and preserves SNI/Host header (DNS rebinding mitigation).
The platform's authoritative probe at register-time is the real gate;
the runtime's localhost call is structurally safe because it never
hits the public internet.
## Forbidden patterns
1. Modifying the inbound event body before HMAC verification.
2. Re-serializing JSON before signing or comparing signatures.
3. Caching verdicts beyond the 5-min replay-cache TTL.
4. Generating verdicts > 250 KB.
5. Reading agent-specific event fields by index instead of by name.