Compare commits

...

3 Commits

Author SHA1 Message Date
Administrator
70e7f2a9fd release: v3.3.85 2026-06-19 18:45:22 +02:00
Administrator
cfd5ca07ec chore(gateway/tooling): verify harnesses, Windows token ACL, ESM lint, honest transport docs
Repo-side hardening and tooling from the intensive test round (none of this ships
in the app installer).

- gateway: registry.json (holds bearer tokens) now gets a best-effort owner-only
  NTFS ACL on Windows via `icacls /inheritance:r /grant:r <user>:F` (the chmod
  0600 is a no-op on NTFS); verified the file ends up <user>:(F) only.
- gateway: connect_server now reads the app version from the real get_system_info
  shape (data.app.version / data.agent.version), so "connected to vX.Y.Z" works.
- gateway: read_log tool description documents grep as a case-insensitive substring
  filter with "|" alternation (not a regex), matching the agent-side change.
- gateway: standalone verification harnesses moved to gateway/verify/ (so
  `node --test` only sweeps real unit tests) and exposed via `npm run verify`:
  e2e-verify, integration-mcp (live gateway-MCP <-> agent, all 14 tools), and
  adversarial-probe (redaction fuzz + ReDoS + lockout). `npm test` runs the units.
- eslint: gateway/** now lints as ESM (sourceType module) via a dedicated block;
  global ignores fixed so `eslint .` is clean across the whole project (0 errors).
- docs/remote-diagnostics-setup.md: made the transport story honest — the agent
  speaks plaintext ws:// over enforced loopback; the SSH/WireGuard tunnel is the
  ONLY confidentiality layer (wss/TLS + cert-pin is a documented future mode, not
  active). Removed the stale "bind to a LAN/VPN IP" guidance (loopback is enforced).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:41:21 +02:00
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
15 changed files with 537 additions and 71 deletions

View File

@ -17,12 +17,18 @@ On the **server** (the machine running the app):
2. Enable **"Diagnose-Zugriff"**. 2. Enable **"Diagnose-Zugriff"**.
3. Copy the connection **code**. It looks like `mhu1_<base64url...>`. 3. Copy the connection **code**. It looks like `mhu1_<base64url...>`.
The code carries a one-time auth **token** (and, for TLS, the server cert fingerprint). It does The code carries a one-time auth **token**. It does **not** carry a host — you supply the host
**not** carry a host — you supply the host yourself when you connect. Treat the code as a yourself when you connect. Treat the code as a **secret**: anyone with the code and network reach
**secret**: anyone with the code and network reach to the agent can read diagnostics. to the agent can read diagnostics.
The agent's **safe default is to bind to `127.0.0.1`** (loopback only). It is not exposed to the The agent **always binds to `127.0.0.1`** (loopback only) — this is enforced; there is no setting
network. You reach it through a tunnel (next step). to expose it on a LAN/Internet interface. You reach it through a tunnel (next step).
**Transport note:** the agent speaks **plaintext `ws://` over loopback**. The token and the
diagnostic data are *not* encrypted on the wire by the agent itself — confidentiality comes
entirely from the **tunnel** (SSH/WireGuard) you put in front of it. The loopback bind means the
plaintext traffic never leaves the host except inside that encrypted tunnel. (A future build may
add `wss`/TLS with cert pinning via an `fp` field in the code; it is not active today.)
--- ---
@ -47,8 +53,9 @@ Bring up a WireGuard tunnel to the server, then connect to the server's WireGuar
you forward loopback over the tunnel, `127.0.0.1`). Use whichever address resolves to the agent's you forward loopback over the tunnel, `127.0.0.1`). Use whichever address resolves to the agent's
`127.0.0.1:9110` on the server. `127.0.0.1:9110` on the server.
> Only expose the agent directly on a LAN/VPN IP if you fully trust that network segment. The > The agent cannot be bound directly to a LAN/Internet IP in this build (loopback is enforced),
> default loopback + tunnel is the secure choice. > so the tunnel is the **only** way to reach it remotely — and the only thing encrypting the
> transport. Keep the tunnel (SSH/WireGuard) up for the whole session.
--- ---
@ -110,7 +117,7 @@ When a connect or a request fails, the gateway returns a human-readable cause. Q
| close code **4002** | stale or rotated code — re-copy the current code from the server | | close code **4002** | stale or rotated code — re-copy the current code from the server |
| close code **4003** | brute-force lockout, wait 60s | | close code **4003** | brute-force lockout, wait 60s |
| connected but no `auth-ok` | old app version without the diagnostic agent — update that server | | connected but no `auth-ok` | old app version without the diagnostic agent — update that server |
| wss fingerprint mismatch | server cert changed (reinstalled?) — re-copy the code | | wss fingerprint mismatch | reserved for the future TLS mode (not active today) — re-copy the code |
If you tunnel and still get `ECONNREFUSED`, check that the SSH session is up and that the agent is If you tunnel and still get `ECONNREFUSED`, check that the SSH session is up and that the agent is
actually listening on `127.0.0.1:9110` on the server (Diagnose-Zugriff enabled). actually listening on `127.0.0.1:9110` on the server (Diagnose-Zugriff enabled).

View File

@ -1,47 +1,6 @@
import security from 'eslint-plugin-security'; import security from 'eslint-plugin-security';
export default [ const sharedRules = {
{
files: ['**/*.js'],
ignores: ['node_modules/**', 'release/**', 'tests/**'],
plugins: { security },
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
require: 'readonly',
module: 'readonly',
exports: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
process: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
setImmediate: 'readonly',
Buffer: 'readonly',
URL: 'readonly',
fetch: 'readonly',
AbortController: 'readonly',
AbortSignal: 'readonly',
navigator: 'readonly',
document: 'readonly',
window: 'readonly',
localStorage: 'readonly',
HTMLElement: 'readonly',
alert: 'readonly',
confirm: 'readonly',
requestAnimationFrame: 'readonly',
queueMicrotask: 'readonly',
Intl: 'readonly',
crypto: 'readonly',
URLSearchParams: 'readonly',
EventSource: 'readonly',
}
},
rules: {
// Security rules // Security rules
// detect-object-injection disabled: 78 false positives from config lookups like obj[hosterName] // detect-object-injection disabled: 78 false positives from config lookups like obj[hosterName]
'security/detect-object-injection': 'off', 'security/detect-object-injection': 'off',
@ -80,6 +39,65 @@ export default [
'no-unsafe-finally': 'error', 'no-unsafe-finally': 'error',
'no-unmodified-loop-condition': 'warn', 'no-unmodified-loop-condition': 'warn',
'no-template-curly-in-string': 'warn', 'no-template-curly-in-string': 'warn',
};
const nodeGlobals = {
process: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
setImmediate: 'readonly',
Buffer: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
fetch: 'readonly',
crypto: 'readonly',
};
export default [
{ ignores: ['**/node_modules/**', 'release/**', 'tests/**'] },
{
files: ['**/*.js'],
ignores: ['gateway/**'],
plugins: { security },
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
require: 'readonly',
module: 'readonly',
exports: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
...nodeGlobals,
AbortController: 'readonly',
AbortSignal: 'readonly',
navigator: 'readonly',
document: 'readonly',
window: 'readonly',
localStorage: 'readonly',
HTMLElement: 'readonly',
alert: 'readonly',
confirm: 'readonly',
requestAnimationFrame: 'readonly',
queueMicrotask: 'readonly',
Intl: 'readonly',
EventSource: 'readonly',
} }
},
rules: sharedRules
},
{
files: ['gateway/**/*.js', 'gateway/**/*.mjs'],
ignores: ['gateway/node_modules/**'],
plugins: { security },
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: nodeGlobals
},
rules: sharedRules
} }
]; ];

View File

@ -48,7 +48,7 @@ const DIAGNOSTIC_TOOLS = [
{ {
name: 'read_log', name: 'read_log',
title: 'Read a log file', title: 'Read a log file',
description: 'Read a tail of one of the app log files, optionally grep-filtered, optionally a rotated backup.', description: 'Read a tail of one of the app log files, optionally a rotated backup. grep is a case-insensitive substring filter; separate alternatives with "|" (e.g. "error|timeout|502") to keep any line matching at least one term. Not a regular expression.',
op: 'read_log', op: 'read_log',
inputSchema: { inputSchema: {
name: z.enum(['debug', 'fileuploader', 'accountRotation', 'crash']), name: z.enum(['debug', 'fileuploader', 'accountRotation', 'crash']),
@ -172,7 +172,8 @@ async function doConnect({ code, host, port, label }) {
let version; let version;
const info = await client.request('get_system_info', {}); const info = await client.request('get_system_info', {});
if (info && info.ok && info.data) { if (info && info.ok && info.data) {
version = info.data.version ?? info.data.appVersion ?? undefined; const d = info.data;
version = d.version ?? d.appVersion ?? (d.app && d.app.version) ?? (d.agent && d.agent.version) ?? undefined;
} }
const id = `${target.label}@${target.host}:${target.port}`; const id = `${target.label}@${target.host}:${target.port}`;

View File

@ -10,6 +10,10 @@
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
"scripts": {
"test": "node --test \"test/**/*.test.js\"",
"verify": "node verify/e2e-verify.mjs && node verify/integration-mcp.mjs && node verify/adversarial-probe.mjs"
},
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "~1.29.0", "@modelcontextprotocol/sdk": "~1.29.0",
"ws": "^8", "ws": "^8",

View File

@ -1,6 +1,8 @@
import { readFile, writeFile, chmod } from 'node:fs/promises'; import { readFile, writeFile, chmod } from 'node:fs/promises';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { execFile } from 'node:child_process';
import { userInfo } from 'node:os';
const HERE = dirname(fileURLToPath(import.meta.url)); const HERE = dirname(fileURLToPath(import.meta.url));
const REGISTRY_PATH = join(HERE, 'registry.json'); const REGISTRY_PATH = join(HERE, 'registry.json');
@ -18,10 +20,20 @@ export async function loadRegistry() {
} }
} }
function tightenWindowsAcl(path) {
return new Promise((resolve) => {
let user;
try { user = userInfo().username; } catch { resolve(); return; }
if (!user) { resolve(); return; }
execFile('icacls', [path, '/inheritance:r', '/grant:r', `${user}:F`], { windowsHide: true }, () => resolve());
});
}
export async function saveRegistry(registry) { export async function saveRegistry(registry) {
const data = registry && typeof registry === 'object' ? registry : {}; const data = registry && typeof registry === 'object' ? registry : {};
await writeFile(REGISTRY_PATH, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 }); await writeFile(REGISTRY_PATH, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
try { await chmod(REGISTRY_PATH, 0o600); } catch {} try { await chmod(REGISTRY_PATH, 0o600); } catch {}
if (process.platform === 'win32') { try { await tightenWindowsAcl(REGISTRY_PATH); } catch {} }
} }
export async function upsertEntry(entry) { export async function upsertEntry(entry) {

View File

@ -0,0 +1,137 @@
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import WebSocket from 'ws';
const require = createRequire(import.meta.url);
const appRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const support = require(join(appRoot, 'lib', 'support-bundle.js'));
const stats = require(join(appRoot, 'lib', 'stats.js'));
const RemoteServer = require(join(appRoot, 'lib', 'remote-server.js'));
const { createCollectors } = require(join(appRoot, 'lib', 'diagnostics-collectors.js'));
const { createAgent } = require(join(appRoot, 'lib', 'diagnostics-agent.js'));
const findings = [];
function leakCheck(label, text, needle, { realistic }) {
if (text.includes(needle)) findings.push({ label, needle: needle.slice(0, 24), realistic });
}
console.log('=== A. redactLogText pattern-scrub battery (no config secrets; pure shape detection) ===');
const battery = [
{ line: 'Authorization: Bearer abcDEF123456ghiJKL789', needle: 'abcDEF123456ghiJKL789', realistic: true },
{ line: 'using Bearer eyJhbGciOiJIUzI1Nib-longtokenvalue-0099', needle: 'eyJhbGciOiJIUzI1Nib-longtokenvalue-0099', realistic: true },
{ line: 'resp token=sess_9f8e7d6c5b4a3210ffee', needle: 'sess_9f8e7d6c5b4a3210ffee', realistic: true },
{ line: 'access_token: ya29.A0ARrdaM-longgoogletoken-123', needle: 'ya29.A0ARrdaM-longgoogletoken-123', realistic: true },
{ line: 'refresh_token = 1//0ggLongRefreshToken_abcdef', needle: '1//0ggLongRefreshToken_abcdef', realistic: true },
{ line: 'x-api-key: SuperSecretApiKeyValue99', needle: 'SuperSecretApiKeyValue99', realistic: true },
{ line: 'api_key=AKIAIOSFODNN7EXAMPLEKEY', needle: 'AKIAIOSFODNN7EXAMPLEKEY', realistic: true },
{ line: 'GET /up?key=querykeysecret12345 HTTP/1.1', needle: 'querykeysecret12345', realistic: true },
{ line: 'POST https://discord.com/api/webhooks/123456789012345678/WEBHOOKTOKENsecretvalue', needle: 'WEBHOOKTOKENsecretvalue', realistic: true },
{ line: 'set-cookie: session=COOKIEsecretvalue9988; HttpOnly', needle: 'COOKIEsecretvalue9988', realistic: true },
{ line: 'Cookie: sess_id=ABCcookievalue12345', needle: 'ABCcookievalue12345', realistic: true },
{ line: 'sessionId: SESSIONsecret009988aa', needle: 'SESSIONsecret009988aa', realistic: true },
{ line: 'two leaks: token=firsttok12345678 and api_key=secondkey87654321', needle: 'secondkey87654321', realistic: true },
{ line: 'proxy https://admin:Sup3rProxyPass@proxy.internal:8080/path', needle: 'Sup3rProxyPass', realistic: true },
{ line: 'Authorization: Basic dXNlcjpwYXNzd29yZF9zZWNyZXQ=', needle: 'dXNlcjpwYXNzd29yZF9zZWNyZXQ', realistic: true },
{ line: 'jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhYmMxMjMifQ.SflKxwRJSMeKKF2QT4fwpMabc', needle: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhYmMxMjMifQ', realistic: true },
{ line: 'session=BareSessionSecret998877', needle: 'BareSessionSecret998877', realistic: true },
{ line: 'password: PlainTextPassword123 was used', needle: 'PlainTextPassword123', realistic: true },
{ line: 'random high-entropy blob 9f8e7d6c5b4a3210ffeeddccbbaa with no key context', needle: '9f8e7d6c5b4a3210ffeeddccbbaa', realistic: false },
];
for (const t of battery) {
const out = support.redactLogText(t.line, []);
leakCheck('redactLogText: ' + t.line.slice(0, 40), out, t.needle, t);
console.log(` ${out.includes(t.needle) ? 'LEAK ' : 'scrub'} ${t.line.slice(0, 52)}`);
}
console.log('\n=== B. value-scrub: config secret in odd encodings (deepRedact via collectors) ===');
const SECRET = 'CFGsecret_aabbccddeeff';
const tmp = mkdtempSync(join(tmpdir(), 'mhu-adv-'));
writeFileSync(join(tmp, 'debug.log'), `plain ${SECRET}\nurlenc CFGsecret_aabbccddeeff also\n`);
const cfg = {
hosters: { doodstream: [{ accountId: 'a', apiKey: SECRET }] }, hosterSettings: {},
globalSettings: { diagnostics: { enabled: true, token: 'd'.repeat(64) },
pendingQueue: { savedAt: 1, selectedUploadHosters: [], selectedFiles: [], queueJobs: [
{ file: 'x', fileName: 'x', hoster: 'doodstream', status: 'error', error: `failed ${SECRET}` }] } },
history: [{ timestamp: 't', files: [{ name: 'x', results: [{ hoster: 'doodstream', status: 'error', error: `e ${SECRET}` }] }] }],
rotationCursors: {},
};
const cols = createCollectors({
loadConfig: () => JSON.parse(JSON.stringify(cfg)),
getAllLogPaths: () => ({ fileuploader: join(tmp, 'f.log'), debug: join(tmp, 'debug.log'), accountRotation: join(tmp, 'r.log'), doodstreamDebug: join(tmp, 'doodstream-debug.log'), crashLog: join(tmp, 'c.log'), logDir: tmp }),
support, stats, appInfo: () => ({ version: '3.3.84' }), systemInfo: () => ({}), agentInfo: () => ({}),
});
for (const [name, fn] of [
['getConfigRedacted(all)', () => cols.getConfigRedacted({ section: 'all' })],
['getQueueState(includeJobs)', () => cols.getQueueState({ includeJobs: true })],
['getQueueState(default)', () => cols.getQueueState({})],
['listErrors', () => cols.listErrors({})],
['getHistory(files)', () => cols.getHistory({ includeFiles: true })],
['serverHealth', () => cols.serverHealth({})],
['readLog(debug)', () => cols.readLog({ name: 'debug' })],
]) {
const text = JSON.stringify(fn());
leakCheck('collector:' + name, text, SECRET, { realistic: true });
console.log(` ${text.includes(SECRET) ? 'LEAK ' : 'scrub'} ${name}`);
}
console.log('\n=== C. abuse / DoS: ReDoS grep, oversized tailKb, malformed args (must not hang/crash) ===');
const t0 = Date.now();
writeFileSync(join(tmp, 'debug.log'), 'a'.repeat(80) + '! catastrophic-bait line\n' + `plain ${SECRET}\nurlenc CFGsecret_aabbccddeeff also\n`);
const redos = cols.readLog({ name: 'debug', grep: '(a+)+$', tailKb: 1 });
const redosMs = Date.now() - t0;
if (redosMs > 1500) findings.push({ label: 'grep ReDoS hang (' + redosMs + 'ms) on 80-a line', needle: '(a+)+$', realistic: true });
console.log(` grep "(a+)+$" vs 80-a line returned in ${redosMs}ms (must be <1500: ${redosMs < 1500})`);
const longGrep = cols.readLog({ name: 'debug', grep: 'a'.repeat(5000) });
console.log(` grep 5000-char pattern: ${longGrep && (longGrep.matchedLines !== undefined || longGrep.content !== undefined) ? 'handled' : 'handled'}`);
const bigTail = cols.readLog({ name: 'debug', tailKb: 9999999 });
console.log(` tailKb 9999999 clamped to: ${bigTail.tailKb} (<=1024:${bigTail.tailKb <= 1024})`);
let crashed = false;
for (const bad of [null, undefined, 42, [], { name: 123 }, { name: ['debug'] }, { name: 'debug', backup: 'evil' }, { name: 'debug', tailKb: -5 }, { limit: 'NaN' }]) {
try { cols.readLog(bad); cols.listErrors(bad); cols.getQueueState(bad); cols.getHistory(bad); cols.getAppEvents(bad); }
catch (e) { crashed = true; findings.push({ label: 'collector THREW on malformed args: ' + JSON.stringify(bad), needle: String(e.message), realistic: true }); }
}
console.log(` malformed-args battery: ${crashed ? 'THREW (bad)' : 'no throw (good)'}`);
console.log('\n=== D. agent whitelist: write/exec/unknown ops rejected, never throws ===');
const agent = createAgent(cols);
let agentThrew = false;
for (const op of ['save_config', 'run_health_check', 'exec', 'eval', 'delete_log', '__proto__', 'constructor', 'getConfigRedacted', '', null, 'get_config_redacted; drop']) {
try { const r = agent.handle(op, {}); if (r && r.ok === true && !['get_config_redacted'].includes(op)) findings.push({ label: 'agent ACCEPTED non-whitelisted op: ' + op, needle: op, realistic: true }); }
catch (e) { agentThrew = true; findings.push({ label: 'agent THREW on op ' + op, needle: String(e.message), realistic: true }); }
}
console.log(` non-whitelisted ops: ${agentThrew ? 'THREW (bad)' : 'all returned {ok:false} (good)'}`);
console.log('\n=== E. transport abuse: brute-force lockout + concurrent clients (live RemoteServer) ===');
const TOKEN = 'z'.repeat(64);
const srv = new RemoteServer();
await srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest: (m, _c, reply) => reply(agent.handle(m.op, m.args)) });
const port = srv.getPort();
function wsOnce(sendToken) {
return new Promise((resolve) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
let authed = false;
ws.on('open', () => ws.send(JSON.stringify({ type: 'auth', token: sendToken, role: 'diagnostic' })));
ws.on('message', (raw) => { try { const m = JSON.parse(raw); if (m.type === 'auth-ok') { authed = true; ws.close(); resolve({ authed: true }); } } catch {} });
ws.on('close', (code) => resolve({ authed, code }));
ws.on('error', () => {});
});
}
const okClients = await Promise.all([wsOnce(TOKEN), wsOnce(TOKEN), wsOnce(TOKEN)]);
console.log(` 3 concurrent valid clients all authed: ${okClients.every(c => c.authed)}`);
let lastCode = null;
for (let i = 0; i < 6; i++) lastCode = (await wsOnce('wrongtoken')).code;
console.log(` after 6 bad-token attempts, close code = ${lastCode} (4003 lockout expected: ${lastCode === 4003})`);
const afterLock = await wsOnce(TOKEN);
console.log(` valid token DURING lockout window: authed=${afterLock.authed} closeCode=${afterLock.code} (locked out even with right token: ${!afterLock.authed})`);
srv.stop();
console.log('\n=== SUMMARY ===');
const real = findings.filter(f => f.realistic);
const theo = findings.filter(f => !f.realistic);
if (theo.length) console.log(` ${theo.length} THEORETICAL (acknowledged denylist limit): ${theo.map(f => f.label).join(' | ')}`);
if (real.length) { console.log(` ${real.length} REAL finding(s):`); for (const f of real) console.log(` - ${f.label} :: ${f.needle}`); process.exit(2); }
console.log(' No REAL leaks/crashes found. (Theoretical = standalone secret with zero key/Bearer/URL context — inherent to denylist.)');
process.exit(0);

View File

@ -0,0 +1,217 @@
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { writeFileSync, mkdtempSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import assert from 'node:assert';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { encode } from '../code.js';
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
const gwRoot = join(here, '..');
const appRoot = join(gwRoot, '..');
const indexPath = join(gwRoot, 'index.js');
const registryPath = join(gwRoot, 'registry.json');
const RemoteServer = require(join(appRoot, 'lib', 'remote-server.js'));
const support = require(join(appRoot, 'lib', 'support-bundle.js'));
const stats = require(join(appRoot, 'lib', 'stats.js'));
const { createCollectors } = require(join(appRoot, 'lib', 'diagnostics-collectors.js'));
const { createAgent } = require(join(appRoot, 'lib', 'diagnostics-agent.js'));
const SECRET_API = 'APIKEY_live_77ffee0011aabb';
const SECRET_PW = 'pw_S3cr3t_zzqqww';
const SECRET_DIAGTOK = 'd'.repeat(64);
const OPAQUE_TOK = 'OPAQUE_session_tok_5a4b3c2d1e';
const WEBHOOK = 'https://discord.com/api/webhooks/987654321098765432/IntegrationWebhookSecretXyz';
const SECRETS = [SECRET_API, SECRET_PW, SECRET_DIAGTOK, OPAQUE_TOK, 'IntegrationWebhookSecretXyz'];
const tmp = mkdtempSync(join(tmpdir(), 'mhu-int-'));
const debugLog = join(tmp, 'debug.log');
const rotLog = join(tmp, 'account-rotation.log');
const dood = join(tmp, 'doodstream-debug.log');
writeFileSync(debugLog, [
`[2026-06-19T10:00:00Z] boot ok`,
`[2026-06-19T10:00:01Z] upload with apiKey=${SECRET_API}`,
`[2026-06-19T10:00:02Z] Authorization: Bearer ${OPAQUE_TOK}`,
`[2026-06-19T10:00:03Z] grepneedle marker line`,
].join('\n'));
writeFileSync(rotLog, `[2026-06-19T10:00:00Z] rotating to acc2\n`);
writeFileSync(dood, `[dood] api_key=${SECRET_API}\n`);
const config = {
hosters: { doodstream: [{ accountId: 'acc1', apiKey: SECRET_API, password: SECRET_PW }] },
hosterSettings: {},
globalSettings: {
webhookUrl: WEBHOOK,
diagnostics: { enabled: true, port: 9110, token: SECRET_DIAGTOK },
pendingQueue: { savedAt: '2026-06-19T09:00:00Z', selectedUploadHosters: ['doodstream'], selectedFiles: ['a.mp4', 'b.mp4'], queueJobs: [
{ file: 'a.mp4', fileName: 'a.mp4', hoster: 'doodstream', status: 'error', error: `rejected token=${OPAQUE_TOK}` },
{ file: 'b.mp4', fileName: 'b.mp4', hoster: 'doodstream', status: 'uploading', error: null },
] },
},
rotationCursors: { doodstream: 1 },
history: [{ timestamp: '2026-06-19T08:00:00Z', files: [{ name: 'a.mp4', results: [
{ hoster: 'doodstream', status: 'error', error: `boom apiKey=${SECRET_API}` },
{ hoster: 'voe.sx', status: 'done', url: 'https://voe.sx/x' },
] }] }],
};
function getAllLogPaths() {
return { fileuploader: join(tmp, 'fileuploader.log'), debug: debugLog, accountRotation: rotLog, doodstreamDebug: dood, crashLog: join(tmp, 'crash.log'), logDir: tmp };
}
const collectors = createCollectors({
loadConfig: () => JSON.parse(JSON.stringify(config)),
getAllLogPaths, support, stats,
appInfo: () => ({ name: 'mhu', version: '3.3.84' }),
systemInfo: () => ({ platform: 'win32', hostname: 'INT-HOST', cpuCount: 8 }),
agentInfo: () => ({ version: '3.3.84', port: 9110, clientCount: 1, lastAccess: null }),
});
const agent = createAgent(collectors);
const TOKEN = 't'.repeat(64);
const failures = [];
function check(name, cond, detail) {
if (cond) { console.log(` PASS ${name}`); }
else { console.log(` FAIL ${name}${detail ? ' :: ' + detail : ''}`); failures.push(name); }
}
function noLeak(name, payloadText) {
for (const s of SECRETS) {
if (payloadText.includes(s)) { check(`${name} (no-leak:${s.slice(0, 10)})`, false, 'secret present'); return; }
}
check(`${name} (no-leak)`, true);
}
const srv = new RemoteServer();
let client, transport;
const savedRegistry = existsSync(registryPath) ? readFileSync(registryPath, 'utf8') : null;
async function callJSON(name, args) {
try {
const r = await client.callTool({ name, arguments: args || {} });
const text = r.content ? r.content.map(c => c.text || '').join('') : '';
let data; try { data = JSON.parse(text); } catch { data = null; }
return { text, data, isError: !!r.isError, threw: false };
} catch (e) {
return { text: String(e && e.message || e), data: null, isError: true, threw: true };
}
}
function rejected(r) { return r.threw || r.isError || (r.data && r.data.ok === false); }
(async () => {
await srv.start({
port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true,
onDiagnosticRequest: (msg, _c, reply) => {
let res; try { res = agent.handle(msg.op, msg.args); } catch (e) { res = { ok: false, error: String(e && e.message || e) }; }
reply(res);
},
});
const port = srv.getPort();
const code = encode({ v: 1, port, token: TOKEN, label: 'integration' });
transport = new StdioClientTransport({ command: process.execPath, args: [indexPath] });
client = new Client({ name: 'mhu-int-test', version: '1.0.0' });
await client.connect(transport);
const toolList = await client.listTools();
const names = toolList.tools.map(t => t.name).sort();
const expected = ['connect_server', 'current_server', 'disconnect_server', 'get_app_events', 'get_config_redacted', 'get_history', 'get_queue_state', 'get_rotation_state', 'get_system_info', 'list_errors', 'list_logs', 'list_servers', 'read_log', 'server_health'].sort();
check('listTools returns all 14 tools', JSON.stringify(names) === JSON.stringify(expected), names.join(','));
let r = await callJSON('connect_server', { code, host: '127.0.0.1' });
check('connect_server ok', r.data && r.data.ok === true, r.text.slice(0, 120));
check('connect_server reports version 3.3.84', r.data && r.data.server && r.data.server.version === '3.3.84');
r = await callJSON('current_server');
check('current_server shows host 127.0.0.1', r.data && r.data.current && r.data.current.host === '127.0.0.1');
r = await callJSON('server_health', { errorLimit: 10 });
check('server_health ok + sections', r.data && r.data.ok && r.data.data.errors && r.data.data.queue && r.data.data.logs);
noLeak('server_health', r.text);
r = await callJSON('read_log', { name: 'debug', tailKb: 64 });
check('read_log debug ok + benign content kept', r.data && r.data.ok && r.data.data.content.includes('grepneedle marker line'));
noLeak('read_log:debug', r.text);
r = await callJSON('read_log', { name: 'debug', grep: 'grepneedle' });
check('read_log grep filters to matching line', r.data && r.data.ok && r.data.data.content.includes('grepneedle') && !r.data.data.content.includes('boot ok'));
r = await callJSON('read_log', { name: 'doodstream' });
check('read_log doodstream REJECTED (live keys)', rejected(r), r.text.slice(0, 100));
noLeak('read_log:doodstream-reject', r.text);
r = await callJSON('read_log', { name: '../../../etc/passwd' });
check('read_log path traversal REJECTED', rejected(r), r.text.slice(0, 100));
noLeak('read_log:traversal-reject', r.text);
r = await callJSON('list_logs');
check('list_logs lists debug+fileuploader+accountRotation+crash', r.data && r.data.ok && r.data.data.files.length === 4);
check('list_logs marks doodstream NOT readable (not in files)', r.data && !r.data.data.files.some(f => f.name === 'doodstream'));
r = await callJSON('list_errors', { limit: 50 });
check('list_errors finds the 1 history error', r.data && r.data.ok && r.data.data.total === 1);
noLeak('list_errors', r.text);
r = await callJSON('get_queue_state', { includeJobs: true });
check('get_queue_state jobs present (2)', r.data && r.data.ok && Array.isArray(r.data.data.jobs) && r.data.data.jobs.length === 2);
check('get_queue_state stale flag set', r.data && r.data.data.stale === true);
noLeak('get_queue_state:includeJobs', r.text);
r = await callJSON('get_queue_state', {});
noLeak('get_queue_state:default-args', r.text);
r = await callJSON('get_history', { limit: 10, includeFiles: true, includeUrls: true });
check('get_history returns batches + perHoster', r.data && r.data.ok && Array.isArray(r.data.data.batches) && r.data.data.perHoster);
noLeak('get_history', r.text);
r = await callJSON('get_config_redacted', { section: 'all' });
check('get_config_redacted ok + history omitted', r.data && r.data.ok && r.data.data.config && r.data.data.config.history === undefined);
noLeak('get_config_redacted:all', r.text);
r = await callJSON('get_config_redacted', { section: 'hosters' });
noLeak('get_config_redacted:hosters', r.text);
r = await callJSON('get_rotation_state');
check('get_rotation_state returns cursors', r.data && r.data.ok && r.data.data.rotationCursors);
noLeak('get_rotation_state', r.text);
r = await callJSON('get_system_info');
check('get_system_info returns app+system+agent', r.data && r.data.ok && r.data.data.app && r.data.data.system);
noLeak('get_system_info', r.text);
r = await callJSON('get_app_events', { limit: 20 });
check('get_app_events ok', r.data && r.data.ok);
noLeak('get_app_events', r.text);
r = await callJSON('list_servers');
check('list_servers includes the connected one', r.data && r.data.ok && r.data.current && r.data.current.includes('integration'));
r = await callJSON('disconnect_server');
check('disconnect_server ok', r.data && r.data.ok && r.data.disconnected === true);
r = await callJSON('server_health');
check('tool after disconnect returns guidance (no server connected)', r.data && r.data.ok === false && /connect_server/.test(r.data.error || ''));
r = await callJSON('connect_server', { code: 'mhu1_not_valid_base64!!', host: '127.0.0.1' });
check('connect with garbage code REJECTED', r.data && r.data.ok === false);
r = await callJSON('connect_server', { code, host: '127.0.0.1', port: 1 });
check('connect to dead port REJECTED with guidance', r.data && r.data.ok === false && /not running|refused|firewall|closed|timeout/i.test(r.data.error || ''));
await client.close();
srv.stop();
if (savedRegistry !== null) writeFileSync(registryPath, savedRegistry, 'utf8');
console.log('');
if (failures.length) { console.log(`INTEGRATION FAIL: ${failures.length} check(s) failed: ${failures.join(' | ')}`); process.exit(1); }
console.log('INTEGRATION PASS: full gateway-MCP <-> live agent stack verified, every tool, no leaks, error paths correct.');
process.exit(0);
})().catch((e) => {
console.error('INTEGRATION ERROR:', e && e.stack || e);
try { srv.stop(); } catch {}
try { if (savedRegistry !== null) writeFileSync(registryPath, savedRegistry, 'utf8'); } catch {}
process.exit(1);
});

View File

@ -15,7 +15,7 @@ function createAgent(collectors) {
}; };
function handle(op, args) { function handle(op, args) {
const fn = OPS[op]; const fn = (typeof op === 'string' && Object.prototype.hasOwnProperty.call(OPS, op)) ? OPS[op] : null;
if (typeof fn !== 'function') return { ok: false, error: `unknown or non-readonly op: ${op}` }; if (typeof fn !== 'function') return { ok: false, error: `unknown or non-readonly op: ${op}` };
try { try {
const data = fn(args || {}); const data = fn(args || {});

View File

@ -104,10 +104,12 @@ function createCollectors(deps) {
let content = support.redactLogText(raw, _secrets()); let content = support.redactLogText(raw, _secrets());
let matchedLines; let matchedLines;
if (a.grep && typeof a.grep === 'string' && a.grep.length <= 200) { if (a.grep && typeof a.grep === 'string' && a.grep.length <= 200) {
let re; const terms = a.grep.split('|').map(s => s.trim().toLowerCase()).filter(Boolean);
try { re = new RegExp(a.grep, 'i'); } catch { re = null; } if (terms.length) {
if (re) { const lines = content.split('\n').filter(l => {
const lines = content.split('\n').filter(l => re.test(l)); const low = l.toLowerCase();
return terms.some(t => low.includes(t));
});
matchedLines = lines.length; matchedLines = lines.length;
content = lines.join('\n'); content = lines.join('\n');
} }

View File

@ -43,10 +43,12 @@ function redactLogText(text, secrets) {
} }
out = out out = out
.replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED) .replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED)
.replace(/(authorization:\s*bearer\s+)\S+/gi, '$1' + REDACTED) .replace(/(\/\/[^\s/:@]+:)[^\s/@]+(@)/g, '$1' + REDACTED + '$2')
.replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, '$1' + REDACTED)
.replace(/\bbearer\s+[A-Za-z0-9._\-/+]{16,}/gi, 'bearer ' + REDACTED) .replace(/\bbearer\s+[A-Za-z0-9._\-/+]{16,}/gi, 'bearer ' + REDACTED)
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}/g, REDACTED)
.replace(/([?&](?:api_?key|key|token|access_token|password|pass)=)[^\s&"'`]+/gi, '$1' + REDACTED) .replace(/([?&](?:api_?key|key|token|access_token|password|pass)=)[^\s&"'`]+/gi, '$1' + REDACTED)
.replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|(?:access|refresh|auth|session)[_-]?token|token|sessionid)"?\s*[:=]\s*"?)[A-Za-z0-9._\-/+]{8,}/gi, '$1' + REDACTED) .replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|(?:access|refresh|auth|session)[_-]?token|token|sessionid|session)"?\s*[:=]\s*"?)[A-Za-z0-9._\-/+]{8,}/gi, '$1' + REDACTED)
.replace(/(\bset-cookie:|\bcookie:)\s*\S[^\n]*/gi, '$1 ' + REDACTED) .replace(/(\bset-cookie:|\bcookie:)\s*\S[^\n]*/gi, '$1 ' + REDACTED)
.replace(/(\bsess(?:_?id)?\b["'=:\s]+)[A-Za-z0-9._\-]{8,}/gi, '$1' + REDACTED); .replace(/(\bsess(?:_?id)?\b["'=:\s]+)[A-Za-z0-9._\-]{8,}/gi, '$1' + REDACTED);
return out; return out;

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "3.3.84", "version": "3.3.85",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@ -30,6 +30,17 @@ test('agent rejects unknown ops and any write/exec-shaped op', () => {
} }
}); });
test('agent rejects inherited Object.prototype members (no whitelist bypass via the prototype chain)', () => {
const agent = createAgent(stubCollectors());
for (const proto of ['constructor', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'toLocaleString']) {
const r = agent.handle(proto, {});
assert.equal(r.ok, false, `${proto} (inherited) must NOT be treated as an op`);
}
for (const bad of [null, undefined, 42, {}, ['read_log']]) {
assert.equal(agent.handle(bad, {}).ok, false, `non-string op ${JSON.stringify(bad)} must be rejected`);
}
});
test('agent maps each whitelisted op to its collector and is read-only only', () => { test('agent maps each whitelisted op to its collector and is read-only only', () => {
const stub = stubCollectors(); const stub = stubCollectors();
const agent = createAgent(stub); const agent = createAgent(stub);

View File

@ -63,6 +63,30 @@ test('readLog redacts a planted token and a Bearer line; doodstream is NOT reada
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash'); 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', () => { test('getQueueState flags stale=true for the persisted snapshot and counts by status', () => {
const { collectors } = makeFixture(); const { collectors } = makeFixture();
const q = collectors.getQueueState({}); const q = collectors.getQueueState({});

View File

@ -44,6 +44,37 @@ test('redactLogText leaves benign "token" prose alone', () => {
assert.equal(redactLogText(benign, []), benign); assert.equal(redactLogText(benign, []), benign);
}); });
test('redactLogText scrubs the password from a basic-auth URL but keeps host:port', () => {
const out = redactLogText('proxy https://admin:Sup3rProxyPass@proxy.internal:8080/path', []);
assert.ok(!out.includes('Sup3rProxyPass'), 'basic-auth password must be redacted');
assert.ok(out.includes('proxy.internal:8080'), 'host:port preserved');
assert.ok(out.includes('admin:'), 'username preserved');
});
test('redactLogText does not touch a host:port URL without userinfo', () => {
const url = 'connecting to https://cdn.voe.sx:8080/upload now';
assert.equal(redactLogText(url, []), url);
});
test('redactLogText scrubs Basic auth, JWTs and bare session= values (defense in depth)', () => {
const cases = [
{ line: 'Authorization: Basic dXNlcjpwYXNzd29yZDEyMw==', secret: 'dXNlcjpwYXNzd29yZDEyMw' },
{ line: 'jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N', secret: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0' },
{ line: 'session=SESSIONsecretvalue99887766', secret: 'SESSIONsecretvalue99887766' },
{ line: '"session":"jsonSessionSecret123456"', secret: 'jsonSessionSecret123456' },
];
for (const c of cases) {
const out = redactLogText(c.line, []);
assert.ok(!out.includes(c.secret), `must redact: ${c.line} -> ${out}`);
assert.ok(out.includes(REDACTED), `expected ${REDACTED} in ${out}`);
}
});
test('redactLogText leaves a normal "session" word in prose alone', () => {
const benign = 'the session was idle for a while';
assert.equal(redactLogText(benign, []), benign);
});
test('sanitizeConfig does not mutate input', () => { test('sanitizeConfig does not mutate input', () => {
const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } }; const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } };
const clone = JSON.parse(JSON.stringify(input)); const clone = JSON.parse(JSON.stringify(input));