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:
Kjell Tore Guttormsen 2026-07-18 10:15:11 +02:00
commit 207385fbbe
10 changed files with 251 additions and 19 deletions

View file

@ -109,13 +109,18 @@ export const HIGH_PATTERNS = [
{ pattern: /<!--\s*(?:AGENT|AI|HIDDEN|ACTUAL\s+TASK|REAL\s+INSTRUCTION)\s*:/i, label: 'hidden comment: agent-directed HTML comment' },
// --- Content Injection: CSS/HTML obfuscation (AI Agent Traps) ---
{ pattern: /<[^>]+style\s*=\s*"[^"]*display\s*:\s*none[^"]*"[^>]*>/i, label: 'html-obfuscation: display:none element with content' },
{ pattern: /<[^>]+style\s*=\s*"[^"]*visibility\s*:\s*hidden[^"]*"[^>]*>/i, label: 'html-obfuscation: visibility:hidden element' },
{ pattern: /<[^>]+style\s*=\s*"[^"]*position\s*:\s*absolute[^"]*-\d{3,}px[^"]*"[^>]*>/i, label: 'html-obfuscation: off-screen positioned element' },
{ pattern: /<[^>]+style\s*=\s*"[^"]*font-size\s*:\s*0[^"]*"[^>]*>/i, label: 'html-obfuscation: zero font-size element' },
{ pattern: /<[^>]+style\s*=\s*"[^"]*opacity\s*:\s*0[^"]*"[^>]*>/i, label: 'html-obfuscation: zero opacity element' },
{ pattern: /<[^>]+style\s*=\s*"[^"]*(?:height|width)\s*:\s*0[^"]*overflow\s*:\s*hidden[^"]*"[^>]*>/i, label: 'html-obfuscation: zero-size overflow-hidden element' },
{ pattern: /aria-label\s*=\s*"[^"]*(?:ignore|override|system|instruction|execute|exfiltrate)[^"]*"/i, label: 'html-obfuscation: injection in aria-label attribute' },
// v7.8.3 (#24): quantifiers bounded ({1,256}/{0,256}) — the unbounded
// overlapping [^"]* runs plus the required closing quote backtracked
// O(N^2)/O(N^3) when an attacker omitted the closing quote (~27s at the
// 512KB hook read cap). 256 chars comfortably covers legitimate inline
// style/aria-label attributes.
{ pattern: /<[^>]{1,256}style\s*=\s*"[^"]{0,256}display\s*:\s*none[^"]{0,256}"[^>]{0,256}>/i, label: 'html-obfuscation: display:none element with content' },
{ pattern: /<[^>]{1,256}style\s*=\s*"[^"]{0,256}visibility\s*:\s*hidden[^"]{0,256}"[^>]{0,256}>/i, label: 'html-obfuscation: visibility:hidden element' },
{ pattern: /<[^>]{1,256}style\s*=\s*"[^"]{0,256}position\s*:\s*absolute[^"]{0,256}-\d{3,}px[^"]{0,256}"[^>]{0,256}>/i, label: 'html-obfuscation: off-screen positioned element' },
{ pattern: /<[^>]{1,256}style\s*=\s*"[^"]{0,256}font-size\s*:\s*0[^"]{0,256}"[^>]{0,256}>/i, label: 'html-obfuscation: zero font-size element' },
{ pattern: /<[^>]{1,256}style\s*=\s*"[^"]{0,256}opacity\s*:\s*0[^"]{0,256}"[^>]{0,256}>/i, label: 'html-obfuscation: zero opacity element' },
{ pattern: /<[^>]{1,256}style\s*=\s*"[^"]{0,256}(?:height|width)\s*:\s*0[^"]{0,256}overflow\s*:\s*hidden[^"]{0,256}"[^>]{0,256}>/i, label: 'html-obfuscation: zero-size overflow-hidden element' },
{ pattern: /aria-label\s*=\s*"[^"]{0,256}(?:ignore|override|system|instruction|execute|exfiltrate)[^"]{0,256}"/i, label: 'html-obfuscation: injection in aria-label attribute' },
// --- Semantic Manipulation: Oversight & Critic Evasion (AI Agent Traps) ---
{ pattern: /for\s+educational\s+purposes?\s+only/i, label: 'evasion: educational purpose framing' },

View file

@ -22,7 +22,7 @@
//
// OWASP: MCP05 (Tool Description Manipulation / Rug Pull)
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { levenshtein } from './string-utils.mjs';
@ -144,7 +144,12 @@ export function saveCache(cache, opts = {}) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(cacheFile, JSON.stringify(cache, null, 2), 'utf-8');
// Atomic write (v7.8.3 #25): temp file + rename so a concurrent
// load-modify-save never reads a torn file (which parses as {} and
// silently drops every baseline).
const tmpPath = `${cacheFile}.tmp-${process.pid}-${Date.now()}`;
writeFileSync(tmpPath, JSON.stringify(cache, null, 2), 'utf-8');
renameSync(tmpPath, cacheFile);
} catch {
// Silently fail — drift detection is advisory, not critical
}

View file

@ -4,7 +4,7 @@
// Zero external dependencies.
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, renameSync } from 'node:fs';
import { join, resolve, relative, dirname, basename, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -259,7 +259,11 @@ export function saveRegistry(registry, pluginRoot) {
registry.updated = new Date().toISOString();
registry.entry_count = Object.keys(registry.entries).length;
writeFileSync(filePath, JSON.stringify(registry, null, 2) + '\n');
// Atomic write (v7.8.3 #51): temp file + rename — a torn write would
// parse-fail in loadRegistry and silently reset to an empty registry.
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
writeFileSync(tmpPath, JSON.stringify(registry, null, 2) + '\n');
renameSync(tmpPath, filePath);
return filePath;
}

View file

@ -266,7 +266,7 @@ export async function fetchDirectVsix(url) {
};
}
async function httpsFetchSameHost(url, sourceHost) {
async function httpsFetchSameHost(url, sourceHost, depth = 0) {
const u = new URL(url);
if (u.protocol !== 'https:') {
throw new Error(`refusing non-HTTPS URL: ${url}`);
@ -282,7 +282,10 @@ async function httpsFetchSameHost(url, sourceHost) {
const loc = res.headers.get('location');
if (!loc) throw new Error(`HTTP ${res.status} without Location header`);
const next = new URL(loc, url).toString();
return httpsFetchSameHost(next, sourceHost);
// Cap redirect depth — mirrors httpsFetch (v7.8.3 #31): a same-host
// redirect loop would otherwise recurse forever.
if (depth >= 5) throw new Error('too many redirects');
return httpsFetchSameHost(next, sourceHost, depth + 1);
}
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
const out = await readBodyCapped(res, controller);

View file

@ -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();
}