fix: eliminate command injection and unhandled JSON.parse in LSP tool

- config.ts: Replace execSync(`which ${command}`) with spawnSync("which", [command])
  to prevent shell injection from malicious lsp.json config files
- client.ts: Wrap JSON.parse in parseMessage with try/catch and handle null messages
  in the stream reader to prevent process crashes from malformed LSP output

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-13 11:45:04 -06:00
parent 120ae367ad
commit 2c4f5de321
2 changed files with 19 additions and 11 deletions

View file

@ -164,7 +164,7 @@ const CLIENT_CAPABILITIES = {
function parseMessage(
buffer: Buffer,
): { message: LspJsonRpcResponse | LspJsonRpcNotification; remaining: Buffer } | null {
): { message: LspJsonRpcResponse | LspJsonRpcNotification | null; remaining: Buffer } | null {
const headerEndIndex = findHeaderEnd(buffer);
if (headerEndIndex === -1) return null;
@ -182,10 +182,15 @@ function parseMessage(
const messageText = new TextDecoder().decode(messageBytes);
const remaining = Buffer.from(buffer.subarray(messageEnd));
return {
message: JSON.parse(messageText),
remaining,
};
let message: LspJsonRpcResponse | LspJsonRpcNotification;
try {
message = JSON.parse(messageText);
} catch {
// Malformed JSON from LSP server — skip this message and advance past it
return { message: null, remaining };
}
return { message, remaining };
}
function findHeaderEnd(buffer: Uint8Array): number {
@ -239,6 +244,11 @@ async function startMessageReader(client: LspClient): Promise<void> {
const { message, remaining } = parsed;
workingBuffer = remaining;
if (!message) {
parsed = parseMessage(workingBuffer);
continue;
}
if ("id" in message && message.id !== undefined) {
const pending = client.pendingRequests.get(message.id);
if (pending) {

View file

@ -2,7 +2,7 @@ import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { execSync } from "node:child_process";
import { spawnSync } from "node:child_process";
import YAML from "yaml";
import { globSync } from "glob";
import { CONFIG_DIR_NAME } from "../../config.js";
@ -177,11 +177,9 @@ const LOCAL_BIN_PATHS: Array<{ markers: string[]; binDir: string }> = [
];
function which(command: string): string | null {
try {
return execSync(`which ${command}`, { encoding: "utf-8" }).trim() || null;
} catch {
return null;
}
const result = spawnSync("which", [command], { encoding: "utf-8" });
if (result.status !== 0) return null;
return result.stdout.trim() || null;
}
export function resolveCommand(command: string, cwd: string): string | null {