Skip to content

feat: close the remaining audit gaps — escrow arbitration, durable webhooks, installer pinning - #282

Merged
ralyodio merged 1 commit into
masterfrom
fix/audit-remaining-gaps
Aug 19, 2026
Merged

ralyodio merged 1 commit into
masterfrom
fix/audit-remaining-gaps

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

The three product gaps left open after #279, plus a production issuer cleanup.

ESC-NEW-01 — disputed escrows had no exit

dispute_resolution and dispute_status existed in the production schema with no writer anywhere. disputeEscrow set status='disputed' and a reason, and stopped.

The sharper half was the exits. A disputed escrow could only be released, and only by the depositor — the party who had just been disputed against, or who had just disputed. Refund required funded, so raising a dispute removed the refund path. Whoever raised one made their own position strictly worse, the beneficiary had no path at all, and the escrow sat until somebody gave up.

  • disputed is now refundable, so a beneficiary can concede
  • resolveDispute() + admin-gated POST /api/admin/escrows/[id]/resolve is the arbiter exit

Admin-gated deliberately: the two parties disagree by definition, so neither can be the one who decides. A resolution note of ≥10 chars is required — it is the record of why someone else's money moved.

REC-D-07 — three seconds of retry, then the event is gone

deliverWebhook retried 3× in-process with backoff, spending the entire budget inside one request over ~3 seconds. An endpoint down for four — a deploy, a restart, a brief network fault — lost the event permanently, as did anything in flight when our own process was recycled. Merchants reconcile against these, so a lost payment.confirmed is a payment the merchant never hears about.

New webhook_deliveries queue (migration 20260819210000, applied and verified):

  • 1m → 12h backoff, ~a day of grace
  • dead-letter after 8 attempts, so an abandoned event is a row an operator can find rather than something that evaporated
  • rows are claimed before delivery, conditioned on the attempt count read — two overlapping cron runs cannot both send the same webhook, and a duplicate payment.confirmed is a real problem for a merchant

Payloads are re-signed per attempt rather than replaying a stored signature: a signature is bound to its timestamp, so a stored one is either rejected by a merchant enforcing freshness or — worse, if they are not — accepted indefinitely. The secret is re-read too, so a rotation mid-queue is honoured and a removed webhook stops being retried.

W-01 — a merge reached every host in five minutes

Unpinned, the installer follows master and the timer polls every 300s, so anything merged executed on every operator host within five minutes with no human between the merge and the execution.

Unattended auto-upgrade from a mutable ref is now opt-in (COINPAY_AUTO_UPGRADE_UNPINNED=1). Pinned installs keep it, since a tag can only ever re-install the same code it already has. Verified the gate's truth table across all five pinned/opt-in/disabled combinations.

Production issuer cleanup (done, reversible)

Deactivated 5 of 18 reputation issuers. An issuer key provisions merchant accounts and issues invoices on other people's behalf:

  • evilpoc, poc2, OutHunt — all on .example, which is RFC 2606 reserved and cannot resolve
  • X / domain X — not a domain
  • Tounes / Coinpayportal.com — a third party registered an issuer claiming the platform's own domain, verified by nothing

Checked first that only ugig.net (14,333 receipts) and d0rz.com (5) have ever produced anything, so none of the deactivated rows could break live traffic. active = true restores any of them.

Still open and deliberately untouched: 6 *.trycloudflare.com issuers (ephemeral tunnel hostnames are a weak identity by construction) and the cleartext api_key column on 17 rows — rotating those needs the integrators told.

Tests

334 files / 4690 tests, up from 327/4600. tsc --noEmit clean.

🤖 Generated with Claude Code

ESC-NEW-01: dispute_resolution and dispute_status existed in the production
schema with no writer anywhere. disputeEscrow set status='disputed' and a
reason, and stopped — so a dispute recorded a grievance and changed nothing.

The sharper half was the exits. A disputed escrow could only be *released*, and
only by the depositor; refund required 'funded', so raising a dispute REMOVED
the refund path. Whoever raised one made their own position strictly worse and
the beneficiary had no path at all, so a disputed escrow sat until someone gave
up. 'disputed' is now refundable — a beneficiary can concede — and
resolveDispute() adds the arbiter exit, admin-gated because the two parties
disagree by definition and neither can be the one who decides.

REC-D-07: deliverWebhook retried three times in-process, spending its whole
budget inside one request over roughly three seconds. An endpoint down for four
— a deploy, a restart, a brief network fault — lost the event permanently, as
did anything in flight when our own process was recycled. Merchants reconcile
against these, so a lost payment.confirmed is a payment the merchant never
hears about.

Adds webhook_deliveries (migration 20260819210000, applied and verified): a
durable queue with 1m→12h backoff and a dead-letter state after 8 attempts, so
an abandoned event becomes a row an operator can find rather than something
that evaporated. Rows are claimed before delivery, conditioned on the attempt
count read, so two overlapping cron runs cannot both send the same webhook.

Payloads are re-signed on every attempt rather than replaying a stored
signature: a signature is bound to its timestamp, so a stored one is either
rejected by a merchant enforcing freshness or — worse, if they are not —
accepted indefinitely. The secret is re-read too, so a rotation mid-queue is
honoured and a removed webhook stops being retried.

W-01: unattended auto-upgrade from a mutable ref is now opt-in. Unpinned, the
installer follows master and polls every five minutes, so any merge executed on
every operator host within five minutes with no human in between. Pinned
installs keep auto-upgrade, since a tag can only re-install the same code.
Verified the gate's truth table across all five pinned/opt-in/disabled
combinations.

Suite green at 334 files / 4690 tests (up from 327/4600), tsc --noEmit clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

319 finding(s)

HIGH/CRITICAL: 32 | MEDIUM: 37 | LOW: 250

Severity Rule Location
HIGH secret-private-key .env.example:236
HIGH secret-generic-api-key docs/API.md:430
HIGH secret-generic-api-key docs/API.md:585
HIGH secret-generic-credential docs/FIX_VERIFY_SIGNATURE.md:156
HIGH secret-generic-credential docs/integration-examples/nodejs-bot.md:225
HIGH secret-generic-api-key docs/sdk/getting-started.md:36
HIGH secret-generic-api-key docs/sdk/getting-started.md:318
HIGH secret-generic-api-key packages/sdk/README.md:99
HIGH secret-generic-credential packages/sdk/README.md:122
HIGH secret-generic-credential packages/sdk/README.md:772
HIGH sh-remote-script-execution public/install.sh:167
HIGH sh-remote-script-execution public/install.sh:379
HIGH sh-remote-script-execution public/install.sh:384
HIGH sh-remote-script-execution public/install.sh:388
HIGH sh-remote-script-execution public/install.sh:733
HIGH sh-remote-script-execution public/install.sh:734
HIGH sh-remote-script-execution public/install.sh:774
HIGH sh-remote-script-execution public/install.sh:775
HIGH sh-remote-script-execution public/install.sh:776
HIGH secret-generic-credential scripts/setup-droplet.sh:609
HIGH secret-generic-api-key src/app/docs/sdk/page.tsx:135
HIGH secret-generic-api-key src/app/docs/sdk/page.tsx:214
HIGH secret-generic-credential src/app/docs/sdk/page.tsx:1001
HIGH secret-generic-credential src/app/docs/sdk/page.tsx:1022
HIGH secret-generic-api-key src/app/docs/sdk/page.tsx:1401
HIGH secret-generic-credential src/app/docs/sdk/page.tsx:1482
HIGH secret-generic-credential src/app/docs/sdk/page.tsx:1491
HIGH secret-generic-api-key src/app/docs/sdk/page.tsx:1533
HIGH secret-generic-credential src/components/docs/AuthenticationDocs.tsx:37
HIGH secret-generic-credential src/components/docs/OAuthDocs.tsx:262
HIGH secret-generic-credential supabase/config.toml:255
HIGH secret-generic-credential supabase/config.toml:287
MEDIUM manifest-install-lifecycle-script package.json:27
MEDIUM js-shell-exec-interpolation packages/sdk/bin/coinpay.js:48
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-issuer.test.js:23
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-reputation.test.js:23
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:22
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:33
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:48
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:63
MEDIUM js-shell-exec-interpolation packages/sdk/test/cli-subscription.test.js:78
MEDIUM js-shell-exec-interpolation packages/sdk/test/wallet-backup.test.js:82
MEDIUM js-shell-exec-interpolation packages/sdk/test/wallet.test.js:249
MEDIUM js-shell-exec-interpolation packages/sdk/test/wallet.test.js:280
MEDIUM insecure-temp-file public/install.sh:108
MEDIUM sh-unquoted-expansion-destructive public/install.sh:690
MEDIUM js-unescaped-html-sink public/payments.js:93
MEDIUM js-unescaped-html-sink public/payments.js:139
MEDIUM js-predictable-cipher-iv scripts/decrypt-wallet-backup.mjs:31
MEDIUM insecure-temp-file scripts/install-gl-client.sh:37

…and 269 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 24e33cf into master Aug 19, 2026
9 checks passed
@ralyodio
ralyodio deleted the fix/audit-remaining-gaps branch August 19, 2026 21:00
ralyodio added a commit that referenced this pull request Aug 19, 2026
…bhooks, installer pinning (#282)

Closes the three product gaps left open after #279.

ESC-NEW-01: dispute_resolution/dispute_status had no writer, and refund required 'funded' — so raising a dispute removed the refund path entirely. 'disputed' is now refundable and resolveDispute() adds an admin-gated arbiter exit.

REC-D-07: webhook retry spent its whole budget in ~3 seconds inside one request. Adds a durable queue with 1m-12h backoff and a dead-letter after 8 attempts; rows are claimed before delivery and payloads re-signed per attempt.

W-01: unattended auto-upgrade from a mutable ref is now opt-in. Previously any merge to master executed on every operator host within five minutes.

Also deactivated 5 bogus reputation issuers in production (reversible), after verifying only ugig.net and d0rz.com have ever produced a receipt.

334 files / 4690 tests green, all 8 CI checks pass.
ralyodio added a commit that referenced this pull request Aug 19, 2026
…bhooks, installer pinning (#282)

Closes the three product gaps left open after #279.

ESC-NEW-01: dispute_resolution/dispute_status had no writer, and refund required 'funded' — so raising a dispute removed the refund path entirely. 'disputed' is now refundable and resolveDispute() adds an admin-gated arbiter exit.

REC-D-07: webhook retry spent its whole budget in ~3 seconds inside one request. Adds a durable queue with 1m-12h backoff and a dead-letter after 8 attempts; rows are claimed before delivery and payloads re-signed per attempt.

W-01: unattended auto-upgrade from a mutable ref is now opt-in. Previously any merge to master executed on every operator host within five minutes.

Also deactivated 5 bogus reputation issuers in production (reversible), after verifying only ugig.net and d0rz.com have ever produced a receipt.

334 files / 4690 tests green, all 8 CI checks pass.
ralyodio added a commit that referenced this pull request Aug 19, 2026
…bhooks, installer pinning (#282)

Closes the three product gaps left open after #279.

ESC-NEW-01: dispute_resolution/dispute_status had no writer, and refund required 'funded' — so raising a dispute removed the refund path entirely. 'disputed' is now refundable and resolveDispute() adds an admin-gated arbiter exit.

REC-D-07: webhook retry spent its whole budget in ~3 seconds inside one request. Adds a durable queue with 1m-12h backoff and a dead-letter after 8 attempts; rows are claimed before delivery and payloads re-signed per attempt.

W-01: unattended auto-upgrade from a mutable ref is now opt-in. Previously any merge to master executed on every operator host within five minutes.

Also deactivated 5 bogus reputation issuers in production (reversible), after verifying only ugig.net and d0rz.com have ever produced a receipt.

334 files / 4690 tests green, all 8 CI checks pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant