Skip to content

fix(plugin-webhooks): move webhook custom headers onto the encrypted channel (#7986) - #8114

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-7986-webhook-headers-cleartext
Aug 12, 2026
Merged

fix(plugin-webhooks): move webhook custom headers onto the encrypted channel (#7986)#8114
huangyiirene merged 1 commit into
mainfrom
claude/issue-7986-webhook-headers-cleartext

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Closes part of #7986①-a only. ①-b and ①-f are deliberately not in this PR; see What this does NOT close below.

The finding, restated

#7799 moved the webhook signing secret out of sys_webhook.definition_json into an encrypted signing_secret column. It did not move the custom headers map — and headers is the ordinary place an Authorization: Bearer … goes.

sys_webhook declares no enable block at all, so it keeps the full default data API. An ordinary GET /api/v1/data/sys_webhook returned the whole header map, credentials included, to every persona that can read the object — with none of the retention bound that eventually ages out sys_http_delivery's copies.

Not a regression from PR #7901: the exposure predates it and nothing that card did made it worse. What was wrong was the conclusion a reader would reasonably draw from #7799 — that webhook credentials are no longer in a blob.

The shape, and the two I rejected

The card offered three candidates and the dispatch deliberately pre-selected none. I measured, then chose option 1 — move the whole map onto the encrypted channel — for ①-a.

Why the whole map and not just the credential-looking entries. Only some entries are credentials and the platform cannot tell which.

  • Guess from the header name (authorization, x-api-key, …) — rejected. Fail-open on exactly the custom spellings most likely to be a credential in practice (X-Acme-Token, X-Vendor-Key). A heuristic that silently passes the one header that mattered is worse than none, because it reads as coverage. Every other credential decision in this repo fails closed.
  • Author declares which are sensitive (secretHeaders: [...]) — rejected here, escalated. That is a change to the authoring envelope (packages/spec/src/automation/webhook.zod.ts) and belongs to the spec surface. It would also still leave the source: 'flow' half of the same exposure untouched: a flow http node's headers are interpolated per run and never pass through WebhookSchema at all.
  • Redact on readrejected for ①-a. [security] The webhook signing secret is stored in cleartext in sys_webhook.definition_json #7799's whole argument is that cleartext at rest is the defect, and sys_webhook is the surface that argument was made about: a configuration table with no retention bound holding the only copy, which reaches backups, replicas and exports. Nothing distinguishes the header map from the key there.

The cost option 1 is accused of — "it encrypts non-sensitive headers too and the admin UI can't read them" — is measured and small: definition_json is a raw JSON textarea pending a real builder (sys-webhook.object.ts says so in as many words), so what an admin loses is the ability to read back a Content-Type they typed, on a surface that was never the intended authoring UI. Writing still works, with the same mask-echo semantics signing_secret already has.

What changed

The authored headers map lands in a new sys_webhook.headers_secret column on the engine's encrypted credential channel, exactly as signing_secret does — engine encrypts into sys_secret, row keeps an opaque secret:<id> ref, every read path returns a mask. definition_json carries the same envelope minus both credential passengers.

Nothing about authoring changes.webhook.zod.ts is untouched, authors still write headers: { … }, and every authored header is still delivered on the wire byte-for-byte.

Fail-closed, and the explicit #8022 reading

The drop rule. A stored map that cannot be decrypted drops the subscription rather than delivering without it — the same trade #7799 made for the signature. This needs restating because the intuition runs the other way: a missing Authorization looks self-announcing, since the receiver answers 401 and the attempt lands in sys_http_delivery. But that is only the authenticated case. Against an endpoint that does not require the header — a routing X-Tenant-Id, an X-Environment: staging — the delivery succeeds while quietly deviating from the configuration the author wrote, and nothing anywhere records that it went out incomplete.

#8022's re-arm timing is unchanged, and that is measured, not assumed.onCryptoProviderChange is still bound before the first cache build; rearmAfterCryptoRegistered still refuses to coalesce onto the in-flight build. What changed is insidedoRefresh: both credentials are now resolved on the same rebuilt cache. That is load-bearing — a header map recovered on any other cadence would let the enqueuer re-arm into a delivery that is correctly signed and silently missing its Authorization, which is both cards' failure mode at once. Pinned by re-arms with its headers when the CryptoProvider registers.

The say-once ledger.droppedForSecret is now cleared only when both credentials resolve. Clearing it per-credential would let a row whose secret resolves and whose headers do not wipe its own "already reported" mark every refresh and shout the same error every 60s — precisely the unreadable-error-channel failure #8022's say-once rule exists to prevent.

One behavioural widening, stated plainly. The pre-crypto boot window now drops header-bearing webhooks as well as secret-bearing ones. #8022's re-arm closes that window on registration, and packages/cli/src/commands/serve.ts always wires a provider (LocalCryptoProvider in dev; production refuses to start without a stable key), so the window is the one #8022 already characterised. A webhook with no credentials still needs no CryptoProvider at all — pinned.

Reverse verification

Every pin's reading on origin/main (9e45153) and after. 10 new pins; full suite 56/56 green after.

#Pinorigin/mainAfterWhat it carries / guards
1credential header appears nowhere in the persisted sys_webhook row🔴🟢Defect ①-a — the bearer is in definition_json and in the data-API read
2still delivers every authored header on the wire, credential included🟢🟢Guards delivery — separates this fix from one that merely deletes headers from the blob
3re-seeding mints no extra cipher rows🔴🟢Defect — orphan sys_secret row per restart if the headers write is unconditional
4rotating declared headers re-encrypts and delivers the new value🟢🟢Guards code-is-authority rotation for package rows
5webhook authored without headers still materializes and delivers🟢🟢Guards "no credentials ⇒ no crypto ⇒ no CryptoProvider needed"
6sweep moves an admin-authored row's headers, keeps it delivering, idempotent🔴🟢Defect — the sweep did not touch headers; also pins re-run idempotence (no second cipher row)
7un-swept row keeps delivering and says so🔴🟢Defect — no cleartext-headers warning existed
8signing_secret still ciphertext, signing still byte-identical🟢🟢Guards #7799 (existing pins, kept green)
9drops rather than delivering with headers missing; ADR-0112 code+status🔴 †🟢Guards #7799/#8022 fail-closed, extended
10re-arms with its headers on CryptoProvider registration🔴 †🟢Guards #8022 re-arm timing

† Pins 9 and 10 are red on main at their precondition (sys_secret holds 2 cipher rows — one per credential), because on main only one credential is encrypted. Their behavioural assertions are what they guard after the fix. Flagging this rather than claiming a cleaner red: the population they need does not exist on main.

Pin 5 is the weakest of the set — on main the headers_secret column does not exist, so stored[field] ?? null is trivially null. Its non-vacuous half on both sides is the sys_secret size assertion.

Existing assertions I changed (not deleted): five #7799 pins asserted sys_secretrow counts written when the shared fixture carried one credential; the fixture authors headers too, so the counts are now 2 (or 1). I updated the counts and kept what each pin guards, and re-pointed the "cipher store holds a transform" assertion to select by namespace/key instead of by array position so it still says which row is the signing secret's. One assertion in the secret's bytes appear nowhere… previously pinned headers as a survivor of the definition_json strip; timeoutMs carries that half now. For the "authored without a secret" pin I dropped headers from its fixture too, so it keeps asserting exactly what it always asserted — a credential-free webhook does no crypto at all.

No test was deleted.

Gates

GateReading
plugin-webhooks suite56/56 pass (5 files)
tsc --noEmit (plugin-webhooks)clean, exit 0
check-test-source-aliasOK — 72 packages scanned, 63 registered. Unchanged: no new test file (pins extend webhook-secret-at-rest.test.ts) and no new workspace dependency. KNOWN_UNALIASED_TEST_IMPORTSnot touched.
check:type-check-coverageOK — 64/77 packages type-checked, 13 in DEBT, 1 exempt
check:type-check-debt (--re-measure)reading pending the full build closure the gate requires; will report
check:i18nreading pending the same closure (needs @objectstack/cli built); 4 locale bundles updated for the new field, following #7799's precedent
Migration (before / after / re-run)pin 6: blob contains the bearer → gone from API read and at-rest bytes, headers still delivered, managed_by not re-frozen → re-run reports found: 0 and mints no second cipher row
ADR-0112no new refusal surface; the extended drop report carries the existing INTERNAL_ERROR / 500 pair, now with the field naming which credential
Changeset.changeset/webhook-custom-headers-encrypted-at-rest.md
Untouchedcontent/docs/releases/**, docs/adr/**, .claude/skills/**, skills/**, sys-session.object.ts, packages/plugins/plugin-email/**, webhook.zod.ts; no enable block added to sys_webhook

What this does NOT close

①-b — sys_http_delivery.headers_json is still cleartext. Measured on this branch, after the fix: the enqueuer decrypts the map and hands it to enqueue(), which writes it verbatim to the delivery row.

{ "bearerReadableViaDataApi": true, "bearerAtRest": true,
"headersJson": "{\"Authorization\":\"Bearer prod_tok_…\",\"X-Team\":\"crm\"}",
"secretReadable": false, "sysSecretRows": 2 }

So #7986 stays open, and the "webhook headers are no longer in a blob" reading would be exactly the mistake this issue was filed to correct about #7799. Stated in the changeset for the same reason.

I stopped rather than landing a services-lane shape for ①-b, because every available option there is either structurally wrong or exits this lane:

  1. Field.secret() on headers_json — structurally wrong, three ways. (a) sys_http_delivery gets one row per delivery; a secret-typed write mints a sys_secret row every time and the engine has no delete cascade and no retention on sys_secret, so every delivery would leave an orphan cipher row forever while the delivery row itself ages out at 30d. The repo already treats one orphan per webhook per restart as a bug worth secretPatch — one per delivery is orders of magnitude worse. (b) encryptSecretFieldsthrows with no CryptoProvider, and AutoEnqueuer enqueues fire-and-forget (void … .catch(warn)), so header-bearing webhooks would silently stop delivering in Regression from #7799: for ~60s after every restart, a webhook holding an encrypted signing secret silently drops its subscription — no delivery, no sys_http_delivery row, while it still reads active:true #8022's boot window with no re-arm. (c) claim() would need a resolveSecretField (driver read + decrypt) per row on the dispatcher tick.
  2. Author-declared sensitive names — needs webhook.zod.tsand the flow HttpConfigSchema. This is the STOP fork; it transfers to domain:spec.
  3. Redact on read via internal: trueomitInternalFields has no system carve-out by explicit design (api-key-ui-lifecycle (secondary): the key column (SHA-256 hash) serializes over the data API, contradicting its own "never exposed to clients" description #7728), and SqlHttpOutbox.claim() reads headers through engine.find, so this breaks its own consumer. api-key-ui-lifecycle (secondary): the key column (SHA-256 hash) serializes over the data API, contradicting its own "never exposed to clients" description #7728 itself names the remedy — "a purpose-built privileged accessor, the way resolveSecret does" — which is an objectql change, another lane.

Also worth recording: #7722's shape does not extend here. It worked because a signature is a one-way derivative that can replace the secret. Headers must be sent verbatim. A store-a-reference-and-resolve-at-send variant is impossible generically — for source: 'flow' (http-nodes.ts) the headers are interpolated per run from run-scoped variables, refId is a node id, and there is no config row to read back.

①-f — sys_email.headers_json was examined and deferred, not overlooked. The dispatch cut it for an in-flight conflict (#7801 / PR #8064 holds plugin-email/src/email-service.ts, which is ①-f's read-back point), not a value judgement. It is the same shape as ①-b, so it should follow whatever ①-b's decision turns out to be.


Generated by Claude Code

…channel (#7986)
#7799 moved the signing secret out of `sys_webhook.definition_json`. It left
the custom `headers` map behind — and `headers` is the ordinary place an
`Authorization: Bearer …` goes. `sys_webhook` declares no `enable` block at
all, so `GET /api/v1/data/sys_webhook` handed the whole map back to every
persona that can read the object, with none of the retention bound that
eventually ages out the delivery table's copies.
The authored map now lands in a new `headers_secret` column on the same
encrypted channel as the key: the engine encrypts it into `sys_secret`, the row
keeps an opaque ref, reads return a mask, and the enqueuer recovers it through
`engine.resolveSecretField()` on the SAME cache refresh that recovers the
signing secret — so #8022's re-arm cannot produce a correctly-signed delivery
with its headers missing. The existing boot sweep moves already-persisted
cleartext headers out of the blob in one idempotent update alongside the key.
The whole map moves rather than the credential-looking entries: only some
entries are credentials and the platform cannot tell which. Guessing from the
header name is fail-OPEN on exactly the custom spellings (`X-Acme-Token`) most
likely to be one; letting the author declare which are sensitive is a change to
the authoring envelope and belongs to the spec surface. `webhook.zod.ts` is
untouched and every authored header is still delivered byte-for-byte.
Fail-closed and symmetric with #7799: a stored map that cannot be decrypted
DROPS the subscription rather than delivering without it. Against an endpoint
that does not require the header, a delivery missing its `Authorization`
succeeds while quietly deviating from the authored configuration, and nothing
records that it went out incomplete.
Does NOT close the issue end to end: the same headers are still written in
cleartext to `sys_http_delivery.headers_json` (measured after this change), and
closing that half needs a decision outside this package. `sys_email.headers_json`
is untouched for an in-flight-conflict reason, not a value judgement.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LiA1beRqJfW2XEmPVRoMqT
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 4:05pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-webhooks, @objectstack/spec.

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

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/spec)
  • content/docs/automation/approvals.mdx(via @objectstack/spec)
  • content/docs/automation/connectors.mdx(via @objectstack/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via packages/plugins/plugin-webhooks, @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via packages/spec)
  • content/docs/concepts/north-star.mdx(via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/cli.mdx(via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx(via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/spec)
  • content/docs/permissions/authorization.mdx(via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/spec)
  • content/docs/permissions/system-context.mdx(via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-webhooks, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/apps.mdx(via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

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

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-webhooks, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v16.mdx(via @objectstack/spec)
  • content/docs/releases/v17.mdx(via @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/spec)

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.

@huangyiirene
huangyiirene marked this pull request as ready for review August 12, 2026 16:32
@huangyiirene
huangyiirene added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 1602949Aug 12, 2026
27 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7986-webhook-headers-cleartext branch August 12, 2026 16:49
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@huangyiirene@claude