Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-webhooks): a stored signing secret that resolves to nothing parks the subscription instead of delivering UNSIGNED (#8542) - #8560
Conversation
… resolves to nothing (#8542) `resolveWebhookSecret` returned `undefined` for two different facts — "the author configured this webhook unsigned" and "a key IS stored and nothing came back" — and `AutoEnqueuer.attachSecret` acts on the first reading. So the second silently became the first: the subscription armed and every delivery went out unauthenticated while `sys_webhook` kept reading `active: true`. That is the #7799 signing invariant failing OPEN, beside two adjacent modes that fail closed and loud. Fixed at the seam, not at each caller: presence is already decidable there (a set secret comes back from the generic read path as the engine's mask), so a stored key that does not resolve now raises `WebhookSecretUnresolvableError` and reaches `attachSecret` exactly the way a throwing resolver already did — park, durable record (#8069), say-once `error` with the ADR-0112 pair. The redeliver guard (#8069/#8541) keeps its contract in both directions: an unresolvable key is still refused with its own reason, anything else still propagates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
os-zhuang
commented
Aug 13, 2026
PM review — ⭐ The reproduction requirement did exactly what it was there forThe dispatch said: the card's evidence is a trace, not an experiment — reproduce at least one trigger before changing anything, and report which you could and could not construct. The result rewrote the card's own story in both directions:
⭐ Two of the card's own sub-causes are falsified, measured: a written-back mask cannot land (
⭐ The asymmetry the whole fix rests on was verified rather than assumed: a set secret reads back as the engine's mask, an unset one as The fix is at the seam, and it shows
⭐ The error text naming both remedies is the detail I'd have missed: re-save the secret, or clear the field to The control, and why it is the right one
⭐ And leaving its header map declared on purpose, so the signing refusal is shown not to spill onto the sibling credential that resolves fine — that is a blast-radius check nobody asked for and it is the right instinct on a fail-closed change. ⭐ Vacuity trap closed the strong way: every pin asserts its own precondition ( PR #8541's Follow-ups#8558 (the identical collapse on Generated by Claude Code |
os-zhuang
commented
Aug 13, 2026
Docs drift check — measured, ⛔ no action needed. Do not open a patch round for it. The bot flagged It is not. ⛔ Both follow-ups routed (labels only — ⛔ the grade is triage's):
Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8542
resolveWebhookSecretreturnedundefinedfor two different facts — "the author configured this webhook unsigned" (legitimate,secretis optional on the envelope) and "a key IS stored and nothing came back" — andAutoEnqueuer.attachSecretacts on the first reading. So the second silently became the first: the subscription armed, every matching record change was delivered, and the HMAC signature simply stopped being attached. Nothing logged, nothing dropped,sys_webhookstill readingactive: truewith the secret column masked, so both the operator and Setup still read "this webhook is signed".That is the #7799 signing invariant failing OPEN, sitting immediately beside two adjacent modes that fail closed and loud — a throwing resolver and an engine with no encrypted-field channel, both of which park the subscription and report at
error, say-once (#8043).The card's evidence was a trace. This is what measurement changed.
The filer was explicit that the mechanism was read off the call path and never exercised, and the dispatch required reproduction before any edit. Reproduced against a real
ObjectQLengine (in-memory driver double, reversible test crypto, the realSysWebhookschema), onorigin/mainat116c0d95b:resolveSecretFieldopensif (!row) return null; the caller holds a snapshot that still says a secret is storedsecret:valuesys_secretrows, a seed script at driver levelsigning_secret: "", encrypts it, mints a realsys_secretrow and leaves a perfectly VALID ref behind⭐ Two of the card's own sub-causes for the middle trigger are falsified. A written-back mask cannot land —
encryptSecretFieldsdrops any masked field whose value equals the read mask, and the stored ref survives untouched. Cleartext through the engine cannot land either — it is re-encrypted into a fresh ref (and with no CryptoProvider it is refused fail-closed). So that trigger needs an out-of-engine write, and the honest story is narrower than "a written-back mask, a non-encrypting write path, or a hand-edited column".⭐ The asymmetry the fix stands on holds, measured. A set secret reads back through the generic path as the engine's mask, an unset one as
null; at rest the row holdssecret:sec_1. Presence is decidable at the seam without the value ever being readable — which is exactly the knowledge the oldundefinedreturn threw away.The fix, and why at the seam
resolveWebhookSecretnow raisesWebhookSecretUnresolvableErrorwhen presence is already established and the dereference answers nothing. Nothing inAutoEnqueuerneeded a new branch: the refusal lands in thecatchthat already parks the subscription, so the drop, the durablesys_http_deliveryrecord (#8069) and the say-once remedy-bearingerrorcarryingINTERNAL_ERROR/500(ADR-0112) all apply to it unchanged. One rule in one place instead of every consumer re-deriving it.The error text names the three causes in the order worth checking and both remedies — re-save the secret, or clear the field to
nullif the webhook is meant to be unsigned, because an empty secret is not the same thing as no secret.PR #8541's
RedeliverGuarddoes not regress, in either direction. Its two pins are unchanged and still pass byte for byte: a stored-but-unresolvable key still resolves to the refusal reason (the guard converts this seam's own error, and only this one), and any other failure still propagates, because "we could not check" must never read as "allowed".Reverse verification
Predicted before running: plain RED on the three new pins, and GREEN on the control plus every existing suite. Measured with the tests added and
webhook-secret.ts/redeliver-guard.ts/auto-enqueuer.tsreverted toorigin/main:With the implementation restored:
Test Files 6 passed (6) · Tests 70 passed (70).The failing assertion is the one that matters — on the unfixed tree a request reaches the receiver, carrying no
X-Objectstack-Signature.The vacuity trap, and how it is closed
A pin whose webhook was never given a secret exercises only the legitimate-unsigned arm and passes against a completely unfixed tree. So every pin here first asserts its own precondition: the row reads back
signing_secret === SECRET_MASK, the column is non-empty at rest, andactive: true. A fixture that had quietly lost its secret fails that precondition instead of passing vacuously.The control — what must NOT change
a webhook authored unsigned still arms and delivers — the refusal is not a blanketpins the working feature a naive fix would break: no secret stored, precondition asserted as the exact mirror (signing_secretreadsnull), delivery reaches the wire,status: 'success', no signature header, noerrorlogged. Its header map is left declared on purpose, so the signing refusal is also shown not to spill onto the sibling credential that resolves fine (#7986).Scope
packages/plugins/plugin-webhooks/src+ tests + changeset, as dispatched.service-messaging's outbox is untouched — nothing measured forced it. Rebased check:origin/mainhas not moved since the branch point (116c0d95b), and #7986's remaining surface issys_http_delivery.headers_jsoninservice-messagingwith no branch in flight, so there is no overlap.Filed out of scope, unassigned and unlabeled for triage:
resolveWebhookHeaders— the delivery goes out MISSING its authored headers #8558 — the identical collapse is still open onresolveWebhookHeaders, where the delivery goes out MISSING its authored headers, contradicting that file's own documented intent.""into asecretfield: it encrypts the empty string, mints asys_secretrow for it, and every read path then reports "a secret is set" #8559 — objectql accepts""into asecretfield, encrypts it, and leaves every read path reporting "a secret is set". That is what makes this card's third trigger reachable from the ordinary data API.Gates
Derived with
node scripts/pm/dispatch-gates.mjsover the actual changed paths, all green:check:changeset-gate-self-tests,check:objectui-changeset,check:test-source-alias,check:type-source-resolution,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset, plus the convention-scopedcheck:query-options-erasure,check:type-check-coverage,check:i18n(9 packages in sync),check:nul-bytes,check:engine-double-contract,check:error-code-casing,check:durability-log-level, and ESLint over the package.pnpm --filter @objectstack/plugin-webhooks testandtypecheckboth clean.Generated by Claude Code