diff --git a/src/config/issue-format.test.ts b/src/config/issue-format.test.ts index 2a163852e0f..b69a7e7a6cf 100644 --- a/src/config/issue-format.test.ts +++ b/src/config/issue-format.test.ts @@ -35,6 +35,18 @@ describe("config issue format", () => { ).toEqual(["× : first", "× channels.signal.dmPolicy: second"]); }); + it("sanitizes control characters and ANSI sequences in formatted lines", () => { + expect( + formatConfigIssueLine( + { + path: "gateway.\nbind\x1b[31m", + message: "bad\r\n\tvalue\x1b[0m\u0007", + }, + "-", + ), + ).toBe("- gateway.\\nbind: bad\\r\\n\\tvalue"); + }); + it("normalizes issue metadata for machine output", () => { expect( normalizeConfigIssue({ diff --git a/src/config/issue-format.ts b/src/config/issue-format.ts index 87ebeb0d7c5..69b88d93cdf 100644 --- a/src/config/issue-format.ts +++ b/src/config/issue-format.ts @@ -1,3 +1,4 @@ +import { sanitizeTerminalText } from "../terminal/safe-text.js"; import type { ConfigValidationIssue } from "./types.js"; type ConfigIssueLineInput = { @@ -52,7 +53,9 @@ export function formatConfigIssueLine( opts?: ConfigIssueFormatOptions, ): string { const prefix = marker ? `${marker} ` : ""; - return `${prefix}${resolveIssuePathForLine(issue.path, opts)}: ${issue.message}`; + const path = sanitizeTerminalText(resolveIssuePathForLine(issue.path, opts)); + const message = sanitizeTerminalText(issue.message); + return `${prefix}${path}: ${message}`; } export function formatConfigIssueLines( diff --git a/src/plugins/schema-validator.test.ts b/src/plugins/schema-validator.test.ts index ef63f4f4c12..7f2b849d774 100644 --- a/src/plugins/schema-validator.test.ts +++ b/src/plugins/schema-validator.test.ts @@ -182,4 +182,30 @@ describe("schema validator", () => { expect(issue?.message).toContain("... (+"); } }); + + it("sanitizes terminal text while preserving structured fields", () => { + const maliciousProperty = "evil\nkey\t\x1b[31mred\x1b[0m"; + const res = validateJsonSchemaValue({ + cacheKey: "schema-validator.test.terminal-sanitize", + schema: { + type: "object", + properties: {}, + required: [maliciousProperty], + }, + value: {}, + }); + + expect(res.ok).toBe(false); + if (!res.ok) { + const issue = res.errors[0]; + expect(issue).toBeDefined(); + expect(issue?.path).toContain("\n"); + expect(issue?.message).toContain("\n"); + expect(issue?.text).toContain("\\n"); + expect(issue?.text).toContain("\\t"); + expect(issue?.text).not.toContain("\n"); + expect(issue?.text).not.toContain("\t"); + expect(issue?.text).not.toContain("\x1b"); + } + }); }); diff --git a/src/plugins/schema-validator.ts b/src/plugins/schema-validator.ts index 0fea2c14bf4..af64be10147 100644 --- a/src/plugins/schema-validator.ts +++ b/src/plugins/schema-validator.ts @@ -1,6 +1,7 @@ import { createRequire } from "node:module"; import type { ErrorObject, ValidateFunction } from "ajv"; import { appendAllowedValuesHint, summarizeAllowedValues } from "../config/allowed-values.js"; +import { sanitizeTerminalText } from "../terminal/safe-text.js"; const require = createRequire(import.meta.url); type AjvLike = { @@ -113,10 +114,12 @@ function formatAjvErrors(errors: ErrorObject[] | null | undefined): JsonSchemaVa const message = allowedValuesSummary ? appendAllowedValuesHint(baseMessage, allowedValuesSummary) : baseMessage; + const safePath = sanitizeTerminalText(path); + const safeMessage = sanitizeTerminalText(message); return { path, message, - text: `${path}: ${message}`, + text: `${safePath}: ${safeMessage}`, ...(allowedValuesSummary ? { allowedValues: allowedValuesSummary.values, diff --git a/src/terminal/safe-text.ts b/src/terminal/safe-text.ts new file mode 100644 index 00000000000..15cd19ffd01 --- /dev/null +++ b/src/terminal/safe-text.ts @@ -0,0 +1,20 @@ +import { stripAnsi } from "./ansi.js"; + +/** + * Normalize untrusted text for single-line terminal/log rendering. + */ +export function sanitizeTerminalText(input: string): string { + const normalized = stripAnsi(input) + .replace(/\r/g, "\\r") + .replace(/\n/g, "\\n") + .replace(/\t/g, "\\t"); + let sanitized = ""; + for (const char of normalized) { + const code = char.charCodeAt(0); + const isControl = (code >= 0x00 && code <= 0x1f) || code === 0x7f; + if (!isControl) { + sanitized += char; + } + } + return sanitized; +}