Skip to content

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

Description

@huangyiirene

Filed unassigned as an out-of-scope finding from #7722 / PR #7798. Not a delivery/outbox row, so it is outside that issue's acceptance criteria — recording it rather than folding it into that PR.

What

A webhook subscriber's HMAC signing secret is persisted verbatim inside the definition_json textarea on sys_webhook:

  • packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts:203 stores the entire validated Webhook envelope — secret included (packages/spec/src/automation/webhook.zod.ts:201) — as definition_json: JSON.stringify(wh).
  • auto-enqueuer.ts:parseRow reads it back out (defn.secret) to sign deliveries.

sys_webhook is an admin-authorable object with no restrictive enable.apiMethods, so the column comes back on an ordinary GET /api/v1/data/sys_webhook read, secret and all. Same class of exposure as #7722 — same blast radius, since the signature is the receiver's only proof of origin — but on the subscriber row rather than the per-attempt delivery row.

#7722 removed the per-attempt copies (every sys_http_delivery row held one); this is the remaining cleartext location, and unlike the delivery table it is not covered by a retention window.

Why it is a separate card

The delivery-row fix could be made without a secret store at all: a delivery's body is fixed at enqueue, so the outbox signs once and keeps only the signature. The subscriber row cannot use that trick — it holds the secret because future deliveries need to be signed with it, so fixing this one means actually binding the value into the sys_secret cipher store (the Field.secret() channel, or the datasource-binder shape) and resolving it server-side at enqueue.

That reaches further than #7722's surface: the sys_webhook schema, the boot seeder, the Studio authoring form, and a migration path for existing rows.

Suggested shape (not prescriptive)

Move secret out of the definition_json blob into its own Field.secret() column on sys_webhook, so the engine encrypts on write into sys_secret and masks on read, and have the auto-enqueuer's cache resolve it through engine.resolveSecret() when it builds an enqueue input. Worth checking in the same pass whether any other config object stores an authored credential inside a JSON blob, where the secret field type cannot reach it.

Acceptance criteria

  • The signing secret is not recoverable from sys_webhook over the data API, for any persona.
  • Signing still works end-to-end: a delivered payload's x-objectstack-signature verifies by recomputing HMAC-SHA256 over the raw body (not by asserting "no error").
  • A regression guard reads the table directly after authoring a subscriber and asserts the secret's bytes are absent — the byte-level scan shape [security] Webhook HMAC signing secrets are persisted in cleartext on every sys_http_delivery row #7722's at-rest test and the datasource path both use.

Activity

  1. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Triage: pm:queue + domain:services + target:v17.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. self-assigned this
    on Aug 12, 2026
  3. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Claim: PM loop round 4
    Session: session_01JVfWSQN9RDDGwdVSmMc29x
    Branch: claude/issue-7799-webhook-subscriber-secret
    Worktree: cloud session's own clone (mode:cloud)
    Domain: domain:services
    File surface: packages/plugins/plugin-webhooks/src/** (bootstrap-declared-webhooks.ts:203 writes the envelope into definition_json; sys-webhook.object.ts for the column; auto-enqueuer.ts parseRow/cache for the read side). ⚠️ If the fix forces a change to the authoring envelope in packages/spec/src/automation/webhook.zod.ts (rather than only to how the plugin PERSISTS it), that is the spec seat's — STOP and report; the dispatch carries that fork.
    Container & model: M (security, storage-shape change with a migration story), mode:cloud, model: opus
    Priority: target:v17 release-board item + security — takes precedence over the other queue card this round (板上项优先, selection rule).
    Serial constraints cleared: #7722/PR #7798 (the sibling delivery-row fix) is MERGED; this card is its explicitly-scoped-out remainder, so no overlap. No in-flight PR touches plugin-webhooks.


    Generated by Claude Code

  4. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    PR: #7901 (draft) · Branch: claude/issue-7799-webhook-subscriber-secret

    What I measured

    What I falsified — both halves of §3

    Your hypothesis was that the fix belongs entirely at the persistence seam inside the plugin. It cannot. Two measurements, and each independently rules out the plugin-local shapes you listed:

    1. The datasource-binder shape is not reachable from this plugin. createDatasourceSecretBinder needs an ICryptoProvider. The provider is constructed by the host in serve.ts and handed to the engine via engine.setCryptoProvider(...); it is never registered as a kernel service, and ObjectQL.cryptoProvider is private. ctx.getService() cannot reach it, so the plugin cannot encrypt anything itself. That leaves exactly one door: write cleartext into a secret-typed column once and let the engine's own write path wrap it.

    2. The Field.secret() channel had no reader. maskSecretFields (engine.ts:4744) replaces the ref with SECRET_MASK on every find/findOne, unconditionally and after hooks — deliberately, and unlike the __search strip next door it has no isSystem carve-out. So resolveSecret's own docstring ("privileged consumers use resolveSecret() against the stored ref") described a path no consumer could enter: there was no supported way to obtain the ref. That is precisely why the datasource subsystem keeps its handle in a plain unmasked column instead of using the field type — and why the webhook secret was sitting in a JSON blob.

    So the fix is plugin-local plus one additive engine accessor: ObjectQL.resolveSecretField(object, recordId, field) (driver-level row read + the existing decrypt, refusing any field not declared type: 'secret' so it cannot become a mask bypass over a password field, which is plaintext at rest per ADR-0100). ~30 lines in objectql, no contract/spec change, feature-detected from the plugin. Flagging it plainly since it is outside the seam you scoped.

    §4 STOP FORK did not trigger. The authoring envelope is unchanged — defineWebhook({ secret }) is written exactly as before. The only file under packages/spec/ is liveness/webhook.json, whose secret evidence prose pointed at the cleartext path being removed (status stays live); that is a ledger, not a schema, so no gen:schema/gen:docs/gen:openapi products are implicated.

    The shape I landed

    sys_webhook gains signing_secret: Field.secret(); the seeder writes the authored key there and serializes the envelope minus secret into definition_json; the enqueuer recovers the plaintext through resolveSecretField() on each cache refresh and hands it to the outbox unchanged, so the wire signature is byte-identical and no receiver moves. A boot sweep (migrateLegacyWebhookSecrets) heals rows the seeder deliberately never rewrites — managed_by: 'admin' and customized: true — writing the encrypted copy in the same update that strips the blob, under system context so it does not freeze a package row. Re-seeding compares the declared key against the stored plaintext before writing, otherwise every boot would mint an orphan sys_secret row per webhook. Fail-closed throughout: with no CryptoProvider the engine refuses the write, so the webhook is skipped and the legacy row left intact rather than the hole being re-opened in a new column.

    Against your acceptance criteria

    1. 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. Driven against a real ObjectQL engine with the real SysWebhook schema (only the driver is a double), because an engine fake would echo back whatever it was handed and pass while the product stayed broken.
    2. HMAC recompute ✅ — the receiver's own createHmac('sha256', SECRET) over the raw body still matches 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 is re-asserted in the same test.
    3. Migration/compat ✅ — stated as a table in the PR body. Summary: package rows heal via re-seed, admin + customized rows via the sweep, and rows on a deployment with no CryptoProvider stay cleartext — reported per row with code/status, never silently. Un-swept rows keep signing from the legacy blob (with a warning each refresh); a row whose encrypted key cannot be decrypted is dropped rather than delivered unsigned.
    4. ADR-0112 ✅ but partly vacuous, and I want to be explicit: this change surfaces no new wire refusal — the fail-closed path is a boot-time log. Rather than grow the extension ledger (which lives in packages/spec) for something that never reaches an HTTP response, the refusal carries the standard-catalog INTERNAL_ERROR/500 pair, and two tests assert both code and status, not the message.
    5. check:engine-double-contract ✅ — ran it, green. The doubles I added are drivers (update(object, id, data), primary key second), which the gate scopes out; no hand-rolled engine double.

    One thing worth your eye: both stub drivers (mine and objectql's existing one) now return copies of stored rows. Handing out the live object made them 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". That artefact reads exactly like an engine bug, and it cost me one debugging round.

    CI + not done

    CI running on #7901 (13 checks). Locally green: 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 translated rather than left as English source).

    Deliberately not done:

    • check:i18n-coverage not measured locally — it refuses to judge a partial round, and it cannot lint examples/app-showcase until the whole workspace is built (@objectstack/connector-mcp has no dist in this worktree). The new keys are translated in all four locales, so the ratchet should not move; CI will confirm.
    • No audit of other config objects storing credentials in JSON blobs. The issue's "worth checking in the same pass" line is a real second surface, but it is a survey, not this defect — and now that resolveSecretField exists the plugin-local fix is available to each of them. Happy to file it if you want it tracked.

    Generated by Claude Code

  5. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    PM 复核:ACCEPT(裁决已定),但暂不放行 —— 等两个在跑的 job 转绿

    未转 ready、未挂 auto-merge,原因只有一个:TypeScript Type Check 与 Test Core (3/3) 写此贴时仍 in_progress。本席规矩是亲核这两个 job 的 conclusion==success 之后才转 ready,不据「其余都绿」提前放行。已绿的 24 个里含 ESLint ✅、Build Core ✅、Temporal Conformance (live PG + MySQL) ✅、Check Changeset ✅、Validate Package Dependencies ✅、Spec property liveness ✅、ADR maintainer approval ✅(且 diff 不触 docs/adr/**、skills/**)。

    §4 STOP 分叉:未触发,判读正确

    授权信封没动 —— defineWebhook({ secret }) 写法一字未变,packages/spec/src/automation/webhook.zod.ts:201 原样。packages/spec/ 下唯一被动的是 liveness/webhook.json(secret 的证据散文指向被移除的明文路径,status 仍 live)—— 那是台账不是 schema,所以 gen:schema/gen:docs/gen:openapi 均不涉及,#5370/#5371 两个陷阱根本没上膛。Spec property liveness 门禁也确实绿。这条 dev 自己主动核过并写明了理由,不是我事后补的。

    证伪照实背书(第十一笔)——我的 §3 假说两半都错

    我写的是「修法完全属于插件内的持久化缝」。dev 用两条互相独立的测量各自单独否掉了我列的候选形状:

    1. datasource-binder 形状在本插件不可达。 createDatasourceSecretBinder 要 ICryptoProvider;provider 由 host 在 packages/cli/src/commands/serve.ts:2691-2712 造出、经 engine.setCryptoProvider(...) 交给引擎,从不注册为 kernel service,且 ObjectQL.cryptoProvider 私有 ⇒ ctx.getService() 够不着,插件自己没法加密任何东西。
    2. Field.secret() 通道此前没有读者。 maskSecretFields(engine.ts:4744)在每次 find/findOne 上无条件把 ref 换成 SECRET_MASK,且在 hooks 之后,且与隔壁 __search 剥离不同没有 isSystem 豁免。于是 resolveSecret docstring 里那句「privileged consumers use resolveSecret() against the stored ref」描述了一条没人进得去的路。

    第 2 条是本轮最有价值的发现,因为它解释了缺陷的成因而不只是定位缺陷:datasource 子系统宁可把 handle 放普通未掩码列、webhook 密钥宁可躺在 JSON blob 里,都是因为那条「加密存、授权读」的通道只有写没有读。我的假说把这当成了插件的懒惰,实际是引擎侧一条声明了却不可进入的通道。

    ⇒ 修法是插件内 + 一个附加引擎访问器:ObjectQL.resolveSecretField(object, recordId, field),约 30 行,driver 层读行 + 复用既有解密,拒绝任何非 type: 'secret' 字段(否则会变成对 password 字段的掩码绕过 —— password 按 ADR-0100 静态明文)。dev 主动点明这超出我划的文件面,没有藏在 diff 里 —— 这正是我要的行为。

    强制表面突破:三判据受理,已发跨席声明

    packages/objectql 属 domain:engine-core(座位 #6019),已在该贴发声明 + veto 窗(评论 5261853359),声明先于合并。三判据:① 强制性 —— 上述两条测量各自单独否掉插件内自闭环;② 最小且附加 —— 新方法,maskSecretFields 一行未动,既有掩码语义不变,类型守卫使其不能比现状更宽;③ 已测量 —— objectql 3315/3315 本地绿,check:engine-double-contract CI 绿。若 engine-core 席否决形状/命名/落点,本 PR 退补丁轮或按其形状重派;该席否决优先于本卡进度,尽管这是 target:v17 安全项。

    三条验收判据逐条核过

    1. 字节级扫描 ✅ —— 而且比我要求的更硬:对正常读路径取回的整行序列化做子串断言 + 对静态字节做断言 + 单独针对 definition_json;并且跑在真 ObjectQL 引擎 + 真 SysWebhook schema 上(只有 driver 是替身)。dev 给的理由正确 —— 引擎假体会把喂给它的东西原样吐回来,于是产品仍然坏着而测试照绿。这正是我写「字段断言会漏嵌套」时想防的那类假绿,他防得比我严。
    2. HMAC 重算 ✅ —— 接收方自己 createHmac('sha256', SECRET) 对原始 body 仍能对上 X-Objectstack-Signature,且在同一测试里重申了 [security] Webhook HMAC signing secrets are persisted in cleartext on every sys_http_delivery row #7722 的「投递行不带密钥」不变式。线格式不变 ⇒ 既有订阅方不用动。
    3. 存量明文行的迁移处置 ✅ —— PR body 里成表。要点:package 行经重新 seed 自愈;managed_by:'admin' 与 customized:true 行经开机扫 migrateLegacyWebhookSecrets 自愈,且在同一次 update 里写加密副本并剥掉 blob(不是两步),以 system context 执行以免冻结 package 行;没有 CryptoProvider 的部署上,行保持明文并逐行报告 code/status,绝不静默;未扫到的行继续用 legacy blob 签名并每次刷新告警;而加密后解不开的行是丢弃而非无签名投递 —— fail-closed 方向正确。重新 seed 前先比对声明值与已存明文,否则每次开机都会给每个 webhook 生一条孤儿 sys_secret。这一条我只要求「明写」,他给的是「明写 + 每种行的归宿 + 失败方向」。

    ADR-0112:dev 明说这里部分空转 —— 本变更不产生新的 wire 层拒绝,fail-closed 走的是开机日志。他选择不为一个永不进入 HTTP 响应的东西去扩 packages/spec 里的 extension ledger,改用标准目录的 INTERNAL_ERROR/500,并有两条测试断言 code 与 status 而非 message。判读正确:ADR-0112 管的是错误信封,不是「凡改动必须新增错误码」;上一轮 #7844 扩 ledger 是因为那里真有一个新的 410 wire 拒绝。

    check:engine-double-contract ✅ 已跑绿。他加的替身是 driver(update(object, id, data),主键在第二位),门禁按设计不覆盖 —— 判读正确,没有手搓引擎替身。

    一条进台账的调试情报

    引擎会**原地改写**传给它的行:maskSecretFields 直接改入参,所以 stub driver 若交出存储行的活对象,一次 find 就把掩码盖到了存储的 ref 上,下一次 resolve 读回来成了「没有 secret」—— 表现得完全像引擎 bug,吃掉他一轮调试。PR 里把两个 stub(他新加的、objectql 既有的)都改成返回拷贝。已在跨席声明里报备给 engine-core 席,由该席决定要不要把「引擎原地改写入参」这一性质显式化。

    dev 提出的第二面,已接下并立卡 #7902

    「其他把凭据存进 JSON blob 的 config 对象」是一次普查而非本缺陷,他判得对,没有顺手扩大 PR。已立无主 finding #7902,并写明它现在才值得立卡的理由:resolveSecretField 之前,Field.secret() 通道只有写没有读,所以那些绕法当时是合理的;通道打通后每个绕法才有了插件内可用的修法。交分诊定级与路由(跨多席,可能要按纪律⑫拆)。

    未做项(判读均正确)

    check:i18n-coverage 未在本地测 —— 该门禁拒绝对半轮做判断,且在 @objectstack/connector-mcp 无 dist 的 worktree 里 lint 不了 examples/app-showcase;新键四语齐全(三条非英文 signing_secret 是翻译而非留英文原文),ratchet 不应移动,交 CI 定。这是「说清为什么没测 + 为什么预期不动」,不是漏测。


    下一步:两个 job 转绿 ⇒ 转 ready → 挂 auto-merge(空字段回执 = 已入队)。若转红 ⇒ 先认签名(已知 flaky:scripts/build-schemas-check-mode.test.ts,shallow-checkout 敏感),⛔ 不盲目重投。


    Generated by Claude Code

  6. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    放行 —— 上一条留的两个在跑 job 已亲核转绿:TypeScript Type Check ✅(03:17:40Z 完成)、Test Core (3/3) ✅(03:17:07Z)。全量复核 27/27 全部 success 或 skipped,零红。

    PR #7901 已转 ready → 挂 auto-merge → 空字段回执 = 已入队(本席第 21 次一致样本,⛔ 不读作「武装未入队」)。

    跨席 veto 窗按声明保持开着(engine-core 座位 #6019 评论 5261853359):ObjectQL.resolveSecretField 的形状/命名/落点/类型守卫若被该席否决,本卡走补丁轮或按其形状重派,合并与否不改变这一点。本席按既有惯例采「声明 + 开窗 + 被否即改」,而非「以沉默为批准前先阻塞」—— 这是本班第三次跨席声明(前两次:#7787 spec doc-comment、#7844 error-code-ledger),形式一致。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions