Skip to content

[security] a stored webhook signing secret that resolves to null is treated as "authored unsigned" — the subscription arms and delivers UNSIGNED #8542

Description

@os-zhuang

Found while implementing #8069 (PR #8541). Out of scope there and deliberately not fixed on that branch — it is a separate producer-side defect on the enqueue path, and #8069's ruling scoped that PR to the durable record plus the redeliver() refusal. Unassigned, no pm:queue, for triage to grade.

The defect

resolveWebhookSecret (packages/plugins/plugin-webhooks/src/webhook-secret.ts) collapses two different outcomes onto the same return value:

constplain=awaitresolver.resolveSecretField(object,String(row.id),WEBHOOK_SECRET_FIELD);returntypeofplain==='string'&&plain.length>0 ? plain : undefined;

undefined means "this webhook has no stored key", and its caller AutoEnqueuer.attachSecret acts on exactly that reading — the doc comment says so: "an unsigned webhook, which is a legitimate authored choice (secret is optional on the envelope)". So the subscription arms and every delivery goes out unsigned.

But resolveSecretField also returns null while a key IS stored. From packages/objectql/src/engine.ts:

  • if (!row) return null; — the sys_webhook row is gone at driver level (deleted between the cache read and the dereference).
  • resolveSecret(ref) opens const id = parseSecretRef(ref); if (!id) return null; — the stored value is not a secret:... ref. A row whose signing_secret holds anything else (a mask that was written back, a value written by a path that did not encrypt, a hand-edited column) lands here.
  • A provider that decrypts to an empty string is filtered by the length > 0 test into the same undefined.

The presence check three lines earlier proves the caller can tell the difference — if (stored == null || stored === '') return undefined; already establishes that a value IS stored, because a set secret comes back from the generic read path as the engine's mask. The function then discards that knowledge.

Why this matters

It is the #7799 invariant failing open, on the path #7799 exists to protect. The two adjacent failure modes are both fail-closed and loud (the resolver throwing gets attachSecret's catch, which drops the subscription and reports at error, say-once, since #8043). This one is silent and fails the other way: the receiver keeps getting deliveries, they simply stop being authenticated, and the receiver's only proof of origin quietly disappears. Nothing logs, nothing drops, sys_webhook still reads active: true.

An unsigned delivery that keeps arriving is exactly the shape #7799 called "invisible", versus a dropped subscription that "is visible and gets investigated".

Evidence and its limits

⚠️Measured by reading the call path, NOT by exercising it. I traced resolveWebhookSecretObjectQL.resolveSecretFieldObjectQL.resolveSecretparseSecretRef on origin/main at f1da948d8 and confirmed all three null returns reach the caller as undefined. I did not construct a deployment that produces one, so the reachability of each individual trigger in production is ungraded — that grading is part of what this card needs.

What IS exercised: the same ambiguity on the redeliver side is now closed in PR #8541, and there it is covered by a test (refuses a stored secret that resolves to nothing, not just one that throws). That guard asks "is a value stored and did nothing come back?" rather than relying on a throw, which is the shape a fix here would take. It closes the read; it does not close this write path, and #8541 says so.

Sketch of a fix (⛔ not decided here)

Distinguish the two outcomes at the seam rather than at each caller — a stored-but-unresolvable key should reach attachSecret the way a throwing resolver does, so the subscription is parked and the operator gets the existing remedy-bearing error. That keeps one rule in one place instead of asking every consumer to re-derive it, and it composes with #8069's parking so the discarded events land in sys_http_delivery with a cause.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions