Skip to content

[security] a dropped webhook subscription leaves no durable record — and the naive fix hands an operator a redeliver button that sends UNSIGNED #8069

Description

@huangyiirene

Filed by the domain:services PM seat at the explicit request of the dev on #8022 / PR #8043 — half (b) of that card, which they closed half (a) of and deliberately did not ride on the ordering fix. Unassigned, no pm:queue, for triage to grade.

The reason it was split out is the interesting part, and it is a trap worth reading before anyone "just adds a dead row".

The gap

When plugin-webhooks' auto-enqueuer cannot resolve a webhook's encrypted signing secret, it drops the subscription — correctly, fail-closed, rather than delivering unsigned (#7799). But the drop leaves no durable record: no sys_http_delivery row, so no dead-letter, no retry, no trace an operator can find. #8043 fixed the boot-ordering cause; it did not make the drop durable.

After #8043 the remaining causes are all persistent misconfigurations rather than a transient boot race — no provider ever registered, a missing sys_secret row, a decrypt failure under a rotated key, an engine without resolveSecretField. None self-heals, so none reproduces the "gone in a window nobody was watching, healthy forever after" signature that made #8022 urgent. The gap is real but differently shaped, which is exactly why it belongs in its own card rather than as a rider.

⚠️ The trap — why the obvious fix is worse than the gap

The obvious version is "write the drop as a dead row so an operator can see it and retry". Measured by the dev on #8043, that is actively unsafe:

  1. IHttpOutbox (packages/services/service-messaging/src/http-outbox.ts) has exactly one write doorenqueue(), which mints pending.
  2. A row parked for a missing key has no signature, because the signature is computed at enqueue from the very secret that could not be resolved.
  3. redeliver() resets any terminal row (success / failed / dead) back to pending for a byte-for-byte replay — and it is reachable by any authenticated user via POST /api/v1/webhooks/redeliver.

⇒ A dead row for an unresolvable-key drop gives an operator a button that delivers the webhook unsigned, reopening #7799 through a door nobody would think to audit. The fail-closed guarantee would be undone by the very feature added to make the failure visible.

What a correct fix needs (⛔ not decided here)

  • A never-sendable state for such a row — a new column on the sys_http_delivery platform object, so migration-visible; and
  • a redeliver() refusal for any row that requires a signature and does not have one.

That is a design decision inside service-messaging, touching the exact redeliver path that #8022's source retest verified working (signature verified on a redeliver replay). It wants review on its own merits.

Interim state — observability, ⛔ not durability

#8043 did make the drop loud rather than silent, cheaply and in-file: it now reports at error level with the consequence and the remedy in the message, carrying an ADR-0112 code/status pair (INTERNAL_ERROR / 500) in its metadata — the same pair the seeder's refusal for the same cause already used. Said once per outage per webhook (not every 60 s forever) per AGENTS.md "Degradation log levels"; a webhook that recovers and breaks again is loud again.

That is observability, not durability — the dev said so explicitly and it does not substitute for this card. An operator reading logs can now find it; an operator reading sys_http_delivery still cannot.

Explicitly NOT claimed

Evidence

Measured 2026-08-12 by the dev on PR #8043 (<!-- os-dev-report --> comment 5267475217 on #8022), reading packages/services/service-messaging/src/http-outbox.ts and the redeliver path.

Source

Split out of #8022 (PR #8043) at the implementing dev's request — half (b), deliberately not ridden on the ordering fix.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions