Skip to content

fix(webhooks): refuse malformed sys_webhook.headers_secret at the write door (#8566) - #8743

Merged
qq9340100 merged 1 commit into
mainfrom
claude/issue-8566-webhook-headers-secret-shape-gate
Aug 14, 2026
Merged

fix(webhooks): refuse malformed sys_webhook.headers_secret at the write door (#8566)#8743
qq9340100 merged 1 commit into
mainfrom
claude/issue-8566-webhook-headers-secret-shape-gate

Conversation

@qq9340100

Copy link
Copy Markdown
Collaborator

Fixes #8566

Implements the maintainer ruling of 2026-08-13 (comment 5287713447), option 2: a validation hook on sys_webhook that parses headers_secret before encryptSecretFields and refuses anything that is not a flat JSON object of string values, with a located ADR-0112 VALIDATION_ERROR naming the required shape, skipping an echoed read-mask.

What was wrong

headers_secret is a Field.secret() whose plaintext is not an opaque blob — it is a serialized header map with a required shape, and parseStoredHeaders is its only reader. 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 read back as the mask with active: 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 / beforeUpdate hook on sys_webhook, bound by WebhookOutboxPlugin before its first seeded write — the same shape as the bindWebhookProvenanceStamp hook next door.

Order is load-bearing and was measured, not assumed: before* hooks are dispatched before encryptSecretFields on every write path (insert engine.ts:8031 vs :8070; update :8817 vs :8998/:9206). One step later the plaintext is gone. The suite pins this rather than asserting it — every refusal asserts no sys_secret cipher 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); null to clear; an omitted key; an echoed read-mask, so the ordinary Setup-form round-trip is untouched; and an existing secret: ref.

"" is deliberately passed through to #8559's EmptyCredentialWriteError rather 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-plaintext secret field exists. ⛔ No second check on the plugin's own write paths; they inherit the hook.

Verification — all at HEAD 89685159e

  • pnpm --filter @objectstack/plugin-webhooks test112 passed (8 files), 19 new
  • pnpm --filter @objectstack/plugin-webhooks typecheck — clean
  • Reverse verification, direction predicted before running and matched exactly: neutering the verdict function turns 15 tests red — the 10 table refusals, the object-valued refusal, the 3 message pins, and the unbind pin — while the "" test, every accept test, and the gate-unbound counterfactual stay green. Restored from the commit; tree verified clean.
  • Gates: 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:i18n first reported "Nothing was checked" because @objectstack/cli was 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

`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
@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 14, 2026 5:44pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-webhooks.

2 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx (via packages/plugins/plugin-webhooks)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-webhooks)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-webhooks)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 14, 2026
@qq9340100
qq9340100 marked this pull request as ready for review August 14, 2026 18:07
@qq9340100
qq9340100 added this pull request to the merge queue Aug 14, 2026
Merged via the queue into main with commit b278695 Aug 14, 2026
27 checks passed
@qq9340100
qq9340100 deleted the claude/issue-8566-webhook-headers-secret-shape-gate branch August 14, 2026 18:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants