From 0bb7ab45344068ed8ea3cc77ad9f7c2a038bb45f Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 22 Aug 2026 08:08:04 -0700 Subject: nwc: enforce per-connection spend budgets + flag-gated incident containment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:, which let an authenticated user overwrite another connection's config, and validate budget/fee/renewal inputs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7 --- routes/users.ts | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) (limited to 'routes') 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:`. 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 -- cgit v1.2.3