mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-16 20:01:08 +00:00
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>
This commit is contained in:
parent
1dc5e43095
commit
0c4dc613b2
4 changed files with 73 additions and 7 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.30.5",
|
||||
"version": "0.30.6",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue