Skip to content

fix: reject replayed webhook deliveries - #22

Merged
TheGreatAxios merged 1 commit into
cl-9451-webhooks-verify-raw-body-and-decode-secretsfrom
cl-9450-webhooks-reject-replayed-deliveries
Sep 27, 2026
Merged

TheGreatAxios merged 1 commit into
cl-9451-webhooks-verify-raw-body-and-decode-secretsfrom
cl-9450-webhooks-reject-replayed-deliveries

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes CL-9450

Stacked on #21.

  • After verification, a delivery is claimed in a seen-set keyed by (credentialId, webhook-id) for Standard Webhooks and (credentialId, signature) for Slack. A repeat gets 409 { "error": "replayed" }.
  • Each key expires at its signed timestamp + 300s, exactly when that request would stop verifying, so the TTL matches the ±300s window.
  • Store: Postgres, shared across replicas. The seen-set is webhooks.replay (credential_id FK to the host's credential table with ON DELETE CASCADE, nonce, expires_at; primary key (credential_id, nonce); index on expires_at). It ships as migrations/0001_webhooks.sql, applied by runWebhookMigrations(dbConfig, { schema }) from @corbits/webhooks/migrations: idempotent, one transaction under pg_advisory_xact_lock, the same pattern as @corbits/agent-token. The route assumes the migration ran; there is no runtime DDL.
  • Claiming is a single INSERT … ON CONFLICT DO UPDATE … WHERE expired RETURNING, which is atomic across replicas; expired rows are purged on each claim.
  • A failed delivery (lookup error, no destination, deliverer error) releases its key, so the sender's retry of the same id still goes through.
  • Bearer hooks carry no id and are not deduplicated; the README says so.
  • New peers, matching @corbits/agent-token: drizzle-orm (^0.45.1, the range @intx/db already uses; src/ builds the replay queries with it on the host's db) and postgres (^3.4.8, used by the migration runner).

Tests: a unit replay (Slack) and retry-after-failure; an e2e that sends one Standard Webhooks request to two apps sharing the database and gets 202 then 409; and an e2e that runs runWebhookMigrations from three concurrent runners on a throwaway database and checks the FK and expiry index. CI's pack smoke also imports @corbits/webhooks/migrations.

@TheGreatAxios
TheGreatAxios added this pull request to stack #18 September 27, 2026 02:15
Key Standard Webhooks on (credential, webhook-id) and Slack on
(credential, signature) in a Postgres seen-set shared by replicas.
The webhooks.replay table ships as a migration, run by
runWebhookMigrations from @corbits/webhooks/migrations.
Refs CL-9450.
@TheGreatAxios
TheGreatAxios force-pushed the cl-9450-webhooks-reject-replayed-deliveries branch from 231e9a1 to 0b0ee54 Compare September 27, 2026 02:20
@TheGreatAxios
TheGreatAxios merged commit 431d05f into main Sep 27, 2026
1 check passed
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