Skip to content

fix(webhooks): stop storing the subscriber signing secret in cleartext (#7799) - #7901

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-7799-webhook-subscriber-secret
Aug 12, 2026
Merged

fix(webhooks): stop storing the subscriber signing secret in cleartext (#7799)#7901
huangyiirene merged 3 commits into
mainfrom
claude/issue-7799-webhook-subscriber-secret

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Closes#7799

What was wrong

bootstrap-declared-webhooks.ts:203 serialized the whole validated Webhook envelope — secret included — into definition_json, and auto-enqueuer.ts:parseRow read defn.secret straight back out to sign deliveries. definition_json is an ordinary Field.textarea on an admin-authorable object with no restrictive enable.apiMethods, so a plain GET /api/v1/data/sys_webhook returned the subscriber's HMAC key to every persona that can read the object. That key is the receiver's only proof a delivery came from us.

This is the half #7722 scoped out: that issue removed the same secret's per-attempt copies from sys_http_delivery; this is the remaining cleartext location, and unlike the delivery table nothing ages it out.

The shape

The authored key now lands in a new sys_webhook.signing_secret column of type: 'secret' — the engine's existing encrypted credential channel:

authored `secret` → sys_webhook.signing_secret (Field.secret)
→ engine encrypts via ICryptoProvider
→ ciphertext row in sys_secret
→ row keeps only an opaque `secret:<id>` ref
→ every read path returns the mask
definition_json → the same envelope MINUS `secret`

AutoEnqueuer recovers the plaintext server-side on each cache refresh and hands it to the outbox exactly as before, so the delivered X-Objectstack-Signature is byte-identical and no receiver changes.

Why this could not be done plugin-locally

Two constraints, both measured rather than assumed:

  • The plugin cannot encrypt on its own. The ICryptoProvider is injected into the engine by the host (serve.tsengine.setCryptoProvider) and is not a kernel service, so ctx.getService() cannot reach it. The datasource-binder shape — a plain text column holding sys_secret:<id>, written by a binder the consumer owns — is therefore unavailable here. Writing cleartext into a secret-typed column and letting the engine's own write path wrap it is the only door, and it inherits the engine's fail-closed posture for free.
  • The plugin could not read the ref back.maskSecretFields replaces it with the mask on every find/findOne, unconditionally and after hooks. So ObjectQL.resolveSecret(), documented for "privileged consumers … against the stored ref", had no supported way for a consumer to obtain that ref. This PR adds ObjectQL.resolveSecretField(object, recordId, field) — a driver-level row read plus the existing decrypt — which is what makes the encrypted channel usable by a server-side consumer at all. It refuses any field not declared type: 'secret', so it cannot become a generic mask bypass over a password field (plaintext at rest by design, ADR-0100).

The authoring envelope is untouched

packages/spec/src/automation/webhook.zod.ts is not modifiedsecret stays exactly where authors write it, and defineWebhook({ secret }) is unchanged. No packages/spec/src/** file is touched, so no schema/docs regeneration is in play. The only file under packages/spec/ is the liveness/webhook.json ledger, whose secret evidence prose pointed at the cleartext path this PR removes (status stays live).

Migration / compat — what happens to existing rows

Row kindOutcome
Package-declared (managed_by: 'package', not customized)Rewritten by the seeder on the next boot: key encrypted, blob stripped.
managed_by: 'admin'Swept by migrateLegacyWebhookSecrets at boot.
customized: true (frozen package row)Swept by the same pass.
Any of the above, no ICryptoProvider wiredLeft exactly as it was — still cleartext, still signing. Reported per row with an ADR-0112 code/status pair so it is visible, not silent.

The sweep runs after the seeder (so a just-reseeded row is a no-op), writes with system context (so it does not stamp customized: true and freeze a package row), and stores the encrypted copy in the same update that strips the blob — a failure can never leave a webhook stripped and unsigned. It is idempotent and free on every boot after the first.

Until a given row is swept, AutoEnqueuer still reads the legacy blob and keeps signing, warning each refresh that the value is exposed. A row that has an encrypted key which cannot be decrypted is dropped rather than delivered unsigned — an undelivered webhook is visible and gets investigated; an unsigned one is invisible and teaches the receiver to accept unauthenticated traffic.

Re-seeding compares the declared key against the stored plaintext and writes only on an actual change, so a boot no longer mints an orphan sys_secret row per webhook per restart; rotating the secret in code still propagates.

Tests

packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts, driven against a real ObjectQL engine with the real SysWebhook schema (only the driver is a double) — an engine fake would echo back whatever it was handed and pass every assertion here while the product stayed broken:

  • Byte-level scan — substring assertion on the whole serialized row from the normal read path and on the bytes at rest, plus definition_json specifically. A per-field check walks straight past a key nested in a blob.
  • HMAC recompute — the receiver's own check (createHmac('sha256', SECRET) over the raw body) still matches the delivered X-Objectstack-Signature, and [security] Webhook HMAC signing secrets are persisted in cleartext on every sys_http_delivery row #7722's no-secret-on-the-delivery-row invariant still holds.
  • Re-seed does not mint a second sys_secret row; rotation re-encrypts and signs with the new key; a secret-free webhook still delivers unsigned with no crypto cost.
  • Migration — an admin row the seeder never touches is swept, the cleartext is gone from both the API read and the table, signing is unchanged, provenance is preserved, and the second run is a no-op.
  • Fail-closed — with no CryptoProvider the seed is refused (nothing persisted at all) and the legacy row is left byte-for-byte intact, both reporting { code: 'INTERNAL_ERROR', status: 500 }.
  • A pin test asserts the two objectql wire forms restated in webhook-secret.ts (mask, ref prefix) still equal objectql's own exports — the plugin takes no runtime dependency on objectql, and signing with the mask would silently break every delivery.

packages/objectql/src/secret-fields.test.ts gains coverage for resolveSecretField: it recovers plaintext when the caller can only see the mask, returns null for an unset secret and a missing row, and refuses both a plain column and a password field.

Both stub drivers now return copies of stored rows. Handing out the live object made the doubles lie in the one direction that matters here: maskSecretFields mutates the rows it is given, so a single find stamped the mask over the stored ref and the next resolve read it back as "no secret" — an artefact that reads exactly like an engine bug.

The doubles added here are drivers (update(object, id, data) — primary key second), which check:engine-double-contract scopes out; no hand-rolled engine double is added.

Gates run locally

plugin-webhooks 45/45 · objectql 3315/3315 · tsc --noEmit on both · eslint on every changed file · check:engine-double-contract · check:error-code-casing · check:empty-changeset · check:platform-checklist · check:i18n (bundles regenerated; the three non-English signing_secret strings are translated, not left as English source text).

Deliberately not done

  • No new error code registered. This change surfaces no new wire refusal — the fail-closed path is a boot-time log — so it reuses the standard-catalog INTERNAL_ERROR/500 pair rather than growing the ADR-0112 extension ledger.
  • check:i18n-coverage could not be measured locally: it fails to lint examples/app-showcase until the whole workspace is built (@objectstack/connector-mcp has no dist in this worktree), and it refuses to judge a partial round. The new keys are translated in all four locales, so it should not move.

🤖 Generated with Claude Code

https://claude.ai/code/session_01S1CYfQTgc1hRzTXnk8n3Q2


Generated by Claude Code

#7799)
`bootstrapDeclaredWebhooks` serialized the whole validated `Webhook` envelope —
`secret` included — into `sys_webhook.definition_json`, and `AutoEnqueuer.parseRow`
read `defn.secret` back out to sign deliveries. `definition_json` is an ordinary
textarea on an admin-authorable object with no restrictive `enable.apiMethods`, so
`GET /api/v1/data/sys_webhook` returned the receiver's only proof of origin to every
persona that can read the object. This is the half #7722 scoped out — and unlike the
delivery table, nothing ages it out.
The authored key now lands in a new `sys_webhook.signing_secret` column of type
`secret`: the engine encrypts it into `sys_secret`, keeps an opaque `secret:<id>`
ref on the row, and masks it on every read. `definition_json` carries the same
envelope minus `secret`. The enqueuer recovers the plaintext server-side on cache
refresh, so the delivered `X-Objectstack-Signature` is byte-identical and no
receiver changes. The authoring envelope (`webhook.zod.ts`) is untouched.
A boot sweep migrates rows that already hold cleartext, including the
`managed_by: 'admin'` and `customized: true` rows the seeder deliberately never
rewrites. It stores the encrypted copy in the same update that strips the blob, so
a failure cannot leave a webhook stripped and unsigned. With no CryptoProvider the
engine refuses the write rather than storing cleartext: the webhook is skipped and
the legacy row left intact, reported with an ADR-0112 code/status pair.
Adds `ObjectQL.resolveSecretField(object, recordId, field)` — the privileged
dereference of one row's `secret`-typed field. `resolveSecret()` was documented for
"privileged consumers … against the stored ref", but the read mask meant no
consumer could obtain that ref; this is what makes the encrypted channel reachable
by a server-side consumer at all. It refuses any field not declared `type: 'secret'`
so it cannot become a mask bypass over a `password` field (ADR-0100).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1CYfQTgc1hRzTXnk8n3Q2
…cret (#7799)
`os i18n extract` (merge mode) adds the new `sys_webhook.signing_secret` label and
help to the four locale bundles; the zh-CN / ja-JP / es-ES leaf strings are then
translated in place rather than left as the English source text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1CYfQTgc1hRzTXnk8n3Q2
…et column (#7799)
The `secret` prop is still LIVE, but its evidence pointed at the cleartext path
that #7799 removes (`definition_json` → `defn.secret`). Restate it against the
`signing_secret` column and `engine.resolveSecretField()`, and correct the two
`_note` sentences that described the whole envelope going into `definition_json`.
Evidence prose only — no schema, no status change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1CYfQTgc1hRzTXnk8n3Q2
@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 3:01am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

109 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 @objectstack/objectql, 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 packages/objectql, @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/migration-from-objectql.mdx(via @objectstack/objectql)
  • 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/deployment/vercel.mdx(via @objectstack/objectql)
  • 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/objectql, @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 packages/objectql, @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/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx(via @objectstack/objectql)
  • 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/objectql, 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/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/objectql, @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/objectql, @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 packages/objectql, @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/objectql, @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/objectql, @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.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling labels Aug 12, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 12, 2026 03:20
@huangyiirene
huangyiirene added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit e3a6f6eAug 12, 2026
28 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7799-webhook-subscriber-secret branch August 12, 2026 03:35
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] The webhook signing secret is stored in cleartext in sys_webhook.definition_json

2 participants

@huangyiirene@claude