From b0a8ca6e43630e2347d0dc4d59b9fd950c407ae3 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Fri, 24 Apr 2026 22:50:41 -0700 Subject: [PATCH 1/3] fix(security): add backend abuse guards Rate-limit receipt reconciliation, require explicit first-write vault preconditions, and reject unknown masternode collateral outpoints before calling voteraw. Made-with: Cursor --- lib/appFactory.js | 3 ++ lib/vaults.js | 8 +++-- lib/vaults.test.js | 15 ++++----- middleware/rateLimit.js | 23 ++++++++++++++ middleware/rateLimit.test.js | 15 +++++++++ routes/gov.js | 59 ++++++++++++++++++++++++++++++++++-- tests/gov.routes.test.js | 51 ++++++++++++++++++++++++++++++- tests/vault.routes.test.js | 10 ++++++ 8 files changed, 170 insertions(+), 14 deletions(-) diff --git a/lib/appFactory.js b/lib/appFactory.js index 6cfa923..d9c9acb 100644 --- a/lib/appFactory.js +++ b/lib/appFactory.js @@ -178,12 +178,14 @@ function mountAuthAndVault( register: rateLimiters.disabled(), verifyEmail: rateLimiters.disabled(), vote: rateLimiters.disabled(), + reconcile: rateLimiters.disabled(), } : { login: rateLimiters.loginLimiter(), register: rateLimiters.registerLimiter(), verifyEmail: rateLimiters.verifyEmailLimiter(), vote: rateLimiters.voteLimiter(), + reconcile: rateLimiters.reconcileLimiter(), }; app.use( @@ -233,6 +235,7 @@ function mountAuthAndVault( ? invalidateCurrentVotes : null, voteLimiter: limiters.vote, + reconcileLimiter: limiters.reconcile, nowMs: now, }) ); diff --git a/lib/vaults.js b/lib/vaults.js index b002730..bface4c 100644 --- a/lib/vaults.js +++ b/lib/vaults.js @@ -70,9 +70,11 @@ function createVaultsRepo(db, opts = {}) { const existing = selectByUser.get(userId); if (!existing) { - // Accept either '*' (explicit "no existing") or absent. Any concrete - // etag here is a client bug / stale read. - if (ifMatch && ifMatch !== '*') throw mkErr('etag_mismatch'); + // First writes must be explicit too. The frontend already sends + // If-Match: *, and requiring it keeps blind creates from silently + // bypassing the same precondition contract as updates. + if (!ifMatch) throw mkErr('etag_required'); + if (ifMatch !== '*') throw mkErr('etag_mismatch'); const etag = etagFor(blob); try { insertFirst.run(userId, blob, etag, now()); diff --git a/lib/vaults.test.js b/lib/vaults.test.js index 1100e5f..9ba5119 100644 --- a/lib/vaults.test.js +++ b/lib/vaults.test.js @@ -34,7 +34,7 @@ describe('vaults repo', () => { test('first put creates a vault and returns etag (no saltV — it lives on users now)', () => { const uid = seedUser(db); - const result = vaults.put(uid, { blob: 'ciphertextA' }); + const result = vaults.put(uid, { blob: 'ciphertextA', ifMatch: '*' }); expect(result).toEqual({ etag: etagFor('ciphertextA') }); // Migration 004 removed the salt_v column from vaults; make sure the // repo's response shape matches and no stray saltV leaks back. @@ -46,8 +46,9 @@ describe('vaults repo', () => { expect(got.etag).toBe(etagFor('ciphertextA')); }); - test('first put accepts ifMatch "*" but rejects any concrete etag', () => { + test('first put requires ifMatch "*" and rejects any concrete etag', () => { const uid = seedUser(db); + expect(() => vaults.put(uid, { blob: 'x' })).toThrow(/etag_required/); expect(() => vaults.put(uid, { blob: 'x', ifMatch: 'abc' })).toThrow( /etag_mismatch/ ); @@ -56,7 +57,7 @@ describe('vaults repo', () => { test('subsequent put requires ifMatch to equal current etag', () => { const uid = seedUser(db); - const first = vaults.put(uid, { blob: 'A' }); + const first = vaults.put(uid, { blob: 'A', ifMatch: '*' }); expect(() => vaults.put(uid, { blob: 'B' })).toThrow(/etag_required/); expect(() => vaults.put(uid, { blob: 'B', ifMatch: 'wrong' })).toThrow( @@ -76,7 +77,7 @@ describe('vaults repo', () => { // observed etag. Recovery from a truly corrupted local state goes // through an explicit "reset vault" flow instead. const uid = seedUser(db); - vaults.put(uid, { blob: 'A' }); + vaults.put(uid, { blob: 'A', ifMatch: '*' }); expect(() => vaults.put(uid, { blob: 'C', ifMatch: '*' })).toThrow( /etag_mismatch/ ); @@ -108,7 +109,7 @@ describe('vaults repo', () => { // statement shape directly so the guarantee survives even a repo // refactor that someday skipped the pre-read. const uid = seedUser(db); - const first = vaults.put(uid, { blob: 'A' }); + const first = vaults.put(uid, { blob: 'A', ifMatch: '*' }); const stmt = db.prepare( `UPDATE vaults @@ -129,7 +130,7 @@ describe('vaults repo', () => { test('simulated-race: stale ifMatch against a newer row is rejected', () => { const uid = seedUser(db); - const first = vaults.put(uid, { blob: 'A' }); + const first = vaults.put(uid, { blob: 'A', ifMatch: '*' }); // A concurrent writer advances the state. vaults.put(uid, { blob: 'B', ifMatch: first.etag }); @@ -174,7 +175,7 @@ describe('vaults repo', () => { // a "no such column" at query-prepare time and every vault GET // turns into a 500. const uid = seedUser(db); - vaults.put(uid, { blob: 'hello' }); + vaults.put(uid, { blob: 'hello', ifMatch: '*' }); const row = vaults.get(uid); expect(Object.keys(row).sort()).toEqual(['blob', 'etag', 'updatedAt']); }); diff --git a/middleware/rateLimit.js b/middleware/rateLimit.js index 93ce8f4..6619a6f 100644 --- a/middleware/rateLimit.js +++ b/middleware/rateLimit.js @@ -77,6 +77,15 @@ function voteKey(req) { return `vote|ip|${ipBucket(req)}`; } +// Per-user bucket for /gov/receipts/reconcile. This endpoint may issue +// gobject_getcurrentvotes RPCs, so it needs its own budget instead of +// sharing /gov/vote's relay budget. +function reconcileKey(req) { + const uid = req.user && req.user.id != null ? String(req.user.id) : null; + if (uid) return `reconcile|u${uid}`; + return `reconcile|ip|${ipBucket(req)}`; +} + function loginLimiter() { return rateLimit({ windowMs: 15 * MINUTE, @@ -135,6 +144,18 @@ function voteLimiter() { }); } +function reconcileLimiter() { + return rateLimit({ + windowMs: 60 * MINUTE, + max: 30, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: reconcileKey, + message: { error: 'too_many_reconcile_requests' }, + handler: trippedHandler('reconcile'), + }); +} + function disabled() { return (_req, _res, next) => next(); } @@ -144,10 +165,12 @@ module.exports = { registerLimiter, verifyEmailLimiter, voteLimiter, + reconcileLimiter, disabled, // Exported for direct unit testing. loginKey, registerKey, verifyEmailKey, voteKey, + reconcileKey, }; diff --git a/middleware/rateLimit.test.js b/middleware/rateLimit.test.js index a129a7c..aadc84e 100644 --- a/middleware/rateLimit.test.js +++ b/middleware/rateLimit.test.js @@ -1,6 +1,7 @@ const { loginKey, registerKey, + reconcileKey, voteKey, } = require('./rateLimit'); @@ -83,4 +84,18 @@ describe('rate-limit key generators', () => { expect(a).toMatch(/^vote\|ip\|1\.2\.3\.4$/); }); }); + + describe('reconcileKey', () => { + test('buckets by authenticated user.id when present', () => { + const a = reconcileKey(mk({}, '1.2.3.4', { id: 42 })); + const b = reconcileKey(mk({}, '9.9.9.9', { id: 42 })); + expect(a).toBe(b); + expect(a).toBe('reconcile|u42'); + }); + + test('falls back to IP bucket when the user is missing', () => { + const a = reconcileKey(mk({}, '1.2.3.4')); + expect(a).toMatch(/^reconcile\|ip\|1\.2\.3\.4$/); + }); + }); }); diff --git a/routes/gov.js b/routes/gov.js index 2ee88ae..eee1dd1 100644 --- a/routes/gov.js +++ b/routes/gov.js @@ -19,6 +19,21 @@ const HEX64 = /^[0-9a-f]{64}$/i; // a reconcile with `?refresh=1`. const DEFAULT_RECEIPTS_FRESHNESS_MS = 2 * 60 * 1000; +function knownOutpointSet(masternodes) { + const out = new Set(); + for (const mn of Array.isArray(masternodes) ? masternodes : []) { + if ( + mn && + typeof mn.collateralHash === 'string' && + HEX64.test(mn.collateralHash) && + Number.isInteger(mn.collateralIndex) + ) { + out.add(`${mn.collateralHash.toLowerCase()}:${mn.collateralIndex}`); + } + } + return out; +} + // Governance HTTP surface. // // POST /gov/mns/lookup -> { matches: [{ votingaddress, proTxHash, @@ -66,6 +81,8 @@ const DEFAULT_RECEIPTS_FRESHNESS_MS = 2 * 60 * 1000; // - nowMs: injectable clock for deterministic time-window tests. // - voteLimiter: an express-rate-limit middleware (or a no-op in // tests). Mounted only on POST /gov/vote. +// - reconcileLimiter: same shape, mounted only on POST +// /gov/receipts/reconcile. function createGovRouter({ masternodesProvider, @@ -77,6 +94,7 @@ function createGovRouter({ invalidateCurrentVotes = null, receiptsFreshnessMs = DEFAULT_RECEIPTS_FRESHNESS_MS, voteLimiter = (_req, _res, next) => next(), + reconcileLimiter = (_req, _res, next) => next(), nowMs = () => Date.now(), }) { if (typeof masternodesProvider !== 'function') { @@ -154,10 +172,44 @@ function createGovRouter({ const parsed = validateVoteBody(req.body, { nowMs: nowMs() }); if (!parsed.ok) return res.status(400).json({ error: parsed.error }); try { - const out = await relayVotes(voteRaw, parsed, { - receipts, - userId: req.user && req.user.id, + const knownOutpoints = knownOutpointSet(masternodesProvider() || []); + const relayEntries = []; + const relayIndexes = []; + const results = new Array(parsed.entries.length); + parsed.entries.forEach((entry, index) => { + const key = `${entry.collateralHash}:${entry.collateralIndex}`; + if (!knownOutpoints.has(key)) { + results[index] = { + collateralHash: entry.collateralHash, + collateralIndex: entry.collateralIndex, + ok: false, + error: 'mn_not_found', + }; + return; + } + relayIndexes.push(index); + relayEntries.push(entry); }); + + if (relayEntries.length > 0) { + const relayed = await relayVotes( + voteRaw, + { ...parsed, entries: relayEntries }, + { + receipts, + userId: req.user && req.user.id, + } + ); + relayed.results.forEach((result, index) => { + results[relayIndexes[index]] = result; + }); + } + + const out = { + accepted: results.filter((r) => r && r.ok).length, + rejected: results.filter((r) => r && !r.ok).length, + results, + }; // Invalidate the cached gobject_getcurrentvotes snapshot for // this proposal so the next /gov/receipts read observes the // votes we just relayed (or the chain state that followed @@ -284,6 +336,7 @@ function createGovRouter({ '/receipts/reconcile', sessionMw.requireAuth, csrfMw.require, + reconcileLimiter, async (req, res) => { if (!receipts) { return res.json({ receipts: [], reconciled: false }); diff --git a/tests/gov.routes.test.js b/tests/gov.routes.test.js index a753ac9..25132f9 100644 --- a/tests/gov.routes.test.js +++ b/tests/gov.routes.test.js @@ -53,7 +53,10 @@ async function loggedInAgent(ctx, email = 'user@example.com') { // mutable `state` object whose `masternodes` property can be swapped // between requests. function buildApp({ - masternodes = [], + masternodes = [ + { collateralHash: H2, collateralIndex: 0 }, + { collateralHash: H3, collateralIndex: 1 }, + ], voteRaw, getCurrentVotes, invalidateCurrentVotes, @@ -324,6 +327,52 @@ describe('POST /gov/vote', () => { } }); + test('prechecks collateral outpoints against the current masternode cache before voteraw', async () => { + const { ctx, calls } = buildApp({ + masternodes: [{ collateralHash: H2, collateralIndex: 0 }], + }); + try { + const { agent, csrf } = await loggedInAgent(ctx); + const res = await agent + .post('/gov/vote') + .set('X-CSRF-Token', csrf) + .send(validVoteBody()); + expect(res.status).toBe(200); + expect(res.body.accepted).toBe(1); + expect(res.body.rejected).toBe(1); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe(H2); + expect(res.body.results[1]).toMatchObject({ + collateralHash: H3, + collateralIndex: 1, + ok: false, + error: 'mn_not_found', + }); + } finally { + ctx.db.close(); + } + }); + + test('does not call voteraw when the masternode cache has no matching outpoints', async () => { + const { ctx, calls } = buildApp({ masternodes: [] }); + try { + const { agent, csrf } = await loggedInAgent(ctx); + const res = await agent + .post('/gov/vote') + .set('X-CSRF-Token', csrf) + .send(validVoteBody()); + expect(res.status).toBe(200); + expect(res.body.accepted).toBe(0); + expect(res.body.rejected).toBe(2); + expect(res.body.results.every((r) => r.error === 'mn_not_found')).toBe( + true + ); + expect(calls).toHaveLength(0); + } finally { + ctx.db.close(); + } + }); + test('400 invalid_proposal_hash when proposalHash is malformed', async () => { const { ctx } = buildApp(); try { diff --git a/tests/vault.routes.test.js b/tests/vault.routes.test.js index ece469a..72f4539 100644 --- a/tests/vault.routes.test.js +++ b/tests/vault.routes.test.js @@ -74,6 +74,16 @@ describe('vault routes', () => { expect(res.body.saltV).toBeUndefined(); }); + test('first PUT /vault requires explicit If-Match wildcard', async () => { + const { agent, csrf } = await loggedInAgent(ctx); + const res = await agent + .put('/vault') + .set('X-CSRF-Token', csrf) + .send({ blob: 'ciphertext-1' }); + expect(res.status).toBe(428); + expect(res.body.error).toBe('if_match_required'); + }); + test('subsequent PUT requires matching If-Match', async () => { const { agent, csrf } = await loggedInAgent(ctx); const first = await agent From c9a09f70ed5fa5f7a6653099101c35f8a8b34a5a Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Fri, 24 Apr 2026 23:01:44 -0700 Subject: [PATCH 2/3] fix: preserve vote relay during cache warmup Keep collateral outpoint prechecks active when the masternode cache is populated, but fail open to Core while the cache is empty so tracker lag cannot block valid votes. Made-with: Cursor --- routes/gov.js | 2 +- tests/gov.routes.test.js | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/routes/gov.js b/routes/gov.js index eee1dd1..e37029d 100644 --- a/routes/gov.js +++ b/routes/gov.js @@ -178,7 +178,7 @@ function createGovRouter({ const results = new Array(parsed.entries.length); parsed.entries.forEach((entry, index) => { const key = `${entry.collateralHash}:${entry.collateralIndex}`; - if (!knownOutpoints.has(key)) { + if (knownOutpoints.size > 0 && !knownOutpoints.has(key)) { results[index] = { collateralHash: entry.collateralHash, collateralIndex: entry.collateralIndex, diff --git a/tests/gov.routes.test.js b/tests/gov.routes.test.js index 25132f9..c1dd508 100644 --- a/tests/gov.routes.test.js +++ b/tests/gov.routes.test.js @@ -353,7 +353,7 @@ describe('POST /gov/vote', () => { } }); - test('does not call voteraw when the masternode cache has no matching outpoints', async () => { + test('fails open to voteraw while the masternode cache is empty or warming', async () => { const { ctx, calls } = buildApp({ masternodes: [] }); try { const { agent, csrf } = await loggedInAgent(ctx); @@ -362,12 +362,9 @@ describe('POST /gov/vote', () => { .set('X-CSRF-Token', csrf) .send(validVoteBody()); expect(res.status).toBe(200); - expect(res.body.accepted).toBe(0); - expect(res.body.rejected).toBe(2); - expect(res.body.results.every((r) => r.error === 'mn_not_found')).toBe( - true - ); - expect(calls).toHaveLength(0); + expect(res.body.accepted).toBe(2); + expect(res.body.rejected).toBe(0); + expect(calls).toHaveLength(2); } finally { ctx.db.close(); } From 92ef7f273ad60aae25e40c9929f7c6fa8c099429 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Fri, 24 Apr 2026 23:29:41 -0700 Subject: [PATCH 3/3] fix: make vote prechecks cache freshness aware Track masternode snapshot freshness and only hard-reject unknown collateral outpoints when the cache is fresh, falling back to Core during stale tracker windows. Made-with: Cursor --- data/dataStore.js | 3 ++- routes/gov.js | 38 ++++++++++++++++++++++++++++++++--- server.js | 11 ++++++---- services/masternodeTracker.js | 1 + tests/gov.routes.test.js | 22 ++++++++++++++++++++ 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/data/dataStore.js b/data/dataStore.js index fe8aed7..7495e64 100644 --- a/data/dataStore.js +++ b/data/dataStore.js @@ -46,5 +46,6 @@ module.exports = { sb5EstDate: 0, // Masternodes array (will be populated) - masternodesArr: [] + masternodesArr: [], + masternodesUpdatedAt: 0 }; \ No newline at end of file diff --git a/routes/gov.js b/routes/gov.js index e37029d..67da966 100644 --- a/routes/gov.js +++ b/routes/gov.js @@ -18,6 +18,26 @@ const HEX64 = /^[0-9a-f]{64}$/i; // say stale" race. Callers that want stricter freshness can force // a reconcile with `?refresh=1`. const DEFAULT_RECEIPTS_FRESHNESS_MS = 2 * 60 * 1000; +const DEFAULT_MASTERNODE_CACHE_MAX_AGE_MS = 30 * 1000; + +function readMasternodeSnapshot(value, nowMs, maxAgeMs) { + const masternodes = Array.isArray(value) + ? value + : value && Array.isArray(value.masternodes) + ? value.masternodes + : []; + if (Array.isArray(value)) { + return { masternodes, fresh: true }; + } + const updatedAt = value && Number.isInteger(value.updatedAt) + ? value.updatedAt + : 0; + const age = nowMs - updatedAt; + return { + masternodes, + fresh: updatedAt > 0 && age >= 0 && age <= maxAgeMs, + }; +} function knownOutpointSet(masternodes) { const out = new Set(); @@ -93,6 +113,7 @@ function createGovRouter({ getCurrentVotes = null, invalidateCurrentVotes = null, receiptsFreshnessMs = DEFAULT_RECEIPTS_FRESHNESS_MS, + masternodeCacheMaxAgeMs = DEFAULT_MASTERNODE_CACHE_MAX_AGE_MS, voteLimiter = (_req, _res, next) => next(), reconcileLimiter = (_req, _res, next) => next(), nowMs = () => Date.now(), @@ -131,8 +152,12 @@ function createGovRouter({ (req, res) => { const parsed = validateLookupBody(req.body); if (!parsed.ok) return res.status(400).json({ error: parsed.error }); - const mnArr = masternodesProvider() || []; - const matches = lookupMatches(mnArr, parsed.votingAddresses); + const snapshot = readMasternodeSnapshot( + masternodesProvider(), + nowMs(), + masternodeCacheMaxAgeMs + ); + const matches = lookupMatches(snapshot.masternodes, parsed.votingAddresses); return res.json({ matches }); } ); @@ -172,7 +197,14 @@ function createGovRouter({ const parsed = validateVoteBody(req.body, { nowMs: nowMs() }); if (!parsed.ok) return res.status(400).json({ error: parsed.error }); try { - const knownOutpoints = knownOutpointSet(masternodesProvider() || []); + const snapshot = readMasternodeSnapshot( + masternodesProvider(), + nowMs(), + masternodeCacheMaxAgeMs + ); + const knownOutpoints = snapshot.fresh + ? knownOutpointSet(snapshot.masternodes) + : new Set(); const relayEntries = []; const relayIndexes = []; const results = new Array(parsed.entries.length); diff --git a/server.js b/server.js index e54aaa3..0b295ec 100644 --- a/server.js +++ b/server.js @@ -327,12 +327,15 @@ mountAuthAndVault(app, { mailer, baseUrl: process.env.BASE_URL || 'http://localhost:3001', frontendUrl: PUBLIC_BASE_URL, - // Read the live tracker array fresh on every call rather than + // Read the live tracker snapshot fresh on every call rather than // snapshotting it here — the tracker REASSIGNS `masternodesArr` // every 10s (`data.masternodesArr = []`), so a captured reference - // would go stale after the first refresh. `dataStore.masternodesArr` - // is a property access and therefore always returns the current value. - masternodesProvider: () => dataStore.masternodesArr, + // would go stale after the first refresh. `masternodesUpdatedAt` + // lets /gov/vote avoid hard-rejecting outpoints from a stale cache. + masternodesProvider: () => ({ + masternodes: dataStore.masternodesArr, + updatedAt: dataStore.masternodesUpdatedAt, + }), voteRaw: (collateralHash, collateralIndex, governanceHash, signal, outcome, time, voteSig) => rpcServices(client.callRpc) .voteRaw( diff --git a/services/masternodeTracker.js b/services/masternodeTracker.js index 07bc3a0..ae028f9 100644 --- a/services/masternodeTracker.js +++ b/services/masternodeTracker.js @@ -74,6 +74,7 @@ setInterval(() => { } data.masternodesArr.sort((a, b) => b.lastpaidtime - a.lastpaidtime); + data.masternodesUpdatedAt = Date.now(); data.highestMN = Math.max(...Object.values(data.mapData).map(e => e.masternodes || 0)); diff --git a/tests/gov.routes.test.js b/tests/gov.routes.test.js index c1dd508..e2d421b 100644 --- a/tests/gov.routes.test.js +++ b/tests/gov.routes.test.js @@ -370,6 +370,28 @@ describe('POST /gov/vote', () => { } }); + test('fails open to voteraw when the masternode cache snapshot is stale', async () => { + const { ctx, calls } = buildApp({ + masternodes: { + masternodes: [{ collateralHash: H2, collateralIndex: 0 }], + updatedAt: Date.now() - 60_000, + }, + }); + try { + const { agent, csrf } = await loggedInAgent(ctx); + const res = await agent + .post('/gov/vote') + .set('X-CSRF-Token', csrf) + .send(validVoteBody()); + expect(res.status).toBe(200); + expect(res.body.accepted).toBe(2); + expect(res.body.rejected).toBe(0); + expect(calls).toHaveLength(2); + } finally { + ctx.db.close(); + } + }); + test('400 invalid_proposal_hash when proposalHash is malformed', async () => { const { ctx } = buildApp(); try {