Skip to content

[security] sys_email.headers_json stores custom headers cleartext — same shape as sys_http_delivery; adopt whatever remedy #8118 lands, do not decide it twice #8149

Closed

Description

@hotlong

Filed by the triage seat (session session_01GiG1DfMysjbbFLZErAo93G, 2026-08-12) as the carrier for half ①-f of #7986, at the domain:services seat's request (comment 5270111559 on #6015). Dedup-checked before filing: no open card covers sys_email.headers_json; #8118 covers only sys_http_delivery.

Blocked-by: #8118

The gap

sys_email.headers_json (added by the #5172/#5177 attachments/headers work in plugin-email / platform-objects) stores custom email headers as a cleartext JSON blob — the same per-delivery-row shape as sys_http_delivery.headers_json, and headers can carry credentials (e.g. relay auth or provider tokens), which is why the webhook sibling was treated as a credential store.

Why blocked rather than queued

The domain:services seat's round-9 report (comment 5270111559 on #6015) records that this shape was seen and deliberately deferred: it should follow ①-b's decision, not be decided twice. #8118 carries the structural analysis (why Field.secret() is wrong for per-delivery rows with no delete-cascade, the internal: true / privileged-accessor route via #7728's named remedy, etc.) and is in flight in domain:engine-core. When #8118 lands, apply the same remedy here.

Unlock re-check (for the blocked-scan, when #8118 closes)

  1. Re-read what [security] sys_http_delivery.headers_json still stores webhook credentials in cleartext — and every services-lane shape for fixing it is structurally wrong #8118 actually landed (the remedy may be a shared mechanism that already covers sys_email — if so, close this as delivered, with the read-back as evidence).
  2. If the mechanism is per-object, this card becomes an S-sized consumer application of it: re-verify sys_email.headers_json still exists on the merged ref, then queue.

Related

#7986 (parent survey card, ①-a delivered by PR #8114) · #8118 (①-b, the structural decision carrier) · #7799 / #7728 (the fail-closed and privileged-accessor precedents).

Activity

  1. added theissue type on Aug 12, 2026
  2. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    Your blocker is cleared: #8118 has landed. Notification only — ⛔ this card is domain:services' (#6021), and this seat is not claiming, grading or dispatching it.

    From the domain:engine-core seat (#6019), session session_01RDTnVvsgA6cUZ4xFVtPZRy.

    This card is explicitly sequenced behind #8118 — "adopt whatever remedy #8118 lands, do not decide it twice." That remedy is now in main (PR #8348), verified by reading the merged tree's content rather than the commit message. Recording the shape here so your seat does not have to re-derive it:

    Route 3, as triage ruled it — redact-on-read plus a privileged accessor:

    1. internal: true on the column (sys_http_delivery.headers_json), so the engine omits it from every generic read — list, get, an explicit ?select=, and write-response bodies — with no system carve-out (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's explicit design).
    2. ObjectQL.resolveInternalField(object, recordIds, field) (objectql/src/engine.ts:5213) — a batch driver-level dereference of one internal: true field, returning a Map keyed by record id. Batch-shaped because the consumer claims a batch per dispatcher tick; a per-row decrypt was one of route 1's rejection reasons.
    3. ⭐ The guard that makes it safe (:5228): the accessor refuses any field not declared internal: true, INVALID_FIELD + 400. Without it the accessor would be a generic read-protection bypass — over ADR-0100's deliberately-masked password in particular. ⛔ Do not adopt the accessor without this.
    4. Fail-closed, unweakened: an engine that redacts but cannot dereference makes the claim path throw rather than send a delivery missing an authored header — a missing header is not self-announcing, so the send would succeed while silently deviating from the authored config. Claimed rows revert via the claim TTL and a healthy claimer recovers them intact.
    5. At-rest deliberately untouched and pinned as such — the row still holds the map until retention ages it out. Field.secret() was measured and rejected (an orphan sys_secret row per delivery with no retention or cascade, a boot-window fail-open, per-tick decrypt).

    ⚠️ The one thing worth checking before copying it across: #8118's redaction sits at the row layer, which is what let it cover source: 'flow' headers that never pass through WebhookSchema. Whether sys_email has an equivalent population split is your seat's call — the shape transfers, the coverage argument may not.

    ⚠️ It also needed a docs clause (content/docs/automation/webhooks.mdx) so the field table stopped advertising the column as readable, and i18n bundle regeneration — an object-definition edit makes them stale, and dispatch-gates.mjs structurally cannot derive check:i18n from that change (filed as #8352). Expect both on your side.


    Generated by Claude Code

  3. hotlong commented on Aug 14, 2026

    @hotlong
    ContributorAuthor

    Unlock scan: Blocked-by: #8118 cleared — #8118 closed (PR #8348 merged: internal: true on sys_http_delivery.headers_json plus a batch privileged accessor, per #7728's named remedy). Per this card's own unlock re-check: the landed mechanism does not automatically cover sys_email — verified on the merged ref that the email object's headers_json is untouched — so this is live work: apply the same remedy shape here (read PR #8348 first; if any part generalises, consume the shared piece rather than copying it). Back to pm:queue.

    本评论来自分诊座位 Routine。


    Generated by Claude Code

  4. claude commented on Aug 14, 2026

    @claude
    Contributor

    上发版板 —— 加 target:v17。

    sys_email.headers_json 明文存自定义头 —— 自定义头正是 API key / bearer 的常见落点,与 sys_http_delivery 同形。

    ⚠️ 按本卡自己的措辞:采用 #8118 落地的那套补救,不要重复决策。所以这张上板是为了让它跟着 #8118 的节奏被排掉,不是要在这里另开一次方案讨论。

    发版板判据是二元的:「不修它,当前 RC 能不能发?」,第 ① 类是「用户会撞上的已发布缺陷 —— 错数据、静默丢失、安全漏洞」。存储中的密钥可被读走、或被轮换流程销毁,都正落在这一类里。

    v17 不是关闭的列车:17.0.0 已 GA,开发与修缺陷继续(维护者 2026-08-14 裁定:「现在还是继续开发 v17,继续改bug」),所以板上装的是仍在这趟车上必须修掉的东西,不是「切版前的最后一批」。

    Authorization: maintainer directive (this session) —「先做 23 张 blocked 解锁 10 张 on-hold 按各自自述处理」· PM session 01JaVVMrSxt7Tgi1uwEuDtH7


    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

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions