Skip to content

fix(security): remediate the audit's 29 medium and 15 low findings (GHSA-q642) - #260

Merged
ralyodio merged 5 commits into
masterfrom
fix/audit-medium-low
Aug 16, 2026
Merged

ralyodio merged 5 commits into
masterfrom
fix/audit-medium-low

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Closes out GHSA-q642-mvvc-5w7x — the umbrella "86 confirmed" remediation list. Its 3 critical + 39 high were fixed in #257; this covers the 29 medium + 15 low that were enumerated in that document but never filed as individual advisories.

Verification

  • pnpm type-check clean
  • pnpm build succeeds
  • vitest run — 4045 passing, 3 skipped, 0 failing

Every database claim below was checked against production before writing the fix, by assuming the anon role and reading. That mattered more than expected — see the next section.

The two things worth reading

1. A correction to my own earlier work. The column-level REVOKEs shipped in #257 for P-013/P-014 were no-ops. Supabase grants table-level SELECT, and a table-level grant already permits every column — revoking a column subset subtracts nothing. Verified after the fact: has_column_privilege('authenticated','escrows','release_token','SELECT') still returned true. Those controls did not exist. They do now, by dropping the table grant outright rather than trying to carve columns out of it.

2. Only one of these findings was actually live. reputation_receipts is readable by anon with USING (true), and production holds 13,711 rows carrying amount (up to 999,999) and escrow_tx — enumerable by anyone with the publishable key. Public verifiability is the point of a receipt, so the row stays readable and the financial columns do not.

Everything else is latent: this app authenticates with its own JWT and talks to Postgres as service_role, so auth.uid() is never set and every policy keyed on it denies. Confirmed empirically — as anon, businesses/payments/invoices/stripe_webhook_secrets all return 0 rows, and cleanup_seen_signatures() executes but deletes nothing because RLS blocks the write. Fixed anyway, since each is one policy edit from becoming real.

Highlights

Client IP (V-028) — getClientIp read the leftmost X-Forwarded-For entry. XFF is appended to by each proxy, so a request arriving with X-Forwarded-For: 1.2.3.4 leaves the edge as 1.2.3.4, <real ip>: the leftmost value is the one the client chose. Every per-IP rate limit was bypassable by rotating a header. Now counts in from the right; 7 tests cover it.

Replay across instances (V-026) — signature replay protection used the in-memory checker with a fire-and-forget DB sync. Across more than one instance that is not replay protection: a signature spent on instance A is unknown to instance B.

Token minting (V-027) — jwt.refreshToken() decoded without verifying, then re-signed the payload. A token this side never issued would have been minted into a valid one. Unused today, which is why it's rated low, but it's a loaded gun in a library.

PII in git (P-024) — send-announcement.ts had six real merchant names and emails hardcoded. Now read from the database. They remain in git history and should be treated as disclosed.

Wallet generation (P-017) — gen-mnemonic.mjs fetched the BIP-39 wordlist from GitHub at master, unverified, and from a different source than the derivation code uses. Any divergence would emit a phrase deriving to different keys than the platform expects, making funds sent there unrecoverable. Now uses the pinned local wordlist with a canonical checksum; generated phrases verified against the same validateMnemonic the wallet code uses.

Deploy secrets (P-018) — the LNbits workflow read its SSH key as secrets.X || vars.X. Actions variables are plaintext and not masked in logs. Also now workflow_dispatch only under a production environment (the reviewer rule needs enabling in Settings → Environments).

Others: PayPal capture verifying amount and currency (P-021); invoices no longer marked paid while the forward failed (V-008); invoice-number uniqueness with retry (V-010); proposal accept CAS (P-022); escrow fee-forward failures recorded rather than swallowed (N-015); payment idempotency keys (N-014); SDK timestamp NaN fail-open (P-019); allowBuilds placeholders resolved (N-019); installer integrity (P-003); broadcast bound to the prepared transaction (V-024).

Deliberately not done

  • N-022 — detected is unreachable (removed from the CHECK constraint) but ~26 UI branches still render it. Annotated at the canonical types; removing the branches is a mechanical sweep across payer-facing pages that doesn't belong in a security change.
  • N-017 — already carries a 20% volatility buffer, and fix(security): remediate all 42 draft security advisories #257's actual-balance split covers the rest. The residual is a pricing-tuning decision.
  • N-018 — the high-risk parsers already use zod schemas. A blanket sweep would be large and low-yield.
  • P-030 — a data-retention policy is a business decision, not a code change. The concrete PII leak it points at (P-024) is fixed.
  • V-002 was already fixed by enforcePriceBinding (commit 5717556) before this work.

Migrations

Two, both already applied to production and verified: 20260816020000_audit_medium_low_database.sql and the invoice-uniqueness/fee-event follow-up. Idempotent.

🤖 Generated with Claude Code

ralyodio and others added 5 commits August 16, 2026 01:31
…m 20260815000000

Every claim was checked against production by assuming the anon role first.
That check found two things worth stating plainly.

CORRECTION. The column-level REVOKEs shipped in 20260815000000 for P-013 and
P-014 were NO-OPS. Supabase grants table-level SELECT to anon/authenticated,
and a table-level grant already permits every column, so revoking a column
subset subtracts nothing. Verified after the fact:
has_column_privilege('authenticated','escrows','release_token','SELECT') was
still true. Neither table has legitimate anon/authenticated access, so the
grant is now dropped outright rather than carved up.

P-029 was the only LIVE finding. reputation_receipts is readable by anon with
USING (true), and production holds 13,711 rows carrying amount (up to 999,999)
and escrow_tx — enumerable by anyone with the publishable key. Public
verifiability is the point of a receipt, so the row stays readable and the
financial columns do not: the table grant is replaced by an explicit column
grant. Consumers must now name columns; a bare SELECT * as anon fails.

The rest are LATENT, not live: this app authenticates with its own JWT and
talks to Postgres as service_role, so auth.uid() is never set and every policy
keyed on it denies. Confirmed empirically — as anon, businesses/payments/
invoices/stripe_webhook_secrets all return 0 rows, and cleanup_seen_signatures()
executes but deletes nothing because RLS blocks the underlying delete. They are
fixed anyway, since each is one policy edit away from becoming real:

  V-018 businesses.api_key, P-016 stripe webhook secret — grant removed
  P-002 did_reputation_events public SELECT — policy and grant removed
  P-026 invoices INSERT constrained only user_id, leaving business_id free
  P-027 payments INSERT accepted an arbitrary status (confirmed/forwarded)
  N-021 'cancelled' allowed by the CHECK but never written or handled
  P-028 calendar_events.user_id had no foreign key
  P-025 escrow_series.merchant_id references businesses — documented, not
        renamed, because live code reads it
  P-023 21 functions were EXECUTE-able by anon; now service_role only

Verified after applying: every privilege assertion passes, anon still reads the
allowed receipt columns, and the live reputation and landing endpoints answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l flows

P-021 PayPal capture accepted 'COMPLETED' as proof of payment without ever
reading the captured amount or currency — both of which the client already
returns. A capture for a token amount, or in a different currency, marked a
full-value invoice paid. Now verified against the invoice, and the paid write
is conditional so two captures cannot both record a settlement.

V-008 An invoice flipped to 'paid' when its linked payment was in
forwarding_failed — money confirmed at the intermediary address but never
delivered. The merchant saw a settled invoice and no reason to look. That
status is no longer treated as settling; the retry queue and rescanLateDeposits
re-drive the forward, so it resolves rather than sticking.

V-010 Invoice numbers were allocated with an unlocked read-then-write MAX+1, so
concurrent creates silently produced duplicates. The application cannot make
that atomic, so the guarantee is a UNIQUE (business_id, invoice_number)
constraint with a retry around the insert. The lookup also ordered by
created_at, which returns the most recently created number rather than the
highest — wrong as soon as an invoice is deleted or backdated.

P-022 Accepting a proposal is what binds the payee address and fee, and the
write was unconditional; it is now a compare-and-swap on the observed status.

N-015 A failed escrow fee forward was caught, logged and forgotten: the escrow
was marked settled with fee_tx_hash NULL and nothing recorded that the platform
was still owed. It now writes a fee_forward_failed escrow_events row.

N-016 The escrow fee is bounded at creation, so an out-of-range rate cannot
store a fee that exceeds the escrow and make the beneficiary leg negative.

N-013 The payment window had only an upper bound, so expires_in_minutes could
be set below a second — every payment against such a quote lands late and
strands. Floor added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… client IP

V-028 getClientIp read the LEFTMOST X-Forwarded-For entry. XFF is appended to
by each proxy, so a request arriving with "X-Forwarded-For: 1.2.3.4" leaves the
edge as "1.2.3.4, <real ip>" — the leftmost value is the one the client chose.
Every per-IP rate limit was therefore bypassable by rotating a header. Parsing
now counts in from the RIGHT (TRUSTED_PROXY_HOPS, default 1), and the vendor
headers CF-Connecting-IP / X-Real-IP are only trusted when explicitly enabled,
since they are equally forgeable if the origin is reachable directly. Seven
tests cover it, including that prepending a forged address cannot move the
rate-limit bucket.

V-026 Web-wallet signature replay protection used the synchronous checker,
which consults only this process's in-memory Map and pushes to Supabase
fire-and-forget. Across more than one instance that is not replay protection: a
signature spent on instance A is unknown to instance B. Now awaits the shared
store.

V-027 jwt.refreshToken() decoded WITHOUT verifying, then re-signed the payload.
A token this side never issued would have been accepted and minted into a valid
one. It is unused today, which is why the audit rated it low, but it is a
loaded gun in a library. Now verifies the signature while still allowing an
expired token, which is the actual intent.

V-021 The Web Bot Auth signature lifetime cap only applied when both created
and expires were present, so omitting expires skipped it — the most permissive
case had no check at all. Both are now required.

V-020 The CLI device-authorization start endpoint is unauthenticated by design
and had no rate limit, so one caller could mint unlimited pending device codes
and raise the collision odds against a code a real user is being shown.

V-009 p2p/request had no upper bound on amount_usd and no rate limit, on an
endpoint that provisions merchant accounts and client records.

P-014 (completion) The issuer API-key lookup still queried the raw api_key
column, so the hash column restored by 20260816020000 was unused. Lookups now
match the hash and lazily upgrade any row still found by its raw key, so the
column drains without forcing a key rotation.

V-004 x402 raw_proof stored the payer's full signed authorization verbatim in a
column nothing ever reads. Secret-bearing fields are now hashed, keeping the
proof shape for forensics without retaining anything reusable.

V-014 rescanLateDeposits treated "funds still at the address" as proof no
forward succeeded. For a payment already in forwarding with a recorded
transaction hash that is false — the send may simply not be mined yet — so a
known broadcast now gets a six-hour grace before it can be re-driven.

V-002 was already fixed by enforcePriceBinding (commit 5717556); no change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
P-018 The LNbits deploy workflow read its SSH private key and dotenv
credentials as "secrets.X || vars.X". GitHub Actions VARIABLES are not secrets:
plaintext, readable by anyone with repo access, and not masked in job logs. Had
either been set as a variable, the SSH key would have been printed in the clear.
The fallbacks are gone; a missing secret now fails the job.

N-020 That same workflow ran on every push to master with SSH access to a
production droplet, so any merged commit reached production infrastructure with
nobody in the loop. It is workflow_dispatch only and pinned to the "production"
environment, so a required-reviewer rule applies. NOTE: that rule must be
enabled in Settings -> Environments -> production; the environment key here is
what makes it apply. Its actions are SHA-pinned too — it was the one workflow
missed in #257, and it is the highest-value target in the repo.

P-017 gen-mnemonic.mjs fetched the BIP-39 wordlist from GitHub at master on
every run, unverified. Two problems, and the second is the serious one: nothing
checked what came back, and it was a DIFFERENT source from the one used to
DERIVE addresses (@scure/bip39). Any divergence would emit a phrase deriving to
different keys than the platform expects, making funds sent there
unrecoverable. Now uses the local lockfile-pinned wordlist with a canonical
SHA-256 assertion. Verified: generated phrases pass the same validateMnemonic
the wallet code uses.

P-024 send-announcement.ts had six real merchant names and email addresses
hardcoded, plus two more in a skip list — production PII committed to the
repository. Recipients are read from the database at run time, and the skip
list is domain patterns rather than named individuals. The file carries a note
that this does not remove them from git history; treat those addresses as
disclosed.

P-003 The installer piped curl straight into tar, so a truncated transfer
extracted whatever arrived. It now downloads to a file, verifies it is a
well-formed non-empty gzip, logs the archive digest, supports pinning via
COINPAY_SHA256, and constrains COINPAY_REF, which was interpolated into the
download URL unchecked.

P-019 In the SDK webhook verifier, parseInt('abc', 10) is NaN and every
comparison against NaN is false — so the timestamp freshness check silently
PASSED for any non-numeric timestamp. A security check that no-ops on malformed
input is worse than none, because it reads as one. The signature header parser
also split on every '=', truncating any value containing one.

N-019 Every allowBuilds entry was literally "set this to true or false", which
pnpm treats as unset, so none of those packages ran their install scripts and
nobody had made the decision. Each is now resolved explicitly, with native
builds enabled and telemetry-only postinstalls declined.

N-012 The optional-dependency loader built a "new Function" evaluator taking a
free-form module specifier. Every caller passes a constant, but the shape is
worth not having in a payments codebase; it is now an allowlist.

N-023 The wallet SDK logged every address the wallet holds on an error path
triggered by an ordinary user mistake, correlating a user's whole account set
into any shared console. Logs the count instead.

N-014 Payment creation had no idempotency, so a client retrying after a timeout
got a second payment — another HD address, another unit of quota, another
charge. It now honours an Idempotency-Key, backed by a partial unique index so
concurrent retries cannot both insert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Broadcast checked that tx_id belonged to the wallet, that it was still pending
and that it had not expired — but never looked at the signed_tx bytes. A caller
could prepare one transfer and then broadcast a COMPLETELY DIFFERENT signed
transaction while quoting the legitimate tx_id.

The user signs with their own key, so this is not a theft primitive against
them. It matters because everything the platform records and enforces hangs off
the prepared row: the wallet's history, per-transaction limits, fee accounting
and notifications would all describe a transaction that never happened, while
the one that did goes unrecorded.

EVM transactions are self-describing, so the recipient is decoded and compared
(handling ERC-20 transfers, where the recipient is in the calldata rather than
the tx `to`). A decoded mismatch is refused outright. UTXO and Solana need
chain-specific parsing that is not available here, so rather than pretend those
are checked, the row records binding_verified plus the reason — downstream
accounting can then tell what it may rely on.

Also annotates the unreachable 'detected' payment status (N-022). It was
removed from the payments.status CHECK constraint without the application being
updated, so the database can no longer produce it, yet roughly two dozen UI
branches still render a "Payment Detected!" stage no payment can reach. The
canonical type definitions now say so. The branches themselves are left in
place: removing them is a mechanical sweep across payer-facing pages and does
not belong in a security change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

327 finding(s)

HIGH/CRITICAL: 35 | MEDIUM: 38 | LOW: 254

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 sensitive-file-committed doppler.env:1
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:743
HIGH sh-remote-script-execution public/install.sh:142
HIGH sh-remote-script-execution public/install.sh:338
HIGH sh-remote-script-execution public/install.sh:342
HIGH sh-remote-script-execution public/install.sh:347
HIGH sh-remote-script-execution public/install.sh:351
HIGH sh-remote-script-execution public/install.sh:420
HIGH sh-remote-script-execution public/install.sh:656
HIGH sh-remote-script-execution public/install.sh:657
HIGH sh-remote-script-execution public/install.sh:697
HIGH sh-remote-script-execution public/install.sh:698
HIGH sh-remote-script-execution public/install.sh:699
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:1400
HIGH secret-generic-credential src/app/docs/sdk/page.tsx:1481
HIGH secret-generic-credential src/app/docs/sdk/page.tsx:1490
HIGH secret-generic-api-key src/app/docs/sdk/page.tsx:1532
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:26
MEDIUM js-shell-exec-interpolation packages/sdk/bin/coinpay.js:40
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:83
MEDIUM sh-unquoted-expansion-destructive public/install.sh:613
MEDIUM js-unescaped-html-sink public/payments.js:93

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

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 3b1b62b into master Aug 16, 2026
9 checks passed
ralyodio added a commit that referenced this pull request Aug 16, 2026
#260 changed this workflow to workflow_dispatch-only under a "production"
environment, to satisfy audit finding N-020 (SSH deploy without an approval
gate). That removed continuous deployment, which is behaviour the maintainer
relies on. Reverted at their request.

Continuous deployment here is a deliberate trade-off and it is the maintainer's
to make, not the audit's. The workflow comment records that, and records how to
put the gate back in two lines if that ever changes.

What is NOT reverted, because it was a separate finding and was not asked for:

  P-018 — ENV_FILE and DIRECT_SSH_KEY still read from `secrets` only. They used
  to fall back to `vars.*`, and GitHub Actions variables are plaintext,
  readable by anyone with repo access, and NOT masked in job logs; an SSH
  private key supplied that way would be printed in the clear.

  Action pinning — actions/checkout stays pinned to its commit SHA.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Aug 16, 2026
…-role fallback, mnemonic temp files, replay fail-open) (#262)

* revert(ci): restore auto-deploy for the LNbits droplet

#260 changed this workflow to workflow_dispatch-only under a "production"
environment, to satisfy audit finding N-020 (SSH deploy without an approval
gate). That removed continuous deployment, which is behaviour the maintainer
relies on. Reverted at their request.

Continuous deployment here is a deliberate trade-off and it is the maintainer's
to make, not the audit's. The workflow comment records that, and records how to
put the gate back in two lines if that ever changes.

What is NOT reverted, because it was a separate finding and was not asked for:

  P-018 — ENV_FILE and DIRECT_SSH_KEY still read from `secrets` only. They used
  to fall back to `vars.*`, and GitHub Actions variables are plaintext,
  readable by anyone with repo access, and NOT masked in job logs; an SSH
  private key supplied that way would be printed in the clear.

  Action pinning — actions/checkout stays pinned to its commit SHA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): remediate the three newly-reported advisories

GHSA-j73w-r4j5-3wx9 (critical) — hardcoded service-role key fallback.

The literal 'service-role-key' string the report cites is already gone from the
two files it names, but the class of bug is alive in 16 others, which fall back
to the ANON key or an empty string. Both are worse than they look:

  - Falling back to the anon key does not fail, it silently DOWNGRADES. The
    route keeps serving while RLS starts applying to a code path written on the
    assumption that it does not. Reads come back empty, writes are refused, and
    the symptom appears far from the cause.
  - Falling back to a literal is a hardcoded credential in the sense that
    matters: the deployment believes it is authenticated and is not.

All 16 now go through createServiceClient(), which refuses to build a client
without a real URL and service-role key. Zero fallbacks remain.

GHSA-cqrc-mhqx-48mr (high) — wallet mnemonic in predictable temp files.

The CLI wrote the mnemonic and the password to /tmp/coinpay-wallet-<Date.now()>
and /tmp/coinpay-pass-<Date.now()> before shelling out to gpg. The names are a
guess away, mode 0600 stops other OS users but not anything running as the same
user, and overwrite-then-unlink does not reliably erase on journalling or
copy-on-write filesystems. The mnemonic derives every key on every chain.

Both files are gone. gpg now takes the passphrase on fd 3 and the plaintext on
stdin, so neither touches disk and the passphrase never appears in the process
arguments where ps would show it. Verified end to end: round-trip recovers the
mnemonic, the file on disk is real ciphertext, a wrong password is rejected, and
a filesystem watcher polling every 2ms during the operation observed ZERO temp
files.

GHSA-c9jw-9f79-j657 (high) — optional auth nonce enables replay.

The report recommends making the nonce mandatory. That would 401 every
installed browser extension: packages/extension sends a 3-part header with no
nonce. So the extension now sends one (in both the signed message and the
header — an unsigned nonce would let an attacker mint fresh replay-store keys
for a captured signature), while the server keeps accepting both formats.

More importantly, the nonce was not the actual hole. Replay protection is the
shared signature store, and it FAILED OPEN: on any Supabase error the check
returned null and fell through to a per-process in-memory Map. On a
multi-instance deployment a signature spent on instance A is unknown to
instance B, so protection evaporated exactly when the store was unhealthy —
which is when an attacker is most likely probing. It now rejects when the store
is configured but unreachable, keeping the in-memory path only for deployments
with no shared store at all. Four tests cover the state machine.

Tests: 4049 passing (up from 4045). The global test setup now provides Supabase
env vars, since routes legitimately refuse to run without them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Aug 19, 2026
…HSA-q642) (#260)

* fix(db): medium/low audit findings, and correct the no-op REVOKEs from 20260815000000

Every claim was checked against production by assuming the anon role first.
That check found two things worth stating plainly.

CORRECTION. The column-level REVOKEs shipped in 20260815000000 for P-013 and
P-014 were NO-OPS. Supabase grants table-level SELECT to anon/authenticated,
and a table-level grant already permits every column, so revoking a column
subset subtracts nothing. Verified after the fact:
has_column_privilege('authenticated','escrows','release_token','SELECT') was
still true. Neither table has legitimate anon/authenticated access, so the
grant is now dropped outright rather than carved up.

P-029 was the only LIVE finding. reputation_receipts is readable by anon with
USING (true), and production holds 13,711 rows carrying amount (up to 999,999)
and escrow_tx — enumerable by anyone with the publishable key. Public
verifiability is the point of a receipt, so the row stays readable and the
financial columns do not: the table grant is replaced by an explicit column
grant. Consumers must now name columns; a bare SELECT * as anon fails.

The rest are LATENT, not live: this app authenticates with its own JWT and
talks to Postgres as service_role, so auth.uid() is never set and every policy
keyed on it denies. Confirmed empirically — as anon, businesses/payments/
invoices/stripe_webhook_secrets all return 0 rows, and cleanup_seen_signatures()
executes but deletes nothing because RLS blocks the underlying delete. They are
fixed anyway, since each is one policy edit away from becoming real:

  V-018 businesses.api_key, P-016 stripe webhook secret — grant removed
  P-002 did_reputation_events public SELECT — policy and grant removed
  P-026 invoices INSERT constrained only user_id, leaving business_id free
  P-027 payments INSERT accepted an arbitrary status (confirmed/forwarded)
  N-021 'cancelled' allowed by the CHECK but never written or handled
  P-028 calendar_events.user_id had no foreign key
  P-025 escrow_series.merchant_id references businesses — documented, not
        renamed, because live code reads it
  P-023 21 functions were EXECUTE-able by anon; now service_role only

Verified after applying: every privilege assertion passes, anon still reads the
allowed receipt columns, and the live reputation and landing endpoints answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(payments): audit medium/low findings in invoice, escrow and PayPal flows

P-021 PayPal capture accepted 'COMPLETED' as proof of payment without ever
reading the captured amount or currency — both of which the client already
returns. A capture for a token amount, or in a different currency, marked a
full-value invoice paid. Now verified against the invoice, and the paid write
is conditional so two captures cannot both record a settlement.

V-008 An invoice flipped to 'paid' when its linked payment was in
forwarding_failed — money confirmed at the intermediary address but never
delivered. The merchant saw a settled invoice and no reason to look. That
status is no longer treated as settling; the retry queue and rescanLateDeposits
re-drive the forward, so it resolves rather than sticking.

V-010 Invoice numbers were allocated with an unlocked read-then-write MAX+1, so
concurrent creates silently produced duplicates. The application cannot make
that atomic, so the guarantee is a UNIQUE (business_id, invoice_number)
constraint with a retry around the insert. The lookup also ordered by
created_at, which returns the most recently created number rather than the
highest — wrong as soon as an invoice is deleted or backdated.

P-022 Accepting a proposal is what binds the payee address and fee, and the
write was unconditional; it is now a compare-and-swap on the observed status.

N-015 A failed escrow fee forward was caught, logged and forgotten: the escrow
was marked settled with fee_tx_hash NULL and nothing recorded that the platform
was still owed. It now writes a fee_forward_failed escrow_events row.

N-016 The escrow fee is bounded at creation, so an out-of-range rate cannot
store a fee that exceeds the escrow and make the beneficiary leg negative.

N-013 The payment window had only an upper bound, so expires_in_minutes could
be set below a second — every payment against such a quote lands late and
strands. Floor added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(auth): audit medium/low findings in tokens, replay protection and client IP

V-028 getClientIp read the LEFTMOST X-Forwarded-For entry. XFF is appended to
by each proxy, so a request arriving with "X-Forwarded-For: 1.2.3.4" leaves the
edge as "1.2.3.4, <real ip>" — the leftmost value is the one the client chose.
Every per-IP rate limit was therefore bypassable by rotating a header. Parsing
now counts in from the RIGHT (TRUSTED_PROXY_HOPS, default 1), and the vendor
headers CF-Connecting-IP / X-Real-IP are only trusted when explicitly enabled,
since they are equally forgeable if the origin is reachable directly. Seven
tests cover it, including that prepending a forged address cannot move the
rate-limit bucket.

V-026 Web-wallet signature replay protection used the synchronous checker,
which consults only this process's in-memory Map and pushes to Supabase
fire-and-forget. Across more than one instance that is not replay protection: a
signature spent on instance A is unknown to instance B. Now awaits the shared
store.

V-027 jwt.refreshToken() decoded WITHOUT verifying, then re-signed the payload.
A token this side never issued would have been accepted and minted into a valid
one. It is unused today, which is why the audit rated it low, but it is a
loaded gun in a library. Now verifies the signature while still allowing an
expired token, which is the actual intent.

V-021 The Web Bot Auth signature lifetime cap only applied when both created
and expires were present, so omitting expires skipped it — the most permissive
case had no check at all. Both are now required.

V-020 The CLI device-authorization start endpoint is unauthenticated by design
and had no rate limit, so one caller could mint unlimited pending device codes
and raise the collision odds against a code a real user is being shown.

V-009 p2p/request had no upper bound on amount_usd and no rate limit, on an
endpoint that provisions merchant accounts and client records.

P-014 (completion) The issuer API-key lookup still queried the raw api_key
column, so the hash column restored by 20260816020000 was unused. Lookups now
match the hash and lazily upgrade any row still found by its raw key, so the
column drains without forcing a key rotation.

V-004 x402 raw_proof stored the payer's full signed authorization verbatim in a
column nothing ever reads. Secret-bearing fields are now hashed, keeping the
proof shape for forensics without retaining anything reusable.

V-014 rescanLateDeposits treated "funds still at the address" as proof no
forward succeeded. For a payment already in forwarding with a recorded
transaction hash that is false — the send may simply not be mined yet — so a
known broadcast now gets a six-hour grace before it can be re-driven.

V-002 was already fixed by enforcePriceBinding (commit 0f0f300); no change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(infra): audit medium/low findings in CI, installer, scripts and SDK

P-018 The LNbits deploy workflow read its SSH private key and dotenv
credentials as "secrets.X || vars.X". GitHub Actions VARIABLES are not secrets:
plaintext, readable by anyone with repo access, and not masked in job logs. Had
either been set as a variable, the SSH key would have been printed in the clear.
The fallbacks are gone; a missing secret now fails the job.

N-020 That same workflow ran on every push to master with SSH access to a
production droplet, so any merged commit reached production infrastructure with
nobody in the loop. It is workflow_dispatch only and pinned to the "production"
environment, so a required-reviewer rule applies. NOTE: that rule must be
enabled in Settings -> Environments -> production; the environment key here is
what makes it apply. Its actions are SHA-pinned too — it was the one workflow
missed in #257, and it is the highest-value target in the repo.

P-017 gen-mnemonic.mjs fetched the BIP-39 wordlist from GitHub at master on
every run, unverified. Two problems, and the second is the serious one: nothing
checked what came back, and it was a DIFFERENT source from the one used to
DERIVE addresses (@scure/bip39). Any divergence would emit a phrase deriving to
different keys than the platform expects, making funds sent there
unrecoverable. Now uses the local lockfile-pinned wordlist with a canonical
SHA-256 assertion. Verified: generated phrases pass the same validateMnemonic
the wallet code uses.

P-024 send-announcement.ts had six real merchant names and email addresses
hardcoded, plus two more in a skip list — production PII committed to the
repository. Recipients are read from the database at run time, and the skip
list is domain patterns rather than named individuals. The file carries a note
that this does not remove them from git history; treat those addresses as
disclosed.

P-003 The installer piped curl straight into tar, so a truncated transfer
extracted whatever arrived. It now downloads to a file, verifies it is a
well-formed non-empty gzip, logs the archive digest, supports pinning via
COINPAY_SHA256, and constrains COINPAY_REF, which was interpolated into the
download URL unchecked.

P-019 In the SDK webhook verifier, parseInt('abc', 10) is NaN and every
comparison against NaN is false — so the timestamp freshness check silently
PASSED for any non-numeric timestamp. A security check that no-ops on malformed
input is worse than none, because it reads as one. The signature header parser
also split on every '=', truncating any value containing one.

N-019 Every allowBuilds entry was literally "set this to true or false", which
pnpm treats as unset, so none of those packages ran their install scripts and
nobody had made the decision. Each is now resolved explicitly, with native
builds enabled and telemetry-only postinstalls declined.

N-012 The optional-dependency loader built a "new Function" evaluator taking a
free-form module specifier. Every caller passes a constant, but the shape is
worth not having in a payments codebase; it is now an allowlist.

N-023 The wallet SDK logged every address the wallet holds on an error path
triggered by an ordinary user mistake, correlating a user's whole account set
into any shared console. Logs the count instead.

N-014 Payment creation had no idempotency, so a client retrying after a timeout
got a second payment — another HD address, another unit of quota, another
charge. It now honours an Idempotency-Key, backed by a partial unique index so
concurrent retries cannot both insert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(web-wallet): bind broadcast to the prepared transaction (V-024)

Broadcast checked that tx_id belonged to the wallet, that it was still pending
and that it had not expired — but never looked at the signed_tx bytes. A caller
could prepare one transfer and then broadcast a COMPLETELY DIFFERENT signed
transaction while quoting the legitimate tx_id.

The user signs with their own key, so this is not a theft primitive against
them. It matters because everything the platform records and enforces hangs off
the prepared row: the wallet's history, per-transaction limits, fee accounting
and notifications would all describe a transaction that never happened, while
the one that did goes unrecorded.

EVM transactions are self-describing, so the recipient is decoded and compared
(handling ERC-20 transfers, where the recipient is in the calldata rather than
the tx `to`). A decoded mismatch is refused outright. UTXO and Solana need
chain-specific parsing that is not available here, so rather than pretend those
are checked, the row records binding_verified plus the reason — downstream
accounting can then tell what it may rely on.

Also annotates the unreachable 'detected' payment status (N-022). It was
removed from the payments.status CHECK constraint without the application being
updated, so the database can no longer produce it, yet roughly two dozen UI
branches still render a "Payment Detected!" stage no payment can reach. The
canonical type definitions now say so. The branches themselves are left in
place: removing them is a mechanical sweep across payer-facing pages and does
not belong in a security change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Aug 19, 2026
#260 changed this workflow to workflow_dispatch-only under a "production"
environment, to satisfy audit finding N-020 (SSH deploy without an approval
gate). That removed continuous deployment, which is behaviour the maintainer
relies on. Reverted at their request.

Continuous deployment here is a deliberate trade-off and it is the maintainer's
to make, not the audit's. The workflow comment records that, and records how to
put the gate back in two lines if that ever changes.

What is NOT reverted, because it was a separate finding and was not asked for:

  P-018 — ENV_FILE and DIRECT_SSH_KEY still read from `secrets` only. They used
  to fall back to `vars.*`, and GitHub Actions variables are plaintext,
  readable by anyone with repo access, and NOT masked in job logs; an SSH
  private key supplied that way would be printed in the clear.

  Action pinning — actions/checkout stays pinned to its commit SHA.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Aug 19, 2026
…-role fallback, mnemonic temp files, replay fail-open) (#262)

* revert(ci): restore auto-deploy for the LNbits droplet

#260 changed this workflow to workflow_dispatch-only under a "production"
environment, to satisfy audit finding N-020 (SSH deploy without an approval
gate). That removed continuous deployment, which is behaviour the maintainer
relies on. Reverted at their request.

Continuous deployment here is a deliberate trade-off and it is the maintainer's
to make, not the audit's. The workflow comment records that, and records how to
put the gate back in two lines if that ever changes.

What is NOT reverted, because it was a separate finding and was not asked for:

  P-018 — ENV_FILE and DIRECT_SSH_KEY still read from `secrets` only. They used
  to fall back to `vars.*`, and GitHub Actions variables are plaintext,
  readable by anyone with repo access, and NOT masked in job logs; an SSH
  private key supplied that way would be printed in the clear.

  Action pinning — actions/checkout stays pinned to its commit SHA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): remediate the three newly-reported advisories

GHSA-j73w-r4j5-3wx9 (critical) — hardcoded service-role key fallback.

The literal 'service-role-key' string the report cites is already gone from the
two files it names, but the class of bug is alive in 16 others, which fall back
to the ANON key or an empty string. Both are worse than they look:

  - Falling back to the anon key does not fail, it silently DOWNGRADES. The
    route keeps serving while RLS starts applying to a code path written on the
    assumption that it does not. Reads come back empty, writes are refused, and
    the symptom appears far from the cause.
  - Falling back to a literal is a hardcoded credential in the sense that
    matters: the deployment believes it is authenticated and is not.

All 16 now go through createServiceClient(), which refuses to build a client
without a real URL and service-role key. Zero fallbacks remain.

GHSA-cqrc-mhqx-48mr (high) — wallet mnemonic in predictable temp files.

The CLI wrote the mnemonic and the password to /tmp/coinpay-wallet-<Date.now()>
and /tmp/coinpay-pass-<Date.now()> before shelling out to gpg. The names are a
guess away, mode 0600 stops other OS users but not anything running as the same
user, and overwrite-then-unlink does not reliably erase on journalling or
copy-on-write filesystems. The mnemonic derives every key on every chain.

Both files are gone. gpg now takes the passphrase on fd 3 and the plaintext on
stdin, so neither touches disk and the passphrase never appears in the process
arguments where ps would show it. Verified end to end: round-trip recovers the
mnemonic, the file on disk is real ciphertext, a wrong password is rejected, and
a filesystem watcher polling every 2ms during the operation observed ZERO temp
files.

GHSA-c9jw-9f79-j657 (high) — optional auth nonce enables replay.

The report recommends making the nonce mandatory. That would 401 every
installed browser extension: packages/extension sends a 3-part header with no
nonce. So the extension now sends one (in both the signed message and the
header — an unsigned nonce would let an attacker mint fresh replay-store keys
for a captured signature), while the server keeps accepting both formats.

More importantly, the nonce was not the actual hole. Replay protection is the
shared signature store, and it FAILED OPEN: on any Supabase error the check
returned null and fell through to a per-process in-memory Map. On a
multi-instance deployment a signature spent on instance A is unknown to
instance B, so protection evaporated exactly when the store was unhealthy —
which is when an attacker is most likely probing. It now rejects when the store
is configured but unreachable, keeping the in-memory path only for deployments
with no shared store at all. Four tests cover the state machine.

Tests: 4049 passing (up from 4045). The global test setup now provides Supabase
env vars, since routes legitimately refuse to run without them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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