diff --git a/packages/cli/package.json b/packages/cli/package.json index 266eb7e4..0544e7d8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.30.5", + "version": "0.30.6", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/prompt-file-security.test.ts b/packages/cli/src/__tests__/prompt-file-security.test.ts index 37d943cd..e7c1632f 100644 --- a/packages/cli/src/__tests__/prompt-file-security.test.ts +++ b/packages/cli/src/__tests__/prompt-file-security.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { validatePromptFilePath, validatePromptFileStats } from "../security.js"; +import { stripControlChars, validatePromptFilePath, validatePromptFileStats } from "../security.js"; describe("validatePromptFilePath", () => { it("should accept normal text file paths", () => { @@ -158,6 +158,45 @@ describe("validatePromptFilePath", () => { expect(() => validatePromptFilePath(symlink)).not.toThrow(); }); }); + + it("should reject paths containing ANSI escape sequences", () => { + expect(() => validatePromptFilePath("\x1b[2J\x1b[Hfake.txt")).toThrow("control characters"); + expect(() => validatePromptFilePath("file\x1b[31mred.txt")).toThrow("control characters"); + }); + + it("should reject paths containing null bytes", () => { + expect(() => validatePromptFilePath("file\x00.txt")).toThrow("control characters"); + }); + + it("should reject paths containing other control characters", () => { + expect(() => validatePromptFilePath("file\x07bell.txt")).toThrow("control characters"); + expect(() => validatePromptFilePath("file\x08backspace.txt")).toThrow("control characters"); + expect(() => validatePromptFilePath("file\x7Fdel.txt")).toThrow("control characters"); + }); +}); + +describe("stripControlChars", () => { + it("should strip ANSI escape sequences", () => { + expect(stripControlChars("\x1b[2J\x1b[Hfake.txt")).toBe("[2J[Hfake.txt"); + }); + + it("should strip null bytes", () => { + expect(stripControlChars("file\x00.txt")).toBe("file.txt"); + }); + + it("should strip bell, backspace, and DEL", () => { + expect(stripControlChars("file\x07\x08\x7F.txt")).toBe("file.txt"); + }); + + it("should preserve tabs and newlines", () => { + expect(stripControlChars("line1\nline2\ttab")).toBe("line1\nline2\ttab"); + }); + + it("should return normal strings unchanged", () => { + expect(stripControlChars("/tmp/prompt.txt")).toBe("/tmp/prompt.txt"); + expect(stripControlChars("")).toBe(""); + expect(stripControlChars("hello world")).toBe("hello world"); + }); }); describe("validatePromptFileStats", () => { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2b96a308..7e1a49a2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -317,19 +317,24 @@ async function suggestCloudsForPrompt(agent: string): Promise { /** Print a descriptive error for a failed prompt file read and exit */ function handlePromptFileError(promptFile: string, err: unknown): never { + // SECURITY: Strip control characters to prevent terminal injection via crafted paths. + // validatePromptFilePath() rejects these early, but this is defense-in-depth for + // error paths that run before validation (e.g., stat failures). + // Inline the same regex from security.ts to avoid async import in a sync function. + const safePath = promptFile.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, ""); const errObj = toRecord(err); const code = isString(errObj?.code) ? errObj.code : ""; if (code === "ENOENT") { - console.error(pc.red(`Prompt file not found: ${pc.bold(promptFile)}`)); + console.error(pc.red(`Prompt file not found: ${pc.bold(safePath)}`)); console.error("\nCheck the path and try again."); } else if (code === "EACCES") { - console.error(pc.red(`Permission denied reading prompt file: ${pc.bold(promptFile)}`)); - console.error(`\nCheck file permissions: ${pc.cyan(`ls -la ${promptFile}`)}`); + console.error(pc.red(`Permission denied reading prompt file: ${pc.bold(safePath)}`)); + console.error(`\nCheck file permissions: ${pc.cyan(`ls -la ${safePath}`)}`); } else if (code === "EISDIR") { - console.error(pc.red(`'${promptFile}' is a directory, not a file.`)); + console.error(pc.red(`'${safePath}' is a directory, not a file.`)); console.error("\nProvide a path to a text file containing your prompt."); } else { - console.error(pc.red(`Error reading prompt file '${promptFile}': ${getErrorMessage(err)}`)); + console.error(pc.red(`Error reading prompt file '${safePath}': ${getErrorMessage(err)}`)); } process.exit(1); } diff --git a/packages/cli/src/security.ts b/packages/cli/src/security.ts index 504b9bc9..1f557ae8 100644 --- a/packages/cli/src/security.ts +++ b/packages/cli/src/security.ts @@ -553,6 +553,18 @@ export function validateTunnelPort(port: string): void { } } +/** + * Strip ASCII control characters from a string for safe terminal display. + * Removes characters 0x00-0x1F and 0x7F, preserving tab (0x09) and newline (0x0A). + * SECURITY-CRITICAL: Prevents ANSI escape sequence injection in error messages. + * + * @param s - The string to sanitize + * @returns The string with control characters removed + */ +export function stripControlChars(s: string): string { + return s.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, ""); +} + // Sensitive path patterns that should never be read as prompt files // These protect credentials and system files from accidental exfiltration const SENSITIVE_PATH_PATTERNS: ReadonlyArray<{ @@ -632,6 +644,16 @@ export function validatePromptFilePath(filePath: string): void { ); } + // Reject paths containing control characters (ANSI escape sequences, null bytes, etc.) + // These can cause terminal injection when displayed in error messages. + if (/[\x00-\x08\x0B-\x1F\x7F]/.test(filePath)) { + throw new Error( + "Prompt file path contains control characters (e.g., ANSI escape sequences).\n\n" + + "File paths must be plain text without terminal control codes.\n" + + "Check that the path was entered correctly.", + ); + } + // Normalize the path to resolve .. and textual tricks let resolved = resolve(filePath);