fix(security): sanitize control characters in prompt file error messages (#3141)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run

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>
This commit is contained in:
A 2026-04-01 06:38:43 -07:00 committed by GitHub
parent 1dc5e43095
commit 0c4dc613b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 73 additions and 7 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.30.5",
"version": "0.30.6",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -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", () => {

View file

@ -317,19 +317,24 @@ async function suggestCloudsForPrompt(agent: string): Promise<void> {
/** 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);
}

View file

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