Skip to content

[security] webhook custom headers are still cleartext in two JSON blobs — the sibling of #7799 that PR #7901 did not close #7986

Description

@huangyiirene

Filed by the domain:services PM seat from the #7902 credential-persistence survey (report: comment 5264546868 on #7902). Unassigned, no pm:queuefor triage to grade, per the single-producer rule.

The finding

#7799 moved the webhook signing secret out of sys_webhook.definition_json into an encrypted signing_secret column (PR #7901, merged). It did not move the custom headers, and headers is the ordinary place an Authorization: Bearer … goes.

Two blobs carry them in cleartext, both reachable through the ordinary data API:

#Object · columnEvidenceWho reads it back
①-asys_webhook.definition_jsonsys-webhook.object.ts:181-186 (column, documented as "headers/timeout config"); webhook.zod.ts:195headers: z.record(z.string(), z.string())auto-enqueuer.ts:376defn.headers
①-bsys_http_delivery.headers_jsonsql-http-outbox.ts:109JSON.stringify(input.headers), no redaction; http-delivery.object.ts:126sql-http-outbox.ts:276http-sender.ts

⚠️sys_webhook declares no enable block at all (sys-webhook.object.ts:194), so it keeps the full default data API — the same condition #7799 called out for the secret. sys_http_delivery declares apiMethods: ['get','list'].

Why this is worth its own card rather than a footnote on #7799

#7799 was framed as "the signing secret is in cleartext," and it was fixed exactly as framed. The survey's contribution is noticing that the column was the problem and the secret was only one of its passengers. A reviewer of PR #7901 — including this seat, which accepted it — had no reason to ask what else rode definition_json, because the card never said anything did.

That makes this a scope-of-the-original-fix finding, not a regression: nothing PR #7901 did made this worse, and the exposure predates it. But the "webhook credentials are no longer in a blob" conclusion a reader would reasonably draw from #7799 is not true today, which is the part worth correcting in writing.

Shape of a fix, if wanted (⛔ not decided here)

Not obviously the same shape as the secret's. A signing secret is one opaque value with one consumer; headers is an open-ended Record<string,string> where only some entries are credentials and the platform cannot tell which. Options a card would have to choose between:

  • move the whole map behind Field.secret() — simple, but encrypts non-sensitive headers too and makes them unreadable in the admin UI;
  • split declared-sensitive header names into a secret-backed side channel, leaving the rest in the blob — needs an authoring surface change (packages/spec/src/automation/webhook.zod.ts) and therefore the spec seat;
  • redact on read rather than at rest — cheaper, but leaves cleartext at rest, which is the property [security] The webhook signing secret is stored in cleartext in sys_webhook.definition_json #7799 was about.

The middle option touches the authoring envelope, so per the #7799 dispatch's own STOP fork that piece would transfer to domain:spec.

Explicitly NOT claimed

  • No regression from PR fix(webhooks): stop storing the subscriber signing secret in cleartext (#7799) #7901. The header exposure is unchanged by it, in either direction.
  • No live leak is demonstrated — this is a reachable-cleartext finding, not an incident report. Whether any deployment actually puts a bearer token in webhook headers is unmeasured.
  • The signing secret itself is fixed and stays fixed; sys_webhook.signing_secret is Field.secret()-backed and verified clean by the same survey.

Evidence

Measured 2026-08-12 by the dev on #7902 (read-only survey, pr: null), file:line evidence as cited above. Full verdict table for 26 surfaces in comment 5264546868 on #7902.

Source

Split out of #7902 (the survey), which was itself split out of #7799 / PR #7901.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions