aboutsummaryrefslogtreecommitdiffstats
path: root/routes
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-22 08:08:04 -0700
committerAdam Soltys <[email protected]>2026-08-22 08:08:04 -0700
commit0bb7ab45344068ed8ea3cc77ad9f7c2a038bb45f (patch)
treef288d01fdc82b2df7ba9fd3f6ef6f8792ef49880 /routes
parent38bfeeecdafb678d2a495c2865d1ff4dd6255ac9 (diff)
downloadcoinos-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.ts32
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