fix(webhooks): refuse malformed sys_webhook.headers_secret at the write door (#8566) - #8743
Conversation
`sys_webhook.headers_secret` is a `Field.secret()` whose plaintext is a
serialized header map with a required shape — a flat JSON object of string
values — and nothing validated that shape on the way in. The ordinary data
API accepted any string, encrypted it, minted a real `sys_secret` row and
left a valid `secret:` ref that reads back as the mask with `active: true`.
Measured through `engine.update()`: `{}`, `[]`, `{"X-Count":5}`, a nested
object and `{X-Team: crm}` were all accepted and are all unusable.
Not an exposure fix — #8558/#8565 already made the consumer half park the
subscription and report at `error`. This moves the diagnosis to the door,
where the author is still standing, instead of at the next delivery.
A `beforeInsert`/`beforeUpdate` hook on `sys_webhook`, bound by
WebhookOutboxPlugin before its first seeded write, so it runs BEFORE the
engine's `encryptSecretFields` — one step later the plaintext is gone.
Refuses with a located ADR-0112 VALIDATION_ERROR/400 that quotes the shape
the field's own description asks for and never echoes the rejected value
(this column carries credentials). Lets through: null (clear), an omitted
key, an echoed read-mask (the Setup form round-trip), an existing ref, and
`""` — which stays #8559's `EmptyCredentialWriteError` so one door keeps
one owner and one message.
The suite measures the ordering rather than asserting it: every refusal
pins that no `sys_secret` cipher row was minted, plus a counterfactual with
the gate unbound showing the write still sails through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn
|
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:
|
Fixes #8566
Implements the maintainer ruling of 2026-08-13 (comment
5287713447), option 2: a validation hook onsys_webhookthat parsesheaders_secretbeforeencryptSecretFieldsand refuses anything that is not a flat JSON object of string values, with a located ADR-0112VALIDATION_ERRORnaming the required shape, skipping an echoed read-mask.What was wrong
headers_secretis aField.secret()whose plaintext is not an opaque blob — it is a serialized header map with a required shape, andparseStoredHeadersis its only reader. Nothing validated that shape on the way in: the ordinary data API accepted any string, encrypted it, minted a realsys_secretrow, and left a validsecret:ref that read back as the mask withactive: true. All five spellings from the card's table were accepted and are unusable —{},[],{"X-Count": 5}, a nested object, and{X-Team: crm}.Not an exposure fix, and not graded as one. #8558/#8565 already closed the consumer half. What changes is when the author finds out: at the write door instead of at the next delivery.
The mechanism, and the one thing it turns on
A
beforeInsert/beforeUpdatehook onsys_webhook, bound byWebhookOutboxPluginbefore its first seeded write — the same shape as thebindWebhookProvenanceStamphook next door.Order is load-bearing and was measured, not assumed:
before*hooks are dispatched beforeencryptSecretFieldson every write path (insertengine.ts:8031vs:8070; update:8817vs:8998/:9206). One step later the plaintext is gone. The suite pins this rather than asserting it — every refusal asserts nosys_secretcipher row was minted, which is only true if the gate ran first.The verdict reuses
parseStoredHeaders, the consumer's own reader, so the door refuses exactly what the consumer cannot use and the two cannot drift into a second shape rule.What stays accepted
Valid flat string maps (as JSON text, or as an authored object the engine serializes into the same form);
nullto clear; an omitted key; an echoed read-mask, so the ordinary Setup-form round-trip is untouched; and an existingsecret:ref.""is deliberately passed through to #8559'sEmptyCredentialWriteErrorrather than re-refused here — one door, one owner, one message.The refusal never echoes the value
This column carries credentials. A message quoting the input would print an
Authorization: Bearer …into logs and error bodies, re-opening in the diagnostic exactly what #7986 moved this field onto the encrypted channel to close. The diagnostic names header keys and value types only — pinned by a test that plants a token and asserts it is absent from the message while the key is present.⛔ Option 3 (a general
secret-channel plaintext validator) is not built — recorded as the promotion path for when a second shaped-plaintextsecretfield exists. ⛔ No second check on the plugin's own write paths; they inherit the hook.Verification — all at HEAD
89685159epnpm --filter @objectstack/plugin-webhooks test— 112 passed (8 files), 19 newpnpm --filter @objectstack/plugin-webhooks typecheck— clean""test, every accept test, and the gate-unbound counterfactual stay green. Restored from the commit; tree verified clean.check:nul-bytes,check:error-code-casing,check:test-source-alias,check:type-source-resolution,check:i18n(plugin-webhooks in sync),check:engine-double-contract,check:query-options-erasure,check:type-check-coverage,check:type-check-debt --re-measure,check:empty-changeset,check:adr-0087-registration— all green.check:i18nfirst reported "Nothing was checked" because@objectstack/cliwas unbuilt; re-run after building it, and it is that second run reported above.Changeset:
.changeset/webhook-headers-secret-shape-gate.md(@objectstack/plugin-webhooks: patch).Generated by Claude Code