Files
qbit-cdn/docs/revalidation-hook-migration.md
james.maesandClaude Opus 5.5 7510392166 feat(revalidation): sign hooks with core WebhookSigner and the safe HTTP client
Replace the hand-rolled HMAC in RevalidationHookRunner with core
WebhookSigner (Standard Webhooks) from QQQ 4.1.0-RC.5, and send through
core SafeHttpClient.

New cdn_revalidation_hook fields:
- signatureFormat: STANDARD_WEBHOOKS (the default for new rows) or LEGACY,
  which keeps X-Revalidate-Signature byte-for-byte. A null format, as on
  existing rows, stays LEGACY.
- allowPlainHttp and internalAllowlist: explicit exceptions to the SSRF
  policy.

Closes #15 (architecture review QRun-IO/qqq#1150, finding F11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:33:36 -05:00

4.1 KiB

Revalidation hook migration (F11 / qbit-cdn #15)

Reference: core source read directly at tag v4.1.0-RC.5, including WebhookSigner, WebhookSecret, WebhookVerifier, SafeHttpClient, SafeHttpPolicy, and AddressRules. No temporary core bridge is needed.

Receiver found locally

The plain Website-Frontend and Website-Publisher checkouts have no match for X-Revalidate-Signature. The implementation is in R:/Git.Local/KofTwentyTwo/Website-Frontend-wt-isr, branch feature/cdn-isr, HEAD 476318b at inspection (working files inspected):

  • src/lib/revalidate.ts:5: names x-revalidate-signature.
  • src/lib/revalidate.ts:9: signBody returns sha256= plus hex HMAC-SHA256 over the body using the raw string key (the calculation is line 10).
  • src/lib/revalidate.ts:14: verifySignature compares this value in constant time; line 21 lowercases the incoming legacy header.
  • src/app/api/revalidate/route.ts:30: reads CDN_REVALIDATE_SECRET.
  • src/app/api/revalidate/route.ts:36: reads the body before JSON parsing.
  • src/app/api/revalidate/route.ts:40: reads only the legacy signature header and calls the verifier; standard requests currently return 401.
  • src/__tests__/revalidate-route.test.ts:12: test requests send only the legacy header.

These receiver files were inspected, not modified.

Deployment and receiver changes

  1. Add the three hook columns documented in README. Existing rows (null signatureFormat) keep the LEGACY signature; switch a hook to STANDARD_WEBHOOKS after its receiver verifies Standard Webhooks. New rows default to STANDARD_WEBHOOKS. For internal HTTP endpoints also explicitly allow HTTP and the intended host.
  2. Update src/lib/revalidate.ts:14 to verify Standard Webhooks: require all three headers, validate an integer Unix timestamp within 300 seconds of the receiver clock (reject malformed/non-finite values), then HMAC-SHA256 the bytes <webhook-id>.<webhook-timestamp>.<raw body> and compare decoded signatures in constant time. Accept any matching v1,<base64> entry in the space-separated signature list. Do not lowercase base64 signatures. Match core secret parsing: trim text; decode whsec_ base64 keys, otherwise UTF-8; use at least 16 key bytes. Rotate short existing keys on both sides.
  3. At src/app/api/revalidate/route.ts:36, retain raw body bytes for verification; at line 40 pass all three headers to the new verifier before parsing JSON. Update its header import/documentation (lines 5 and 22). During migration the receiver may explicitly support both formats; do not fall back to legacy when standard headers are present but invalid.
  4. Update src/__tests__/revalidate-route.test.ts:12 and helper tests to cover valid standard requests, altered body/id/timestamp, missing headers, stale or future timestamps, malformed timestamps, encoded keys, and legacy mode.
  5. Deploy the receiver, then switch its hook to STANDARD_WEBHOOKS.

The JSON payload remains {"tags":[...]}. One UUID identifies a hook invocation, shared across its fan-out requests; each request gets its own current timestamp. A later dispatcher invocation generates a new UUID. Revalidation remains idempotent, so receivers must not rely on this ID to deduplicate purge retries; any optional deduplication must remain local to each pod, not suppress other pods.

Compatibility and verification

The legacy HMAC helper remains solely for the explicitly supported legacy format, not as a temporary replacement for core. Golden tests use fixed digests calculated independently of that helper, including a short key and a whitespace/prefix key. Standard requests are checked using RC.5 WebhookVerifier; transport tests cover HTTP/private-address refusal, explicit local exceptions and mixed-address fan-out.

The test/collaborator constructor now takes (secrets, resolver); callers no longer inject java.net.http.HttpClient. The production no-argument constructor remains. The only existing test adaptation outside the runner tests is PurgePipelineTest using this constructor. The RC.5 bump required no unrelated code fixes.