diff options
| author | Adam Soltys <[email protected]> | 2026-08-22 08:08:04 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-22 08:08:04 -0700 |
| commit | 0bb7ab45344068ed8ea3cc77ad9f7c2a038bb45f (patch) | |
| tree | f288d01fdc82b2df7ba9fd3f6ef6f8792ef49880 /routes | |
| parent | 38bfeeecdafb678d2a495c2865d1ff4dd6255ac9 (diff) | |
| download | coinos-server-0bb7ab45344068ed8ea3cc77ad9f7c2a038bb45f.tar.gz coinos-server-0bb7ab45344068ed8ea3cc77ad9f7c2a038bb45f.zip | |
nwc: enforce per-connection spend budgets + flag-gated incident containment
Thread the remaining allowance through every spend path (pay_invoice, pay,
pay_keysend, internal transfer) as maxTotal, and have debit() assert the spend
stays within it after it reloads the invoice and captures the tip — so a
delegated NWC credential can no longer exceed its configured budget, and a
mutated tip can't slip a small-budget check and drain via the internal path.
checkBudget now returns the remaining allowance alongside any error; a blank
budget is treated as deliberately unlimited and a positive budget requires a
known renewal period.
Add operator-controlled containment for a suspected credential disclosure, all
gated behind Redis flags with no hardcoded scope:
nwc:disabled fail every method closed
nwc:credential-cutoff retire connections created before an epoch
nwc:quarantined block an individual disclosed connection
nwc:guard:dormant-keysend quarantine the first keysend on a long-idle app
Fix an ownership check that read the bare pubkey instead of app:<pubkey>, which
let an authenticated user overwrite another connection's config, and validate
budget/fee/renewal inputs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
Diffstat (limited to 'routes')
| -rw-r--r-- | routes/users.ts | 32 |
1 files changed, 30 insertions, 2 deletions
diff --git a/routes/users.ts b/routes/users.ts index 0a3fa415..e301d708 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -1115,6 +1115,7 @@ export default { (a, b) => a + (Math.abs(Number.parseInt(b.amount || 0)) + + Number.parseInt(b.tip || 0) + Number.parseInt(b.fee || 0) + Number.parseInt(b.ourfee || 0)), 0, @@ -1139,10 +1140,37 @@ export default { const { user } = req; const uid = user.id; - let app = await g(pubkey); + if (secret) pubkey = getPublicKey(hexToBytes(secret)); + // App records live under `app:<pubkey>`. Looking up the bare pubkey made + // this ownership check ineffective for normal NWC keys and allowed an + // authenticated user to overwrite another connection's configuration. + let app = await g(`app:${pubkey}`); if (app && uid !== app.uid) fail("Unauthorized"); - if (secret) pubkey = getPublicKey(hexToBytes(secret)); + + const validRenewals = new Set([ + "daily", + "weekly", + "monthly", + "yearly", + "never", + ]); + if (budget_renewal && !validRenewals.has(budget_renewal)) + fail("Invalid budget renewal period"); + + for (const [label, value] of [ + ["spending budget", max_amount], + ["maximum fee", max_fee], + ]) { + if ( + value !== undefined && + value !== null && + value !== "" && + (!Number.isFinite(Number(value)) || Number(value) < 0) + ) + fail(`Invalid ${label}`); + } + // Notifications are opt-in (default off): most users don't consume them and // publishing payment_sent/payment_received for every app would flood the // relay. A client enables them by sending notify:"true" when creating the |
