Skip to content

Commit 0c4dc61

Browse files
la14-1louisgvclaude
authored
fix(security): sanitize control characters in prompt file error messages (#3141)
Reject file paths containing ASCII control characters (ANSI escape sequences, null bytes, etc.) in validatePromptFilePath() to prevent terminal injection. Also strip control chars in handlePromptFileError() as defense-in-depth for error paths before validation. Fixes #3138 Agent: security-auditor Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 1dc5e43 commit 0c4dc61

4 files changed

Lines changed: 73 additions & 7 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openrouter/spawn",
3-
"version": "0.30.5",
3+
"version": "0.30.6",
44
"type": "module",
55
"bin": {
66
"spawn": "cli.js"

packages/cli/src/__tests__/prompt-file-security.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
22
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
33
import { join } from "node:path";
4-
import { validatePromptFilePath, validatePromptFileStats } from "../security.js";
4+
import { stripControlChars, validatePromptFilePath, validatePromptFileStats } from "../security.js";
55

66
describe("validatePromptFilePath", () => {
77
it("should accept normal text file paths", () => {
@@ -158,6 +158,45 @@ describe("validatePromptFilePath", () => {
158158
expect(() => validatePromptFilePath(symlink)).not.toThrow();
159159
});
160160
});
161+
162+
it("should reject paths containing ANSI escape sequences", () => {
163+
expect(() => validatePromptFilePath("\x1b[2J\x1b[Hfake.txt")).toThrow("control characters");
164+
expect(() => validatePromptFilePath("file\x1b[31mred.txt")).toThrow("control characters");
165+
});
166+
167+
it("should reject paths containing null bytes", () => {
168+
expect(() => validatePromptFilePath("file\x00.txt")).toThrow("control characters");
169+
});
170+
171+
it("should reject paths containing other control characters", () => {
172+
expect(() => validatePromptFilePath("file\x07bell.txt")).toThrow("control characters");
173+
expect(() => validatePromptFilePath("file\x08backspace.txt")).toThrow("control characters");
174+
expect(() => validatePromptFilePath("file\x7Fdel.txt")).toThrow("control characters");
175+
});
176+
});
177+
178+
describe("stripControlChars", () => {
179+
it("should strip ANSI escape sequences", () => {
180+
expect(stripControlChars("\x1b[2J\x1b[Hfake.txt")).toBe("[2J[Hfake.txt");
181+
});
182+
183+
it("should strip null bytes", () => {
184+
expect(stripControlChars("file\x00.txt")).toBe("file.txt");
185+
});
186+
187+
it("should strip bell, backspace, and DEL", () => {
188+
expect(stripControlChars("file\x07\x08\x7F.txt")).toBe("file.txt");
189+
});
190+
191+
it("should preserve tabs and newlines", () => {
192+
expect(stripControlChars("line1\nline2\ttab")).toBe("line1\nline2\ttab");
193+
});
194+
195+
it("should return normal strings unchanged", () => {
196+
expect(stripControlChars("/tmp/prompt.txt")).toBe("/tmp/prompt.txt");
197+
expect(stripControlChars("")).toBe("");
198+
expect(stripControlChars("hello world")).toBe("hello world");
199+
});
161200
});
162201

163202
describe("validatePromptFileStats", () => {

packages/cli/src/index.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -317,19 +317,24 @@ async function suggestCloudsForPrompt(agent: string): Promise<void> {
317317

318318
/** Print a descriptive error for a failed prompt file read and exit */
319319
function handlePromptFileError(promptFile: string, err: unknown): never {
320+
// SECURITY: Strip control characters to prevent terminal injection via crafted paths.
321+
// validatePromptFilePath() rejects these early, but this is defense-in-depth for
322+
// error paths that run before validation (e.g., stat failures).
323+
// Inline the same regex from security.ts to avoid async import in a sync function.
324+
const safePath = promptFile.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, "");
320325
const errObj = toRecord(err);
321326
const code = isString(errObj?.code) ? errObj.code : "";
322327
if (code === "ENOENT") {
323-
console.error(pc.red(`Prompt file not found: ${pc.bold(promptFile)}`));
328+
console.error(pc.red(`Prompt file not found: ${pc.bold(safePath)}`));
324329
console.error("\nCheck the path and try again.");
325330
} else if (code === "EACCES") {
326-
console.error(pc.red(`Permission denied reading prompt file: ${pc.bold(promptFile)}`));
327-
console.error(`\nCheck file permissions: ${pc.cyan(`ls -la ${promptFile}`)}`);
331+
console.error(pc.red(`Permission denied reading prompt file: ${pc.bold(safePath)}`));
332+
console.error(`\nCheck file permissions: ${pc.cyan(`ls -la ${safePath}`)}`);
328333
} else if (code === "EISDIR") {
329-
console.error(pc.red(`'${promptFile}' is a directory, not a file.`));
334+
console.error(pc.red(`'${safePath}' is a directory, not a file.`));
330335
console.error("\nProvide a path to a text file containing your prompt.");
331336
} else {
332-
console.error(pc.red(`Error reading prompt file '${promptFile}': ${getErrorMessage(err)}`));
337+
console.error(pc.red(`Error reading prompt file '${safePath}': ${getErrorMessage(err)}`));
333338
}
334339
process.exit(1);
335340
}

packages/cli/src/security.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,18 @@ export function validateTunnelPort(port: string): void {
553553
}
554554
}
555555

556+
/**
557+
* Strip ASCII control characters from a string for safe terminal display.
558+
* Removes characters 0x00-0x1F and 0x7F, preserving tab (0x09) and newline (0x0A).
559+
* SECURITY-CRITICAL: Prevents ANSI escape sequence injection in error messages.
560+
*
561+
* @param s - The string to sanitize
562+
* @returns The string with control characters removed
563+
*/
564+
export function stripControlChars(s: string): string {
565+
return s.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, "");
566+
}
567+
556568
// Sensitive path patterns that should never be read as prompt files
557569
// These protect credentials and system files from accidental exfiltration
558570
const SENSITIVE_PATH_PATTERNS: ReadonlyArray<{
@@ -632,6 +644,16 @@ export function validatePromptFilePath(filePath: string): void {
632644
);
633645
}
634646

647+
// Reject paths containing control characters (ANSI escape sequences, null bytes, etc.)
648+
// These can cause terminal injection when displayed in error messages.
649+
if (/[\x00-\x08\x0B-\x1F\x7F]/.test(filePath)) {
650+
throw new Error(
651+
"Prompt file path contains control characters (e.g., ANSI escape sequences).\n\n" +
652+
"File paths must be plain text without terminal control codes.\n" +
653+
"Check that the path was entered correctly.",
654+
);
655+
}
656+
635657
// Normalize the path to resolve .. and textual tricks
636658
let resolved = resolve(filePath);
637659

0 commit comments

Comments
 (0)