From 72f78d801bb588110d4ac81382beb935c485d84f Mon Sep 17 00:00:00 2001 From: Sentinel-Bluebuilder Date: Sun, 21 Jun 2026 13:01:02 -0700 Subject: [PATCH 1/2] fix: harden subscriber routes, NaN-safe prices, clientlog limit, deploy secrets Security/correctness fixes from the end-to-end platform audit. server.js: - add-subscriber / add-subscribers: gate both routes with assertPlanOwnership. Both broadcast a subscribe TX paid from THIS operator's wallet, so without an ownership check any authenticated caller could drain the operator balance by passing a foreign planId. Also validate planId as a positive integer and cap the bulk route at 500 addresses (each entry is a serialized broadcast). - safeInt(): coerce chain price strings to a finite integer with a 0/null fallback. Bare parseInt(quote_value) yielded NaN on missing/malformed values and silently poisoned downstream prices and lease totals. Applied to every quote_value parse and the lease totalCost. - /api/_clientlog: add a 120/min rate limit and clamp the tag to 80 chars so a same-origin page can't flood server logs (CSRF middleware already blocks cross-site callers). public/index.html: - txFetch: throw on non-ok HTTP responses instead of returning {} as success. A swallowed 400/500 made callers act on an empty object and fail silently; all 18 call sites already sit in try/catch. Keplr-sign relay handled first. - var(--muted) -> var(--text-muted) (4 Privy surfaces). --muted was never defined, so that text rendered with no color var and was effectively invisible. .dockerignore: - exclude .session-key, privy-wallets.json, auto-grant.json, an.json from the build context so runtime secrets/session keys are never baked into the image. --- .dockerignore | 4 +++ public/index.html | 14 +++++++--- server.js | 68 ++++++++++++++++++++++++++++++++++++----------- 3 files changed, 66 insertions(+), 20 deletions(-) diff --git a/.dockerignore b/.dockerignore index c7de620..15cde63 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,6 +14,10 @@ LICENSE .env.local .wallet.json wallet.json +.session-key +privy-wallets.json +auto-grant.json +an.json my-plans.json nodes-cache.json sellers.json diff --git a/public/index.html b/public/index.html index 2ac4cde..7db2433 100644 --- a/public/index.html +++ b/public/index.html @@ -7164,20 +7164,20 @@

if (!mount) return; _privyMounted = true; - mount.innerHTML = `
Loading email sign-in…
`; + mount.innerHTML = `
Loading email sign-in…
`; let cfg; try { const r = await fetch('/api/wallet/privy-config', { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); cfg = await r.json(); } catch (e) { - mount.innerHTML = `
Could not reach the Privy config endpoint: ${e.message}
`; + mount.innerHTML = `
Could not reach the Privy config endpoint: ${e.message}
`; return; } if (!cfg?.enabled) { mount.innerHTML = ` -
+
Email sign-in is not configured on this server.

To enable: get a Privy app from dashboard.privy.io, @@ -7207,7 +7207,7 @@

Verify and sign in -
+
`; const status = $('privyStatus'); @@ -7531,9 +7531,15 @@

headers: { 'X-Requested-With': 'XMLHttpRequest', ...(opts.headers || {}) }, }); const data = await res.json().catch(() => ({})); + // Keplr-sign relay is a 200 with a special body — handle before the ok-check. if (data && data.mode === 'keplr-sign' && data.signDoc) { return await signAndBroadcast(data.signDoc); } + // Surface HTTP errors instead of returning {} as if the call succeeded — a + // swallowed 400/500 made callers act on an empty object and fail silently. + if (!res.ok) { + throw new Error((data && data.error) || `Request failed (${res.status})`); + } return data; } diff --git a/server.js b/server.js index ed65331..1ba50b5 100644 --- a/server.js +++ b/server.js @@ -596,6 +596,14 @@ loadNodeCacheFromDisk(); // Always kick a fresh scan on startup so the disk seed is replaced with on-chain truth ASAP. runNodeScan().catch(err => console.error('Initial node scan failed:', err.message)); +// Chain price fields arrive as strings ("1000000") but can be missing or +// malformed; bare parseInt then yields NaN which silently poisons every +// downstream price/total. Coerce to a finite integer, falling back to 0. +function safeInt(v, fallback = 0) { + const n = parseInt(v, 10); + return Number.isFinite(n) ? n : fallback; +} + // Adapter: handles both shapes simultaneously — // chain catalog (snake_case: gigabyte_prices, remote_url, no country) // probe-enriched (camelCase: gigabytePrices, remoteUrl, country, city, etc.) @@ -608,8 +616,8 @@ function nodeCacheToAllNodes(raw) { return { address: n.address, remoteUrl: n.remoteUrl || n.remote_url || '', - gbPriceUdvpn: gbPrice ? parseInt(gbPrice.quote_value) : 0, - hrPriceUdvpn: hrPrice ? parseInt(hrPrice.quote_value) : 0, + gbPriceUdvpn: gbPrice ? safeInt(gbPrice.quote_value) : 0, + hrPriceUdvpn: hrPrice ? safeInt(hrPrice.quote_value) : 0, status: 'active', protocol: n.serviceType || null, country: n.country || n.location?.country || null, @@ -1043,7 +1051,7 @@ async function _getPlanStatsImpl(planId) { // null quote_value => price unknown (read failed); keep dvpnAmount null so the // UI shows "—" instead of a fabricated 0. Only a real on-chain value divides. - const quoteNum = price.quote_value != null ? parseInt(price.quote_value) : null; + const quoteNum = price.quote_value != null ? safeInt(price.quote_value, null) : null; return { planId, totalSubscriptions: onchainSubsCount + addedMembers, @@ -1062,7 +1070,7 @@ async function _getPlanStatsImpl(planId) { denom: p.denom, quoteValue: p.quote_value, baseValue: p.base_value, - dvpnAmount: p.denom === 'udvpn' ? (parseInt(p.quote_value || '0') / 1e6) : null, + dvpnAmount: p.denom === 'udvpn' ? (safeInt(p.quote_value) / 1e6) : null, })), renewalPolicy, activeSubs: activeSubs + activeAddedMembers, @@ -1182,8 +1190,8 @@ async function getAllNodeInfo() { const hourlyPrice = (n.hourly_prices || []).find(p => p.denom === 'udvpn'); const gbPrice = (n.gigabyte_prices || []).find(p => p.denom === 'udvpn'); nodeMap[n.address] = { - hourlyUdvpn: hourlyPrice ? parseInt(hourlyPrice.quote_value) : 0, - gbUdvpn: gbPrice ? parseInt(gbPrice.quote_value) : 0, + hourlyUdvpn: hourlyPrice ? safeInt(hourlyPrice.quote_value) : 0, + gbUdvpn: gbPrice ? safeInt(gbPrice.quote_value) : 0, }; } console.log(`[RPC] getAllNodeInfo: ${Object.keys(nodeMap).length} nodes loaded`); @@ -1202,8 +1210,8 @@ async function getAllNodeInfo() { const hourlyPrice = (n.hourly_prices || []).find(p => p.denom === 'udvpn'); const gbPrice = (n.gigabyte_prices || []).find(p => p.denom === 'udvpn'); nodeMap[n.address] = { - hourlyUdvpn: hourlyPrice ? parseInt(hourlyPrice.quote_value) : 0, - gbUdvpn: gbPrice ? parseInt(gbPrice.quote_value) : 0, + hourlyUdvpn: hourlyPrice ? safeInt(hourlyPrice.quote_value) : 0, + gbUdvpn: gbPrice ? safeInt(gbPrice.quote_value) : 0, }; } nextKey = d.pagination?.next_key || null; @@ -1280,7 +1288,7 @@ async function buildLeaseMsg(nodeAddress, hours = 24) { const hp = (nodeInfo.hourly_prices || []).find(p => p.denom === 'udvpn'); if (!hp) throw new Error('Node has no udvpn hourly price'); - const totalCost = (parseInt(hp.quote_value) * hours / 1e6).toFixed(1); + const totalCost = (safeInt(hp.quote_value) * hours / 1e6).toFixed(1); console.log(`[LEASE] Lease msg for ${nodeAddress} ${hours}h (${hp.quote_value} udvpn/hr = ~${totalCost} P2P)`); return { @@ -1889,10 +1897,14 @@ app.post('/api/wallet/privy-login', rateLimit('plogin', 20, 60_000), async (req, // server-side so frontend-only failures (e.g. keplr.signDirect throwing before // the broadcast POST fires) show up in server-out.log. Remove once Keplr create // flow is verified. -app.post('/api/_clientlog', (req, res) => { +// The global CSRF middleware already blocks cross-site callers; rate-limit on +// top so a same-origin page can't flood server logs. Tag is clamped too so a +// caller can't pad each line to the 1500-char data cap. +app.post('/api/_clientlog', rateLimit('clog', 120, 60_000), (req, res) => { try { const { tag, data } = req.body || {}; - console.log('[clientlog] %s %s', tag || '?', JSON.stringify(data ?? {}).slice(0, 1500)); + const safeTag = String(tag ?? '?').slice(0, 80); + console.log('[clientlog] %s %s', safeTag, JSON.stringify(data ?? {}).slice(0, 1500)); } catch (e) { console.warn('[clientlog] failed:', e.message); } @@ -2967,10 +2979,21 @@ app.post('/api/plan/add-subscriber', async (req, res) => { if (!address || !String(address).startsWith('sent1')) { return res.status(400).json({ error: 'valid sent1... address required' }); } - console.log(`Adding subscriber ${address} to plan ${planId} via self-subscribe + share...`); - const result = await _addSubscriberViaShare(parseInt(planId), address, { denom, allocBytes }); + + const planIdNum = parseInt(planId, 10); + if (!Number.isFinite(planIdNum) || planIdNum <= 0) return res.status(400).json({ error: 'planId must be a positive integer' }); + + // Ownership gate: _addSubscriberViaShare broadcasts a subscribe TX paid from + // THIS operator's wallet, so reject add-subscriber against a plan we don't own + // — otherwise any authenticated caller could drain our balance via a foreign + // planId. + const ownErr = await assertPlanOwnership(planIdNum); + if (ownErr) return res.status(ownErr.status).json({ error: ownErr.error }); + + console.log(`Adding subscriber ${address} to plan ${planIdNum} via self-subscribe + share...`); + const result = await _addSubscriberViaShare(planIdNum, address, { denom, allocBytes }); console.log(`Added ${address}: sub=${result.subscriptionId} subTx=${result.subTx} shareTx=${result.shareTx}`); - invalidatePlanSubs(parseInt(planId, 10)); + invalidatePlanSubs(planIdNum); res.json(result); } catch (err) { if (relayKeplrSign(err, res)) return; @@ -2984,23 +3007,36 @@ app.post('/api/plan/add-subscribers', async (req, res) => { try { const { planId, addresses, denom, allocBytes } = req.body; if (!planId) return res.status(400).json({ error: 'planId required' }); + + const planIdNum = parseInt(planId, 10); + if (!Number.isFinite(planIdNum) || planIdNum <= 0) return res.status(400).json({ error: 'planId must be a positive integer' }); + const list = Array.isArray(addresses) ? addresses.map(a => String(a).trim()).filter(a => a.startsWith('sent1')) : []; if (!list.length) return res.status(400).json({ error: 'addresses[] with valid sent1... entries required' }); + // Cap the batch: each entry triggers a serialized subscribe+share broadcast + // from this wallet, so an unbounded list would pin the broadcast queue for + // minutes and block every other caller (and risk gas exhaustion). + if (list.length > 500) return res.status(400).json({ error: 'Maximum 500 addresses per request' }); + + // Ownership gate: every entry broadcasts a subscribe TX from THIS operator's + // wallet, so reject bulk-add against a plan we don't own. + const ownErr = await assertPlanOwnership(planIdNum); + if (ownErr) return res.status(ownErr.status).json({ error: ownErr.error }); const results = []; // Sequential — each address needs its own subscribe+share, and back-to-back // signing from one wallet must serialize to avoid account-sequence collisions. for (const addr of list) { try { - const r = await _addSubscriberViaShare(parseInt(planId), addr, { denom, allocBytes }); + const r = await _addSubscriberViaShare(planIdNum, addr, { denom, allocBytes }); results.push({ address: addr, ok: true, subscriptionId: r.subscriptionId, reused: r.reused, subTx: r.subTx, shareTx: r.shareTx }); } catch (e) { results.push({ address: addr, ok: false, error: parseChainError(e.message) }); } } - invalidatePlanSubs(parseInt(planId, 10)); + invalidatePlanSubs(planIdNum); const added = results.filter(r => r.ok).length; res.json({ ok: added > 0, added, failed: results.length - added, results }); } catch (err) { From 082bcd9d562cc5c5d15728c08590648da3710997 Mon Sep 17 00:00:00 2001 From: Sentinel-Bluebuilder Date: Sun, 21 Jun 2026 13:54:37 -0700 Subject: [PATCH 2/2] fix: tighten subscriber-address validation, cap batch size, escape Privy error Follow-up hardening folded into the audit PR: - add-subscriber / add-subscribers: validate addresses against the full bech32 SENT_ADDR_RE (sent1 + 38 charset chars) instead of a bare startsWith('sent1') prefix check, which accepted malformed/overlong strings that only fail later at broadcast. - batch-link / batch-unlink: cap the node list at 100 per request. Each node adds messages to a single TX; an unbounded list can exceed the block gas limit and saturate the RPC pool. - Privy config error: escape the interpolated error text with escapeHtml before injecting into innerHTML, closing a reflected-XSS vector on the error message. --- public/index.html | 2 +- server.js | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/public/index.html b/public/index.html index 7db2433..f1123e9 100644 --- a/public/index.html +++ b/public/index.html @@ -7171,7 +7171,7 @@

const r = await fetch('/api/wallet/privy-config', { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); cfg = await r.json(); } catch (e) { - mount.innerHTML = `
Could not reach the Privy config endpoint: ${e.message}
`; + mount.innerHTML = `
Could not reach the Privy config endpoint: ${escapeHtml(e && e.message || String(e))}
`; return; } diff --git a/server.js b/server.js index 1ba50b5..7aeced0 100644 --- a/server.js +++ b/server.js @@ -2976,7 +2976,7 @@ app.post('/api/plan/add-subscriber', async (req, res) => { try { const { planId, address, denom, allocBytes } = req.body; if (!planId) return res.status(400).json({ error: 'planId required' }); - if (!address || !String(address).startsWith('sent1')) { + if (!address || !SENT_ADDR_RE.test(String(address).trim())) { return res.status(400).json({ error: 'valid sent1... address required' }); } @@ -3012,7 +3012,7 @@ app.post('/api/plan/add-subscribers', async (req, res) => { if (!Number.isFinite(planIdNum) || planIdNum <= 0) return res.status(400).json({ error: 'planId must be a positive integer' }); const list = Array.isArray(addresses) - ? addresses.map(a => String(a).trim()).filter(a => a.startsWith('sent1')) + ? addresses.map(a => String(a).trim()).filter(a => SENT_ADDR_RE.test(a)) : []; if (!list.length) return res.status(400).json({ error: 'addresses[] with valid sent1... entries required' }); // Cap the batch: each entry triggers a serialized subscribe+share broadcast @@ -3587,6 +3587,9 @@ app.post('/api/plan-manager/batch-link', async (req, res) => { const planIdNum = parseInt(planId, 10); if (!Number.isFinite(planIdNum) || planIdNum <= 0) return res.status(400).json({ error: 'planId must be a positive integer' }); const addrs = [...new Set(nodeAddresses)]; + // Cap batch size to keep one TX under the chain block-gas limit and avoid + // saturating the RPC pool. 100 keeps a single bundled TX well under limit. + if (addrs.length > 100) return res.status(400).json({ error: 'Maximum 100 nodes per batch' }); const badAddr = addrs.find(a => !NODE_ADDR_RE.test(a)); if (badAddr) return res.status(400).json({ error: `invalid node address: ${badAddr}` }); const ownErr = await assertPlanOwnership(planIdNum); @@ -3726,6 +3729,9 @@ app.post('/api/plan-manager/batch-unlink', async (req, res) => { const planIdNum = parseInt(planId, 10); if (!Number.isFinite(planIdNum) || planIdNum <= 0) return res.status(400).json({ error: 'planId must be a positive integer' }); const addrs = [...new Set(nodeAddresses)]; + // Cap batch size to keep one TX under the chain block-gas limit and avoid + // saturating the RPC pool. 100 keeps a single bundled TX well under limit. + if (addrs.length > 100) return res.status(400).json({ error: 'Maximum 100 nodes per batch' }); const badAddr = addrs.find(a => !NODE_ADDR_RE.test(a)); if (badAddr) return res.status(400).json({ error: `invalid node address: ${badAddr}` }); const ownErr = await assertPlanOwnership(planIdNum);