Uh oh!
There was an error while loading. Please reload this page.
fix(service-messaging): store the delivery signature, not the signing secret (#7722) - #7798
Conversation
… 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
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…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
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7722
The defect, confirmed against
origin/mainpackages/services/service-messaging/src/objects/http-delivery.object.tsdeclaredsigning_secret: Field.text(...)andSqlHttpOutbox.enqueuewroteinput.signingSecretinto it verbatim, once per attempt row.
sys_http_deliverydeclaresenable.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
httpnode (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_secretsplit).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:
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 inthe key. The secret is consumed by
enqueue()and dropped. Reading a delivery row now tells youwhat 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_secretrow per delivery (a per-attempt ciphertext row on a telemetry tablewith 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 unchangedbecause the fix sits at the outbox, so the Flow
httpnode stops writing cleartext too.Changes
http-sender.ts— owns both halves of the signing contract now:deliveryBody()(the exactbytes signed AND sent) and
signBody().sendOncesendsdelivery.signatureinstead ofre-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, persistsignature, drop thesecret. Both impls, so a test cannot pass on a row production wouldn't write.
http-delivery.object.ts—signing_secret→signature(+ the four generated locale files).http-outbox.ts—HttpDelivery.signingSecretremoved,signatureadded;EnqueueHttpInput.signingSecretstays and is documented as consumed-not-stored.content/docs/automation/webhooks.mdx— §3.2 column table and §6 signing model.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 delivery —
service-messaging/src/http-signature-at-rest.integration.test.ts.Real
ObjectQL+@objectstack/driver-sqlon better-sqlite3:memory:, real DDL viasyncSchemas(), productionSqlHttpOutbox+HttpDispatcher. After a delivery it runsSELECT *over the delivery table — every column, not a projection of declared fields, so astale 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 adata.record.createdevent through the realAutoEnqueuer. Each assertsX-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 → stringifyround-trip, soa 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.signingSecretwrite fails 3 of the 4 at-rest cases, eachon 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
service-messagingsuiteplugin-webhookssuiteturbo typecheck --filter=...@objectstack/service-messaging(dependents)turbo build— dependents closure of both packagespnpm check:docs-audit-scopeUpgrading
The
signing_secretcolumn is no longer declared, so an existing database keeps it as anunmapped column still holding the old cleartext until it is dropped:
os migrate planreportsthe
drop_columnop, classified destructive, so it is never applied unattended. Until thenthose rows also age out on the table's existing 30-day telemetry retention. Rotate any signing
secret that was exposed. Code reading
HttpDelivery.signingSecretoff a row should readsignature; 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 outsidethis issue's acceptance criteria.
Generated by Claude Code