Skip to content

fix(service-messaging): store the delivery signature, not the signing secret (#7722) - #7798

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-7722-webhook-secret-cleartext
Aug 11, 2026
Merged

fix(service-messaging): store the delivery signature, not the signing secret (#7722)#7798
huangyiirene merged 2 commits into
mainfrom
claude/issue-7722-webhook-secret-cleartext

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes#7722

The defect, confirmed against origin/main

packages/services/service-messaging/src/objects/http-delivery.object.ts declared
signing_secret: Field.text(...) and SqlHttpOutbox.enqueue wrote input.signingSecret
into it verbatim, once per attempt row. sys_http_delivery declares
enable.apiMethods: ['get','list'], so the column is readable over the ordinary data API.
Both producers fed it cleartext: plugin-webhooks/src/auto-enqueuer.ts (signingSecret: sub.secret,
:387 / :483) and the Flow http node (service-automation/src/builtin/http-nodes.ts:133).
Premise holds as filed.

The shape

The issue's suggested shape was a handle resolved at send time (sys_setting/sys_secret split).
This PR takes the stronger option the issue itself names second — keep it off the delivery record
entirely
— because the delivery row does not need the key at all:

A delivery's body is decided at enqueue and replayed byte-for-byte by every retry and by
redeliver(). So the HMAC has exactly one correct value for the row's whole life.

The outbox therefore computes the signature once, at enqueue, and stores only the result —
signature, sha256=<hex>, the same value the receiver is handed on the wire and one-way in
the key. The secret is consumed by enqueue() and dropped. Reading a delivery row now tells you
what was sent, not how to forge something else.

Why this over the handle shape, given the datasource path was the reference:
the datasource binder exists because a live connection pool must recover the cleartext at
runtime. A delivery does not — it needs one HMAC over one fixed body. A handle would still have
had to mint a sys_secret row per delivery (a per-attempt ciphertext row on a telemetry table
with 30-day retention, with no reaper) and re-resolve it on every retry, to end up in the same
place. Both producers keep their enqueueHttp({ …, signingSecret }) call sites unchanged
because the fix sits at the outbox, so the Flow http node stops writing cleartext too.

Changes

  • http-sender.ts — owns both halves of the signing contract now: deliveryBody() (the exact
    bytes signed AND sent) and signBody(). sendOnce sends delivery.signature instead of
    re-deriving an HMAC from a stored secret, so the signer and the transport cannot drift into
    signing one string and posting another.
  • sql-http-outbox.ts / memory-http-outbox.ts — sign at enqueue, persist signature, drop the
    secret. Both impls, so a test cannot pass on a row production wouldn't write.
  • http-delivery.object.tssigning_secretsignature (+ the four generated locale files).
  • http-outbox.tsHttpDelivery.signingSecret removed, signature added;
    EnqueueHttpInput.signingSecret stays and is documented as consumed-not-stored.
  • content/docs/automation/webhooks.mdx — §3.2 column table and §6 signing model.
  • Changeset: patch × service-messaging, plugin-webhooks.

Verification record

Both acceptance checks pass together, and neither is an "it didn't throw" assertion.

1. At-rest byte scan after a real deliveryservice-messaging/src/http-signature-at-rest.integration.test.ts.
Real ObjectQL + @objectstack/driver-sql on better-sqlite3 :memory:, real DDL via
syncSchemas(), production SqlHttpOutbox + HttpDispatcher. After a delivery it runs
SELECT * over the delivery table — every column, not a projection of declared fields, so a
stale physical column could not hide — and asserts the secret's bytes are absent. The scan is
itself guarded (it must see the URL and the signature), so an empty dump can never be what makes
it pass. Cases: webhook, flow, redelivery, unsigned.

2. HMAC recomputed over the raw body — same file, plus
plugin-webhooks/src/webhook-signing-secret.test.ts, which drives the full chain from a
data.record.created event through the real AutoEnqueuer. Each asserts
X-Objectstack-Signature === 'sha256=' + HMAC-SHA256(raw body received, subscriber secret),
and that the signed body is the real event rather than an empty one an HMAC would also match.
This doubles as the round-trip pin: the signature is computed before the row is written and
the body is rebuilt after a full stringify → text column → parse → stringify round-trip, so
a single changed byte breaks verification instead of shipping an unverifiable delivery.

Reverse-verification (the guard fails on the pre-fix behaviour): reinstating the cleartext
column and the signing_secret: input.signingSecret write fails 3 of the 4 at-rest cases, each
on the byte-scan assertion —
AssertionError: expected '…' not to contain 'whsec_7722_do_not_persist_me'.
The 4th (unsigned delivery) legitimately passes either way. Temporary edits reverted before commit.

Gates run locally

GateResult
service-messaging suite18 files / 206 tests passed
plugin-webhooks suite4 files / 35 tests passed
turbo typecheck --filter=...@objectstack/service-messaging (dependents)77 tasks passed
turbo build — dependents closure of both packages66 tasks passed
pnpm check:docs-audit-scopepassed

Upgrading

The signing_secret column is no longer declared, so an existing database keeps it as an
unmapped column still holding the old cleartext until it is dropped: os migrate plan reports
the drop_column op, classified destructive, so it is never applied unattended. Until then
those rows also age out on the table's existing 30-day telemetry retention. Rotate any signing
secret that was exposed.
Code reading HttpDelivery.signingSecret off a row should read
signature; the secret is not available there by design.

Out of scope, filed separately

The "does any other outbox/delivery table copy a credential the same way" sweep found none
no other outbox/delivery/job object declares a credential-shaped column. It did surface one
adjacent finding, filed unassigned rather than folded in here: the subscriber's own secret sits
in cleartext in sys_webhook.definition_json, which is not a delivery/outbox row and is outside
this issue's acceptance criteria.


Generated by Claude Code

… secret (#7722)
`sys_http_delivery` carried the caller's `signingSecret` verbatim on every
attempt row, in a table the ordinary data API reads. Reading deliveries
recovered the shared key that authenticates ObjectStack to the receiver — for
every subscriber at once — and that key is the receiver's only proof of origin,
so the blast radius reaches systems this deployment does not control.
A delivery's body is decided at enqueue and replayed byte-for-byte by every
retry and by `redeliver()`, so its HMAC has exactly one correct value for the
row's whole life. The outbox now computes it once at enqueue and stores only
the result (`signature`, `sha256=<hex>`) — the same value handed to the
receiver on the wire, one-way in the key — and drops the secret. Nothing has to
resolve a credential at send time, and no secret store gains a row per attempt.
The fix sits at the outbox, so both producers (webhook fan-out and the Flow
`http` node) stop writing cleartext without changing their enqueue calls.
`http-sender.ts` now owns both halves of the signing contract (`deliveryBody`
and `signBody`) so the enqueue-time signer and the send-time transport cannot
drift into signing one string and posting another.
Evidence, both required to pass together:
- `http-signature-at-rest.integration.test.ts` — real ObjectQL + driver-sql
(better-sqlite3 `:memory:`), real `SqlHttpOutbox` and `HttpDispatcher`: after
a real delivery it byte-scans `SELECT *` over every column of the delivery
table for the secret, and verifies `X-Objectstack-Signature` by recomputing
HMAC-SHA256 over the raw body that arrived. Covers webhook, flow, redelivery
and the unsigned case.
- `plugin-webhooks/src/webhook-signing-secret.test.ts` — the same two checks
driven end-to-end from a `data.record.created` event through the real
`AutoEnqueuer`.
Reverse-verified: reinstating the cleartext column fails three of the four
at-rest cases on the byte-scan assertion.
Upgrading: the `signing_secret` column is no longer declared, so an existing
database keeps it as an unmapped column holding the old cleartext until
`os migrate plan`'s `drop_column` op is applied (destructive, never unattended);
those rows also age out on the table's 30-day telemetry retention. Rotate any
exposed signing secret.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XuxBHt3YbtJ34CSk6kuNWg
@vercel

vercelBot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 11, 2026 7:40pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-messaging.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx(via @objectstack/service-messaging)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/service-messaging)
  • content/docs/plugins/packages.mdx(via @objectstack/service-messaging)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/service-messaging)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling labels Aug 11, 2026
…verb dispatch
`check:engine-double-contract` was red on the new test's fake engine: its
`delete()` and `update()` accepted every call shape, including the ones
`ObjectQL` refuses. That is the #4434 failure mode — a double looser than the
implementation it replaces keeps a suite green over a path that is dead in
production — so both verbs now open with the producer's own predicates,
`assertEngineDeleteDispatch(options)` and `assertEngineUpdateDispatch(data, options)`
from `@objectstack/metadata-core` (added as a devDependency; metadata-core
rather than objectql, which would invert a dependency edge).
Gate rerun: OK — 152 pinned, 133 in the DEBT ledger, 2 exempt (was: 2 problems,
both this file). plugin-webhooks suite 4 files / 35 tests pass; eslint clean on
the changed file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XuxBHt3YbtJ34CSk6kuNWg
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] Webhook HMAC signing secrets are persisted in cleartext on every sys_http_delivery row

2 participants

@huangyiirene@claude