Skip to content

Commit 458540d

Browse files
fix(events): strip unconsumed wallet internals (balanceUdvpn/spentUdvpn/runGranter) from all admin SSE state payloads
The admin SSE handler (GET /api/events) stripped runGranter only on 'state' events; 'result' and 'progress' events forwarded the FULL state, leaking balanceUdvpn, spentUdvpn, and runGranter — none of which admin.html reads. (It DOES read walletAddress + balance for the wallet header, kept current via Object.assign(state, msg.state) on progress/result events, so those must keep flowing.) Add a top-level sanitizeAdminState(s) helper that rest-spreads a clone with balanceUdvpn/spentUdvpn/runGranter removed (non-mutating — the global state is never touched), and refactor the live-event handler so ANY event carrying data.state is sanitized uniformly (state/result/progress). trimRowDiag still applies to data.result (result events) and data.results[] (state events). The init send is unchanged (its own 5-field destructure). Tests: add sanitizeAdminState unit tests (strip the three, keep walletAddress/balance/status/totalNodes, non-mutating, non-object passthrough) plus a string-level wiring assertion that the /api/events handler body references sanitizeAdminState and trimRowDiag on both the result and results paths, so a future edit dropping a strip fails loudly.
1 parent 83cbbdc commit 458540d

2 files changed

Lines changed: 96 additions & 13 deletions

File tree

server.js

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2291,6 +2291,19 @@ function trimRowDiag(r) {
22912291
};
22922292
}
22932293

2294+
// Strip the state fields the admin browser never consumes from a LIVE event's
2295+
// state payload. admin.html DOES read walletAddress + balance (the wallet
2296+
// header in applyState, kept current via `Object.assign(state, msg.state)` on
2297+
// progress/result events) — those MUST keep flowing. But balanceUdvpn and
2298+
// spentUdvpn are read NOWHERE in admin.html, and runGranter (subscription
2299+
// granter address) is server-internal. Non-mutating: rest-spread a clone so the
2300+
// global `state` is never touched.
2301+
function sanitizeAdminState(s) {
2302+
if (!s || typeof s !== 'object') return s;
2303+
const { balanceUdvpn, spentUdvpn, runGranter, ...rest } = s;
2304+
return rest;
2305+
}
2306+
22942307
const rlAdminSse = sseLimit({ maxPerIp: 10, bucket: 'admin-sse' });
22952308
app.get('/api/events', adminOnly, rlAdminSse, (req, res) => {
22962309
res.setHeader('Content-Type', 'text/event-stream');
@@ -2310,23 +2323,23 @@ app.get('/api/events', adminOnly, rlAdminSse, (req, res) => {
23102323
const ADMIN_BLOCK = /^(loop:|iteration:|batch:)/;
23112324
const handler = (data) => {
23122325
if (data && typeof data.type === 'string' && ADMIN_BLOCK.test(data.type)) return;
2313-
// Strip runGranter from any state payload before sending — even the admin
2314-
// browser doesn't need the granter address; it's purely server-internal.
2315-
if (data && data.type === 'state' && data.state && typeof data.state === 'object') {
2316-
const { runGranter, ...safeState } = data.state;
2317-
// state events also carry results[] (broadcastStateFresh) — trim each
2318-
// row's diag here too. New array of clones; do not mutate data.results.
2319-
const safeResults = Array.isArray(data.results) ? data.results.map(trimRowDiag) : data.results;
2320-
send({ ...data, state: safeState, results: safeResults });
2321-
return;
2326+
let out = data;
2327+
// ANY live event carrying a state payload (state, result, progress) gets the
2328+
// unconsumed wallet internals + server-internal granter stripped uniformly.
2329+
if (data && data.state && typeof data.state === 'object') {
2330+
out = { ...out, state: sanitizeAdminState(data.state) };
23222331
}
23232332
// result events forward the same row object insertBatchResult persists to
2324-
// raw_json — clone+trim for the wire, leave data.result untouched.
2333+
// raw_json — clone+trim its diag for the wire, leave data.result untouched.
23252334
if (data && data.type === 'result' && data.result && typeof data.result === 'object') {
2326-
send({ ...data, result: trimRowDiag(data.result) });
2327-
return;
2335+
out = { ...out, result: trimRowDiag(data.result) };
2336+
}
2337+
// state events also carry results[] (broadcastStateFresh) — trim each row's
2338+
// diag too. New array of clones; do not mutate data.results.
2339+
if (out && Array.isArray(out.results)) {
2340+
out = { ...out, results: out.results.map(trimRowDiag) };
23282341
}
2329-
send(data);
2342+
send(out);
23302343
};
23312344
emitter.on('update', handler);
23322345
// 20s heartbeat comment-line — keeps the TCP connection alive through proxies

test/admin-sse-diag-trim.test.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ vm.createContext(sandbox);
7878
vm.runInContext(
7979
mustExtract(extractFn(src, 'trimRowDiag'), 'trimRowDiag', '}', 'v2rayPort'),
8080
sandbox);
81+
vm.runInContext(
82+
mustExtract(extractFn(src, 'sanitizeAdminState'), 'sanitizeAdminState', '}', 'runGranter'),
83+
sandbox);
8184

8285
const KEPT = ['v2rayProto', 'v2rayTransport', 'v2raySecurity', 'v2rayPort'];
8386
const SENSITIVE = [
@@ -174,6 +177,73 @@ console.log('[4] rows with a falsy diag pass through unchanged');
174177
ok(vm.runInContext('trimRowDiag(undefined)', sandbox) === undefined, 'undefined row → undefined');
175178
}
176179

180+
// ─── 5. sanitizeAdminState: strip wallet internals, KEEP walletAddress/balance ─
181+
console.log('[5] sanitizeAdminState strips balanceUdvpn/spentUdvpn/runGranter, keeps the rest');
182+
{
183+
const original = {
184+
status: 'running', totalNodes: 120,
185+
walletAddress: 'sent1wallet', balance: '12.5',
186+
// unconsumed / server-internal — must be stripped:
187+
balanceUdvpn: 12500000, spentUdvpn: 4200000, runGranter: 'sent1granter',
188+
};
189+
sandbox._state = original;
190+
const safe = vm.runInContext('sanitizeAdminState(_state)', sandbox);
191+
192+
ok(!Object.prototype.hasOwnProperty.call(safe, 'balanceUdvpn'),
193+
'DROPS balanceUdvpn (regression guard)');
194+
ok(!Object.prototype.hasOwnProperty.call(safe, 'spentUdvpn'),
195+
'DROPS spentUdvpn (regression guard)');
196+
ok(!Object.prototype.hasOwnProperty.call(safe, 'runGranter'),
197+
'DROPS runGranter (regression guard)');
198+
199+
// admin.html DOES read these — they MUST survive
200+
ok(safe.walletAddress === 'sent1wallet', 'KEEPS walletAddress (wallet header)');
201+
ok(safe.balance === '12.5', 'KEEPS balance (wallet header)');
202+
ok(safe.status === 'running', 'KEEPS status');
203+
ok(safe.totalNodes === 120, 'KEEPS totalNodes');
204+
205+
// ─── NON-MUTATION ─────────────────────────────────────────────────────────
206+
ok(safe !== original, 'returns a NEW state object (not the same reference)');
207+
ok(original.balanceUdvpn === 12500000, 'original balanceUdvpn UNTOUCHED');
208+
ok(original.spentUdvpn === 4200000, 'original spentUdvpn UNTOUCHED');
209+
ok(original.runGranter === 'sent1granter', 'original runGranter UNTOUCHED');
210+
}
211+
{
212+
// non-object inputs pass through untouched (defensive)
213+
ok(vm.runInContext('sanitizeAdminState(null)', sandbox) === null, 'null state → null');
214+
ok(vm.runInContext('sanitizeAdminState(undefined)', sandbox) === undefined, 'undefined state → undefined');
215+
ok(vm.runInContext('sanitizeAdminState("x")', sandbox) === 'x', 'non-object state passes through');
216+
}
217+
218+
// ─── 6. wiring: the /api/events handler routes every payload through the sanitizers ─
219+
console.log('[6] /api/events handler wires sanitizeAdminState + trimRowDiag on the live path');
220+
{
221+
// Isolate the ADMIN handler body so the init-send (its own 5-field
222+
// destructure, not the helper) — and the unrelated /live handler, which also
223+
// matches `const handler = (data) =>` — can't satisfy these substring checks.
224+
// Anchor at the admin route then find the handler that follows it.
225+
const route = src.indexOf("app.get('/api/events'");
226+
ok(route !== -1, 'found the /api/events admin route in server.js');
227+
const hm = /const handler = \(data\) => \{/.exec(src.slice(route));
228+
ok(!!hm, 'found the live-event handler in /api/events');
229+
const startAbs = route + hm.index;
230+
let depth = 0, started = false, j = startAbs, body = '';
231+
for (; j < src.length; j++) {
232+
const c = src[j];
233+
if (c === '{') { depth++; started = true; }
234+
else if (c === '}') { depth--; if (started && depth === 0) { j++; break; } }
235+
}
236+
body = src.slice(startAbs, j);
237+
238+
ok(body.includes('sanitizeAdminState('),
239+
'handler routes the state path through sanitizeAdminState (regression guard)');
240+
// trimRowDiag must apply to BOTH the single result row and the results[] array
241+
ok(/result:\s*trimRowDiag\(/.test(body),
242+
'handler trims data.result via trimRowDiag (result events)');
243+
ok(/\.map\(trimRowDiag\)/.test(body),
244+
'handler trims results[] via trimRowDiag (state events)');
245+
}
246+
177247
console.log(`\n${'='.repeat(60)}\nRESULTS: ${out.pass} passed, ${out.fail} failed (${out.pass + out.fail} total)`);
178248
if (out.errors.length) for (const e of out.errors) console.log(` FAIL: ${e}`);
179249
console.log('='.repeat(60));

0 commit comments

Comments
 (0)