From 145b71f57efce9948a0615bb9e6793d78a0bcf82 Mon Sep 17 00:00:00 2001 From: lsr911 Date: Thu, 9 Jul 2026 16:35:17 +0800 Subject: [PATCH] fix(oc-path): use truncateUtf16Safe for error message path truncation (#102527) * fix(oc-path): use truncateUtf16Safe for error message path truncation Replace naive .slice(0, 80) with truncateUtf16Safe() at three error-reporting call sites to prevent surrogate pair splitting in oc:// path validation error messages. Co-Authored-By: Claude * test(oc-path): cover utf16 error truncation --------- Co-authored-by: Claude Co-authored-by: Peter Steinberger --- extensions/oc-path/src/oc-path/oc-path.ts | 7 ++-- .../scenarios/security-and-limits.test.ts | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/extensions/oc-path/src/oc-path/oc-path.ts b/extensions/oc-path/src/oc-path/oc-path.ts index fbf8ce781b8..f1670633086 100644 --- a/extensions/oc-path/src/oc-path/oc-path.ts +++ b/extensions/oc-path/src/oc-path/oc-path.ts @@ -10,6 +10,7 @@ * @module @openclaw/oc-path/oc-path */ +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { OcEmitSentinelError, REDACTED_SENTINEL } from "./sentinel.js"; const OC_SCHEME = "oc://"; @@ -134,7 +135,7 @@ export function parseOcPath(input: string): OcPath { if (input.length > MAX_PATH_LENGTH) { fail( `oc:// path exceeds ${MAX_PATH_LENGTH} bytes (length: ${input.length})`, - input.slice(0, 80) + "…", + truncateUtf16Safe(input, 80) + "…", "OC_PATH_TOO_LONG", ); } @@ -147,7 +148,7 @@ export function parseOcPath(input: string): OcPath { if (normalized.length > MAX_PATH_LENGTH) { fail( `oc:// path exceeds ${MAX_PATH_LENGTH} bytes after NFC (length: ${normalized.length})`, - input.slice(0, 80) + "…", + truncateUtf16Safe(input, 80) + "…", "OC_PATH_TOO_LONG", ); } @@ -311,7 +312,7 @@ export function formatOcPath(path: OcPath): string { if (out.length > MAX_PATH_LENGTH) { fail( `Formatted oc:// exceeds ${MAX_PATH_LENGTH} bytes (length: ${out.length})`, - out.slice(0, 80) + "…", + truncateUtf16Safe(out, 80) + "…", "OC_PATH_TOO_LONG", ); } diff --git a/extensions/oc-path/src/oc-path/tests/scenarios/security-and-limits.test.ts b/extensions/oc-path/src/oc-path/tests/scenarios/security-and-limits.test.ts index df4739bc4a2..046cf34bbb0 100644 --- a/extensions/oc-path/src/oc-path/tests/scenarios/security-and-limits.test.ts +++ b/extensions/oc-path/src/oc-path/tests/scenarios/security-and-limits.test.ts @@ -13,6 +13,17 @@ import { import { parseJsonc } from "../../jsonc/parse.js"; import { parseJsonl } from "../../jsonl/parse.js"; +function expectUtf16SafeLimitError(run: () => unknown, expectedInput: string): void { + try { + run(); + expect.fail("expected an OC_PATH_TOO_LONG error"); + } catch (err) { + expect(err).toBeInstanceOf(OcPathError); + expect((err as OcPathError).code).toBe("OC_PATH_TOO_LONG"); + expect((err as OcPathError).input).toBe(expectedInput); + } +} + describe("encoding edges", () => { it("strips leading UTF-8 BOM from path string", () => { expect(parseOcPath("oc://X/Y").file).toBe("X"); @@ -77,6 +88,23 @@ describe("path-string and traversal caps", () => { expect(() => parseOcPath("oc://X/" + "a".repeat(MAX_PATH_LENGTH))).toThrow(/exceeds .* bytes/); }); + it("keeps overlong parse input UTF-16 safe", () => { + const prefix = `oc://${"a".repeat(74)}`; + expectUtf16SafeLimitError( + () => parseOcPath(`${prefix}😀${"b".repeat(MAX_PATH_LENGTH)}`), + `${prefix}…`, + ); + }); + + it("keeps post-NFC overlong parse input UTF-16 safe", () => { + const prefix = `oc://${"a".repeat(74)}`; + const input = `${prefix}😀${"\u0344".repeat(MAX_PATH_LENGTH - prefix.length - 2)}`; + expect(input).toHaveLength(MAX_PATH_LENGTH); + expect(input.normalize("NFC").length).toBeGreaterThan(MAX_PATH_LENGTH); + + expectUtf16SafeLimitError(() => parseOcPath(input), `${prefix}…`); + }); + it("parseOcPath accepts a path right at the cap", () => { const justUnder = "oc://X/" + "a".repeat(MAX_PATH_LENGTH - "oc://X/".length); expect(() => parseOcPath(justUnder)).not.toThrow(); @@ -88,6 +116,15 @@ describe("path-string and traversal caps", () => { ); }); + it("keeps overlong formatted paths UTF-16 safe", () => { + const sectionPrefix = "a".repeat(72); + expectUtf16SafeLimitError( + () => + formatOcPath({ file: "X", section: `${sectionPrefix}😀${"b".repeat(MAX_PATH_LENGTH)}` }), + `oc://X/${sectionPrefix}…`, + ); + }); + it("walker depth cap fires on synthetic deeply-nested AST", () => { // Bypasses parser depth cap so the walker defense fires in isolation. type V = import("../../jsonc/ast.js").JsoncValue;