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.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions