Multi-Hoster-Upload/tests/diagnostics-collectors.test.js
Administrator 8d757a99dd fix(diagnostics): harden read-only agent — grep ReDoS, prototype-chain whitelist bypass, redaction gaps
Intensive end-to-end testing (a live gateway-MCP <-> agent integration harness +
an adversarial redaction/abuse probe + an independent security audit) surfaced
three real issues in the shipped read-only diagnostic agent. All run in lib/**,
which is packaged in the app.

1. grep ReDoS froze the Electron main process. read_log compiled the
   client-supplied grep into `new RegExp(grep, 'i')` and ran it synchronously over
   the log tail IN the main process. A catastrophic pattern (e.g. "(a+)+$" against
   a long line) hangs the whole app — empirically confirmed (8s timeout, killed).
   JS regex is synchronous and uncancellable, so grep is now a case-insensitive
   literal substring filter with "|" alternation ("error|timeout|502"). Provably
   linear-time; covers the real diagnostic need.

2. Prototype-chain whitelist bypass. The op table was a plain object literal, so
   handle("constructor" | "toString" | "valueOf", ...) resolved an inherited
   Object.prototype function, passed the `typeof fn === 'function'` guard and
   returned {ok:true}. Harmless functions today, but a whitelist-integrity hole.
   Now guarded with a string check + Object.prototype.hasOwnProperty.

3. Redaction defense-in-depth gaps. redactLogText now also scrubs: basic-auth URL
   passwords (scheme://user:pass@host), Authorization: Basic, JWTs (eyJ...x.y.z),
   and bare/JSON session= values. Mostly theoretical in today's readable logs
   (secret-bearing bodies go to the excluded doodstream-debug.log; other hosters
   throw static strings) but matters as the verbose-logging surface grows.

Verified: 383 app tests (incl. new regression tests for all three), the live
gateway-MCP integration harness (all 14 tools, zero leaks, error paths), the
adversarial probe (14/14+ secret shapes scrubbed, ReDoS 1ms, lockout, malformed
args), e2e gate, lint 0 errors. Only residual: a standalone high-entropy blob with
zero key/Bearer/URL context — inherent to any denylist, acknowledged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:40:57 +02:00

131 lines
7.0 KiB
JavaScript

const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const support = require('../lib/support-bundle');
const stats = require('../lib/stats');
const { createCollectors } = require('../lib/diagnostics-collectors');
const { createAgent } = require('../lib/diagnostics-agent');
function makeFixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-'));
const paths = {
fileuploader: path.join(dir, 'fileuploader.log'),
debug: path.join(dir, 'debug.log'),
accountRotation: path.join(dir, 'account-rotation.log'),
doodstreamDebug: path.join(dir, 'doodstream-debug.log'),
crashLog: path.join(dir, 'crash.log'),
logDir: dir
};
fs.writeFileSync(paths.debug, 'boot ok\nuploading file with token SECRETTOKEN123456 inline\nAuthorization: Bearer abcdef123456\n');
fs.writeFileSync(paths.doodstreamDebug, 'api_key=LIVEKEY99999 sess=abc\n');
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
const config = {
hosters: { 'voe.sx': [{ id: 'a1', username: 'u', password: 'HUNTER2SECRET' }], 'byse.sx': [{ id: 'b1', apiKey: 'BYSEKEY1234567' }] },
hosterSettings: {},
globalSettings: {
webhookUrl: 'https://discord.com/api/webhooks/12345/WBHOOKSECRETTOKEN',
diagnostics: { enabled: true, port: 9110, token: 'SECRETTOKEN123456', bindAddress: '127.0.0.1' },
pendingQueue: { savedAt: 1, selectedUploadHosters: ['voe.sx'], selectedFiles: [{ path: 'C:/a.mkv' }], queueJobs: [{ file: 'C:/a.mkv', fileName: 'a.mkv', hoster: 'voe.sx', status: 'error', error: 'timeout' }] }
},
history: [{ timestamp: new Date(2026, 0, 1).toISOString(), files: [{ name: 'x.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'Not video file format' }, { hoster: 'byse.sx', status: 'done', url: 'https://byse.sx/x' }] }] }],
rotationCursors: { 'voe.sx': 1 }
};
const collectors = createCollectors({
loadConfig: () => JSON.parse(JSON.stringify(config)),
getAllLogPaths: () => paths,
support, stats,
appInfo: () => ({ name: 'mhu', version: '9.9.9' }),
systemInfo: () => ({ platform: 'win32', hostname: 'srv' }),
agentInfo: () => ({ version: '9.9.9', port: 9110, clientCount: 0, lastAccess: null })
});
return { dir, paths, config, collectors };
}
test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs the token mid-string', () => {
const { collectors } = makeFixture();
const out = collectors.getConfigRedacted({ section: 'all' });
const json = JSON.stringify(out);
assert.ok(!json.includes('HUNTER2SECRET'), 'password must be redacted');
assert.ok(!json.includes('BYSEKEY1234567'), 'apiKey must be redacted');
assert.ok(!json.includes('SECRETTOKEN123456'), 'diag token must be redacted');
assert.ok(!json.includes('WBHOOKSECRETTOKEN'), 'webhook secret must be redacted');
});
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
const { collectors } = makeFixture();
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });
assert.ok(!dbg.content.includes('SECRETTOKEN123456'), 'value-scrub removes the live diag token from logs');
assert.ok(!/Bearer abcdef123456/.test(dbg.content), 'pattern-scrub removes Authorization Bearer');
assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist');
assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
});
test('readLog grep is case-insensitive substring with | alternation, and is ReDoS-safe', () => {
const { paths } = makeFixture();
const fs2 = require('fs');
fs2.writeFileSync(paths.debug, ['ERROR upload failed', 'info all good', 'WARN timeout hit', 'a'.repeat(120) + '! catastrophic bait'].join('\n'));
const { collectors } = (() => {
const support2 = require('../lib/support-bundle');
const stats2 = require('../lib/stats');
const c = require('../lib/diagnostics-collectors').createCollectors({
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [] }),
getAllLogPaths: () => paths, support: support2, stats: stats2,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
});
return { collectors: c };
})();
const alt = collectors.readLog({ name: 'debug', grep: 'error|timeout' });
assert.equal(alt.matchedLines, 2, 'matches the ERROR and timeout lines case-insensitively');
assert.ok(alt.content.includes('ERROR upload failed') && alt.content.includes('WARN timeout hit'));
assert.ok(!alt.content.includes('info all good'), 'non-matching line excluded');
const t0 = Date.now();
const redos = collectors.readLog({ name: 'debug', grep: '(a+)+$' });
assert.ok(Date.now() - t0 < 1000, 'catastrophic-looking grep must return promptly (literal substring, no backtracking)');
assert.equal(redos.matchedLines, 0, '"(a+)+$" is treated as a literal substring, matching nothing here');
});
test('getQueueState flags stale=true for the persisted snapshot and counts by status', () => {
const { collectors } = makeFixture();
const q = collectors.getQueueState({});
assert.equal(q.source, 'persisted');
assert.equal(q.stale, true);
assert.equal(q.counts.error, 1);
});
test('getQueueState (includeJobs default) pattern-scrubs an opaque token in a job error that is NOT a config secret', () => {
const config = {
hosters: {}, hosterSettings: {},
globalSettings: { pendingQueue: { savedAt: 1, selectedUploadHosters: [], selectedFiles: [], queueJobs: [
{ file: 'C:/b.mkv', fileName: 'b.mkv', hoster: 'streamtape', status: 'error', error: 'upload rejected: token=OPAQUE_NONconfig_TOKEN_9988' }
] } },
history: [], rotationCursors: {}
};
const collectors = createCollectors({
loadConfig: () => JSON.parse(JSON.stringify(config)),
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
support, stats,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
});
const q = collectors.getQueueState({});
const json = JSON.stringify(q);
assert.ok(!json.includes('OPAQUE_NONconfig_TOKEN_9988'), 'opaque token in a job error must be pattern-scrubbed even on the default includeJobs path');
});
test('listErrors classifies via stats.classifyErrorCategory and redacts error text', () => {
const { collectors } = makeFixture();
const e = collectors.listErrors({});
assert.equal(e.total, 1, 'only the non-done result is an error');
assert.equal(e.byCategory['file-rejected'], 1, '"Not video file format" -> file-rejected');
});
test('serverHealth assembles the one-shot hub without leaking secrets', () => {
const { collectors } = makeFixture();
const h = collectors.serverHealth({});
const json = JSON.stringify(h);
assert.ok(h.server && h.queue && h.errors && h.logs, 'hub has all sections');
assert.ok(!json.includes('HUNTER2SECRET') && !json.includes('SECRETTOKEN123456') && !json.includes('WBHOOKSECRETTOKEN'), 'no secret leaks in server_health');
});