Config: sanitize terminal-rendered validation errors

This commit is contained in:
Gustavo Madeira Santana
2026-03-02 14:29:34 -05:00
parent 00215f76f7
commit 741c57eaf8
5 changed files with 66 additions and 2 deletions

View File

@@ -35,6 +35,18 @@ describe("config issue format", () => {
).toEqual(["× <root>: 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({

View File

@@ -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(

View File

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

View File

@@ -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,

20
src/terminal/safe-text.ts Normal file
View File

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