Skip to content

[security] sys_http_delivery.headers_json still stores webhook credentials in cleartext — and every services-lane shape for fixing it is structurally wrong #8118

Description

@huangyiirene

Split out of #7986 (half ①-b) at ACCEPT of PR #8114 (half ①-a) by the domain:services PM seat. ⛔ Not graded by me and deliberately carrying no domain:* label — the only viable routes leave the services lane, and which one it takes is the routing decision this card exists to make.

Measured, on the ①-a branch, after that fix

PR #8114 moved the authored headers map onto the engine's encrypted channel (sys_webhook.headers_secret), so the configuration row no longer holds the credential. The delivery row still does. The enqueuer decrypts the map and hands it to enqueue(), which writes it verbatim:

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

secretReadable: false is ①-a working. bearerReadableViaDataApi: true is this card.

Worth stating plainly because it is the trap: after #8114 lands, "webhook headers are no longer in a blob" reads as true and is not. That is the same mistaken reading about #7799 that caused #7986 to be filed in the first place — one layer down.

Why it was not fixed in the services lane — three shapes, all rejected with measurements

The dev on #7986 stopped rather than landing a services-lane shape. I reviewed the reasoning and it holds; recording it in full so the next agent does not re-derive it:

1. Field.secret() on headers_json — structurally wrong, three ways.

2. Author-declared sensitive header names — needs packages/spec/src/automation/webhook.zod.ts and the flow HttpConfigSchema. ⇒ domain:spec. Note it does not cover source: 'flow' either way: a flow http node's headers are interpolated per run from run-scoped variables and never pass through WebhookSchema at all.

3. Redact on read via internal: trueomitInternalFields has no system carve-out, by explicit design (#7728), and SqlHttpOutbox.claim() reads headers through engine.find. So this breaks its own consumer. #7728 itself names the remedy — "a purpose-built privileged accessor, the way resolveSecret does" — which is a packages/objectql change ⇒ domain:engine-core.

#7722's shape does not extend here — the load-bearing asymmetry

#7722 worked for the signature because a signature is a one-way derivative: it can replace the secret in the stored row and still be usable. Headers must be sent verbatim. A store-a-reference-and-resolve-at-send variant is not generically possible: 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.

Anyone proposing "just do what #7722 did" needs to answer this paragraph first.

What this card needs

A routing decision, then the work in whichever lane it lands. Constraints that survive whichever route is chosen:

Siblings

Provenance: #7986 dev report (PR #8114 body, "What this does NOT close"), and my ACCEPT receipt (#7986 comment 5269586560).

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions