Skip to content

Commit f9ef9e0

Browse files
fix(security): full-escape admin log XSS, gate raw_json/error redaction on admin identity
H1: admin.html appendLog now uses escHtml() (escapes " and ') before the URL linkifier, closing an attribute-breakout XSS from attacker-controlled URLs in log lines. Entities inside href are inert, so linkification stays correct. H2: /api/public/node/:addr and /:addr/errors strip raw_json (operator-internal diag payload) for anonymous visitors only. raw_json is joined from results (core/db.js:1111) and the admin failure-diagnostic drawer parses raw.diag/raw.type from this same endpoint — stripping it unconditionally blanked the operator's failure report. The strip is now gated on !req.admin. M2: new _redactPublicError() (bech32 sent1…/sentnode1… → [addr]) applied to the SSE error path AND the REST error_message on both public node endpoints, so wallet/granter addresses in stored RPC rawLogs never reach public surfaces. log_snippet is preserved (already sanitized, Failure-Log UX MUST). auth: attachAdminFlag now mirrors adminOnly's local-mode bypass — when no ADMIN_TOKEN is set (single-user install, the operator's primary deployment), req.admin=true so the operator's own browser keeps raw_json. Previously the two middlewares disagreed and the local operator was flagged non-admin.
1 parent e316f12 commit f9ef9e0

3 files changed

Lines changed: 59 additions & 4 deletions

File tree

admin.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2108,7 +2108,11 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
21082108
div.className = 'log-entry ' + cls;
21092109
div.dataset.cat = cat || logCategory(msg);
21102110
const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
2111-
const escaped = String(msg).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
2111+
// Escape all five HTML-significant chars (incl. " and ') BEFORE linkifying.
2112+
// Without quote-escaping, an attacker-controlled URL containing a double
2113+
// quote (e.g. from an untrusted node operator's remote_addrs) would break
2114+
// out of href="..." into an inline event handler in the admin origin.
2115+
const escaped = escHtml(String(msg));
21122116
const linkified = escaped.replace(/(https?:\/\/[^\s<]+)/g, (u) => `<a href="${u}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:underline">${u}</a>`);
21132117
div.innerHTML = `<span class="log-time">${ts}</span>` + linkified;
21142118
if (!_logEntryMatches(div, _logFilter)) div.style.display = 'none';

core/auth.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,13 @@ export function adminOnly(req, res, next) {
7474
// render admin controls without enforcing authentication.
7575
export function attachAdminFlag(req, res, next) {
7676
const token = process.env.ADMIN_TOKEN;
77+
// No ADMIN_TOKEN configured → local/single-user mode. There are no anonymous
78+
// visitors here: the operator IS the admin (PUBLIC_MODE forces ADMIN_TOKEN to
79+
// be set before any public deployment). Mirror adminOnly's local-mode bypass
80+
// so req.admin is consistent across both middlewares — otherwise the operator's
81+
// own browser is flagged non-admin and loses raw_json in the failure drawer.
7782
if (!token) {
78-
req.admin = false;
83+
req.admin = true;
7984
return next();
8085
}
8186

server.js

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1339,6 +1339,27 @@ app.get('/api/public/node/:addr', attachAdminFlag, rlPublicRead, (req, res) => {
13391339
if (!detail.node) {
13401340
return res.status(404).json({ error: 'Node not found' });
13411341
}
1342+
// Strip raw_json from the PUBLIC response. raw_json is a JSON.stringify of
1343+
// the full per-node result (diag.wgServerEndpoint, diag.remoteUrl,
1344+
// sessionId, handshake internals) and must never reach anonymous visitors.
1345+
// log_snippet (already address-sanitized) is preserved for the Failure-Log UX.
1346+
// Authenticated admins (req.admin via signed session cookie or bearer token)
1347+
// KEEP raw_json — the admin failure-diagnostic drawer parses raw.diag/raw.type
1348+
// from this exact endpoint. Stripping it for everyone blanks the operator's
1349+
// failure report; the strip must be gated on identity, not applied blindly.
1350+
if (!req.admin) {
1351+
const stripRaw = (row) => { if (row && typeof row === 'object') delete row.raw_json; return row; };
1352+
const stripErr = (row) => {
1353+
if (row && typeof row === 'object') {
1354+
delete row.raw_json;
1355+
if (row.error_message != null) row.error_message = _redactPublicError(row.error_message, 2048);
1356+
}
1357+
return row;
1358+
};
1359+
if (detail.node) stripRaw(detail.node);
1360+
if (Array.isArray(detail.history)) detail.history.forEach(stripRaw);
1361+
if (Array.isArray(detail.errors)) detail.errors.forEach(stripErr);
1362+
}
13421363
res.json(detail);
13431364
} catch (err) {
13441365
console.error('[api/public/node]', err);
@@ -1356,6 +1377,20 @@ app.get('/api/public/node/:addr/errors', attachAdminFlag, rlPublicRead, (req, re
13561377
const limit = Math.min(parseInt(req.query.limit || '50', 10) || 50, 500);
13571378
const stage = req.query.stage || null;
13581379
const errors = getNodeErrors(addr, { limit, stage });
1380+
// For anonymous visitors: strip raw_json (operator-internal diag payload)
1381+
// and redact bech32 addresses from error_message (stored verbatim at write
1382+
// time, so it can carry wallet/granter addresses from RPC rawLogs).
1383+
// log_snippet stays — it is already address-sanitized and required by the
1384+
// Failure-Log UX. Authenticated admins (req.admin) see the unredacted rows;
1385+
// the admin failure drawer parses raw_json from this endpoint.
1386+
if (!req.admin && Array.isArray(errors)) {
1387+
for (const row of errors) {
1388+
if (row && typeof row === 'object') {
1389+
delete row.raw_json;
1390+
if (row.error_message != null) row.error_message = _redactPublicError(row.error_message, 2048);
1391+
}
1392+
}
1393+
}
13591394
res.json({ node_addr: addr, total: errors.length, errors });
13601395
} catch (err) {
13611396
console.error('[api/public/node/errors]', err);
@@ -1811,6 +1846,17 @@ const PUBLIC_EVENT_WHITELIST = new Set([
18111846
'progress',
18121847
]);
18131848

1849+
// Redact bech32 wallet/granter addresses (sent1…/sentnode1…/sentpub1…/
1850+
// sentvaloper1…) from any error/log string before it reaches a public surface.
1851+
// Mirrors audit/pipeline.js _sanitizeSnippet's address scrub, which is not in
1852+
// scope here. Raw thrown-error / RPC rawLog text can otherwise carry the
1853+
// tester's wallet or a plan owner's granter address.
1854+
const _PUBLIC_ADDR_RE = /\b(sent|sentnode|sentpub|sentvaloper)1[a-z0-9]{6,}\b/gi;
1855+
function _redactPublicError(v, max = 200) {
1856+
if (v == null) return null;
1857+
return String(v).replace(_PUBLIC_ADDR_RE, '[addr]').slice(0, max);
1858+
}
1859+
18141860
// Keep only the counters / progress fields a public viewer needs.
18151861
// Strips wallet, balance*, spent*, MNEMONIC-derived data, errorMessage internals.
18161862
const PUBLIC_STATE_KEYS = [
@@ -1864,7 +1910,7 @@ function sanitizePublicResult(r) {
18641910
peers: r.peers,
18651911
maxPeers: r.maxPeers,
18661912
errorCode: r.errorCode,
1867-
error: r.error ? String(r.error).slice(0, 200) : null,
1913+
error: r.error ? _redactPublicError(r.error) : null,
18681914
skipped: r.skipped === true ? true : undefined,
18691915
inPlan: r.inPlan === true ? true : undefined,
18701916
testedAt: r.testedAt,
@@ -1889,7 +1935,7 @@ function sanitizeForPublic(evt) {
18891935
if (evt.passed != null) safe.passed = evt.passed;
18901936
if (evt.failed != null) safe.failed = evt.failed;
18911937
if (evt.durationMs != null) safe.durationMs = evt.durationMs;
1892-
if (evt.error != null) safe.error = String(evt.error).slice(0, 200);
1938+
if (evt.error != null) safe.error = _redactPublicError(evt.error);
18931939
// batch:* event fields — only public-safe node-level data
18941940
if (evt.batchId != null) safe.batchId = evt.batchId;
18951941
if (evt.snapshotSize != null) safe.snapshotSize = evt.snapshotSize;

0 commit comments

Comments
 (0)