fix(llm-security): scanner robustness — ReDoS, MCP-stdout DoS, redirect loop, atomic writes (#24,#53,#31,#25,#51)
#24 the HTML-obfuscation injection patterns had overlapping unbounded runs plus a required closing quote, so a non-closing input backtracked O(N^2) (~28.7s at the 512KB cap); quantifiers bounded, pathological input now 4ms. #53 mcp-live-inspect buffered MCP-server stdout via readline with no cap, so a hostile stdio server could exhaust memory / throw an uncaught RangeError; replaced with manual line buffering capped at 4MB that rejects pending RPCs and destroys stdout. #31 vsix-fetch's same-host redirect follower had no depth cap (loop hang); added depth>=5 cap mirroring the sibling fetcher. #25/#51 mcp-description-cache and skill-registry wrote JSON via bare writeFileSync (non-atomic: concurrent load-modify-save loses updates, a torn read silently yields an empty registry); both now write a temp file then renameSync. Suite 1931/0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
This commit is contained in:
parent
f3aaf5479f
commit
207385fbbe
10 changed files with 251 additions and 19 deletions
|
|
@ -6,7 +6,6 @@
|
|||
// Zero external dependencies.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
|
|
@ -144,6 +143,10 @@ export function discoverMcpServers(targetPath, skipGlobal = false) {
|
|||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const PER_CALL_TIMEOUT_MS = 5_000;
|
||||
const KILL_GRACE_MS = 500;
|
||||
// v7.8.3 (#53): cap on a single buffered stdout line. readline has no
|
||||
// maxLength, so a hostile server emitting a giant newline-less line would
|
||||
// buffer unbounded (memory exhaustion / RangeError past MAX_STRING_LENGTH).
|
||||
const MAX_STDOUT_LINE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Create a JSON-RPC 2.0 session over a child process's stdin/stdout.
|
||||
|
|
@ -153,10 +156,10 @@ const KILL_GRACE_MS = 500;
|
|||
function createRpcSession(proc) {
|
||||
const pending = new Map();
|
||||
let nextId = 1;
|
||||
let lineBuf = '';
|
||||
let overflowed = false;
|
||||
|
||||
const rl = createInterface({ input: proc.stdout });
|
||||
|
||||
rl.on('line', (line) => {
|
||||
function handleLine(line) {
|
||||
if (!line.trim()) return;
|
||||
let msg;
|
||||
try { msg = JSON.parse(line); } catch { return; }
|
||||
|
|
@ -171,6 +174,31 @@ function createRpcSession(proc) {
|
|||
res(msg.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Manual line buffering with a byte cap (#53) instead of readline —
|
||||
// readline buffers a newline-less line unbounded. On exceed: reject all
|
||||
// pending calls and destroy stdout so the flood stops.
|
||||
proc.stdout.setEncoding('utf8');
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
if (overflowed) return;
|
||||
lineBuf += chunk;
|
||||
let nl;
|
||||
while ((nl = lineBuf.indexOf('\n')) !== -1) {
|
||||
const line = lineBuf.slice(0, nl);
|
||||
lineBuf = lineBuf.slice(nl + 1);
|
||||
handleLine(line);
|
||||
}
|
||||
if (lineBuf.length > MAX_STDOUT_LINE_BYTES) {
|
||||
overflowed = true;
|
||||
lineBuf = '';
|
||||
const err = new Error(`stdout line exceeded ${MAX_STDOUT_LINE_BYTES}-byte cap`);
|
||||
for (const { reject: rej } of pending.values()) {
|
||||
rej(err);
|
||||
}
|
||||
pending.clear();
|
||||
try { proc.stdout.destroy(); } catch { /* already closed */ }
|
||||
}
|
||||
});
|
||||
|
||||
proc.stdout.on('close', () => {
|
||||
|
|
@ -206,7 +234,7 @@ function createRpcSession(proc) {
|
|||
}
|
||||
|
||||
function close() {
|
||||
rl.close();
|
||||
lineBuf = '';
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue