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..f1123e9 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: ${escapeHtml(e && e.message || String(e))}
`; 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..7aeced0 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); } @@ -2964,13 +2976,24 @@ 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' }); } - 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')) + ? 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 + // 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) { @@ -3551,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); @@ -3690,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);