You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[security] sys_http_delivery.headers_json still stores webhook credentials in cleartext — and every services-lane shape for fixing it is structurally wrong #8118
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:
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.
claim() would need a resolveSecretField (driver read + decrypt) per row, on the dispatcher tick.
2. Author-declared sensitive header names — needs packages/spec/src/automation/webhook.zod.tsand 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: true — omitInternalFields 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:
⛔ Do not weaken the fail-closed rule. A delivery that goes out missing a header is not self-announcing: against an endpoint that does not require it (a routing X-Tenant-Id, an X-Environment: staging) the delivery succeeds while silently deviating from the authored configuration, and nothing records that it went out incomplete.
The source: 'flow' half is the one every shape forgets. Any proposal that only covers WebhookSchema-authored headers has covered less than half the population.
Split out of #7986 (half ①-b) at ACCEPT of PR #8114 (half ①-a) by the
domain:servicesPM seat. ⛔ Not graded by me and deliberately carrying nodomain:*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
headersmap 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 toenqueue(), which writes it verbatim:{ "bearerReadableViaDataApi": true, "bearerAtRest": true, "headersJson": "{\"Authorization\":\"Bearer prod_tok_…\",\"X-Team\":\"crm\"}", "secretReadable": false, "sysSecretRows": 2 }secretReadable: falseis ①-a working.bearerReadableViaDataApi: trueis 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()onheaders_json— structurally wrong, three ways.sys_http_deliverygets one row per delivery. Asecret-typed write mints asys_secretrow every time, and the engine has no delete cascade and no retention onsys_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 a compare-before-write (secretPatch, PR fix(plugin-webhooks): move webhook custom headers onto the encrypted channel (#7986) #8114); one per delivery is orders of magnitude worse. Cf. sys_secret: rotations that happened BEFORE #8030's fix left decryptable orphans on deployed instances, and nothing reaps them #8103, where accumulated orphans are their own security card.encryptSecretFieldsthrows with no CryptoProvider, andAutoEnqueuerenqueues fire-and-forget (void … .catch(warn)) — so header-bearing webhooks would silently stop delivering inside Regression from #7799: for ~60s after every restart, a webhook holding an encrypted signing secret silently drops its subscription — no delivery, nosys_http_deliveryrow, while it still readsactive:true#8022's boot window, with no re-arm to rescue them.claim()would need aresolveSecretField(driver read + decrypt) per row, on the dispatcher tick.2. Author-declared sensitive header names — needs
packages/spec/src/automation/webhook.zod.tsand the flowHttpConfigSchema. ⇒domain:spec. Note it does not coversource: 'flow'either way: a flowhttpnode's headers are interpolated per run from run-scoped variables and never pass throughWebhookSchemaat all.3. Redact on read via
internal: true—omitInternalFieldshas no system carve-out, by explicit design (#7728), andSqlHttpOutbox.claim()reads headers throughengine.find. So this breaks its own consumer. #7728 itself names the remedy — "a purpose-built privileged accessor, the wayresolveSecretdoes" — which is apackages/objectqlchange ⇒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,refIdis 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:
X-Tenant-Id, anX-Environment: staging) the delivery succeeds while silently deviating from the authored configuration, and nothing records that it went out incomplete.sys_webhook.definition_json#7799 (signing_secretciphertext, signing byte-identical), Regression from #7799: for ~60s after every restart, a webhook holding an encrypted signing secret silently drops its subscription — no delivery, nosys_http_deliveryrow, while it still readsactive:true#8022 (re-arm before first cache build; say-once error ledger), or fix(plugin-webhooks): move webhook custom headers onto the encrypted channel (#7986) #8114 (headers_secret, theheadersPatchcompare-before-write).source: 'flow'half is the one every shape forgets. Any proposal that only coversWebhookSchema-authored headers has covered less than half the population.Siblings
headersare still cleartext in two JSON blobs — the sibling of #7799 that PR #7901 did not close #7986 — parent; stays open for this half and for ①-f. ①-a landed in PR fix(plugin-webhooks): move webhook custom headers onto the encrypted channel (#7986) #8114.sys_email.headers_json— same shape, examined and deferred ([security] webhook customheadersare still cleartext in two JSON blobs — the sibling of #7799 that PR #7901 did not close #7986 comment 5266514879); it should follow whatever this card decides, not be decided separately.redeliverbutton that sends UNSIGNED #8069 — the other Regression from #7799: for ~60s after every restart, a webhook holding an encrypted signing secret silently drops its subscription — no delivery, nosys_http_deliveryrow, while it still readsactive:true#8022 residual (a dropped subscription leaves no durable record).sys_secretrows; shares the "no retention onsys_secret" fact that makes shape 1 above unworkable.Provenance: #7986 dev report (PR #8114 body, "What this does NOT close"), and my ACCEPT receipt (#7986 comment 5269586560).