fix(security): preserve UTF-16 token mask boundaries (#103843)

This commit is contained in:
Peter Steinberger
2026-07-10 18:20:05 +01:00
committed by GitHub
parent 8a93d288e2
commit c8ebfd8a3a
6 changed files with 95 additions and 12 deletions

View File

@@ -3,6 +3,9 @@
"version": "0.0.0-private",
"private": true,
"type": "module",
"dependencies": {
"@openclaw/normalization-core": "workspace:*"
},
"exports": {
"./runtime": "./src/runtime.ts",
"./runtime-core": "./src/runtime-core.ts",

View File

@@ -0,0 +1,23 @@
// Memory Host SDK tests cover error formatting and secret redaction.
import { describe, expect, it } from "vitest";
import { formatErrorMessage } from "./error-utils.js";
const TOKEN_CASES = [
["leading split", "abcde😀xxxxxxxxwxyz", "abcde...wxyz"],
["trailing split", "abcdefghijklm😀xyz", "abcdef...xyz"],
["intact leading pair", "abcd😀xxxxxxxxwxyz", "abcd😀...wxyz"],
["intact trailing pair", "abcdefghijklmn😀xy", "abcdef...😀xy"],
] as const;
describe("formatErrorMessage", () => {
it.each(TOKEN_CASES)("masks tokens with a UTF-16-safe %s", (_label, token, masked) => {
expect(formatErrorMessage(`TOKEN=${token}`)).toBe(`TOKEN=${masked}`);
});
it("replaces the captured value literally when key and value repeat", () => {
expect(formatErrorMessage("LONG_LONG_LONG_TOKEN=LONG_LONG_LONG_TOKEN")).toBe(
"LONG_LONG_LONG_TOKEN=LONG_L...OKEN",
);
expect(formatErrorMessage("TOKEN=$&abcdxxxxxxxxwxyz")).toBe("TOKEN=$&abcd...wxyz");
});
});

View File

@@ -45,7 +45,14 @@ function redactMatch(match: string, groups: string[]): string {
}
const token = groups.findLast((value) => typeof value === "string" && value.length > 0) ?? match;
const masked = maskToken(token);
return token === match ? masked : match.replace(token, masked);
if (token === match) {
return masked;
}
const tokenOffset = match.lastIndexOf(token);
if (tokenOffset < 0) {
return "***";
}
return `${match.slice(0, tokenOffset)}${masked}${match.slice(tokenOffset + token.length)}`;
}
function redactSensitiveText(text: string): string {

6
pnpm-lock.yaml generated
View File

@@ -1953,7 +1953,11 @@ importers:
packages/media-understanding-common: {}
packages/memory-host-sdk: {}
packages/memory-host-sdk:
dependencies:
'@openclaw/normalization-core':
specifier: workspace:*
version: link:../normalization-core
packages/model-catalog-core: {}

View File

@@ -55,4 +55,36 @@ describe("browser tool detail redaction", () => {
"OPENAI_API_KEY=sk-123...cdef",
);
});
it.each([
["leading split", "abcde😀xxxxxxxxwxyz", "abcde...wxyz"],
["trailing split", "abcdefghijklm😀xyz", "abcdef...xyz"],
["intact leading pair", "abcd😀xxxxxxxxwxyz", "abcd😀...wxyz"],
["intact trailing pair", "abcdefghijklmn😀xy", "abcdef...😀xy"],
])("masks tool payload tokens with a UTF-16-safe %s", (_label, token, masked) => {
expect(redactToolPayloadText(`{"token":"${token}"}`)).toBe(`{"token":"${masked}"}`);
});
it("does not trust mask-shaped input as already redacted", () => {
expect(redactToolPayloadText("TOKEN=abcde...wxyz")).toBe("TOKEN=abcde....wxyz");
});
it("redacts replacement-template text literally without changing surrounding text", () => {
const markerLike = "\u{e000}0\u{e001}";
expect(redactToolPayloadText(`${markerLike} TOKEN=$\`abcdxxxxxxxxwxyz`)).toBe(
`${markerLike} TOKEN=$\`abcd...wxyz`,
);
});
it("redacts the captured value when key and value repeat", () => {
expect(redactToolPayloadText("LONG_LONG_LONG_TOKEN=LONG_LONG_LONG_TOKEN")).toBe(
"LONG_LONG_LONG_TOKEN=LONG_L...OKEN",
);
});
it("prefers an outer credential match over a nested one", () => {
expect(redactToolPayloadText('cookie: "TOKEN=abcdefghijklmno&abcdefghij"')).toBe(
'cookie: "TOKEN=...ghij"',
);
});
});

View File

@@ -1,4 +1,6 @@
// Browser-safe redaction for tool details rendered by the Control UI.
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
const PAYMENT_CREDENTIAL_KEYS =
"card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|securityCode|payment[-_]?credential|paymentCredential|shared[-_]?payment[-_]?token|sharedPaymentToken";
@@ -52,7 +54,9 @@ function redactToken(value: string): string {
if (value.length <= 10) {
return "***";
}
return `${value.slice(0, 6)}...${value.slice(-4)}`;
// A 6/4-shaped hint is intentionally idempotent: every non-separator
// character is already inside the visible prefix or suffix budget.
return `${sliceUtf16Safe(value, 0, 6)}...${sliceUtf16Safe(value, -4)}`;
}
function redactPemBlock(block: string): string {
@@ -63,18 +67,28 @@ function redactPemBlock(block: string): string {
return `${lines[0]}\n...redacted...\n${lines[lines.length - 1]}`;
}
function redactMatch(match: string, groups: Array<string | undefined>): string {
if (match.includes("PRIVATE KEY-----")) {
return redactPemBlock(match);
}
const token = groups.findLast((group) => typeof group === "string" && group.length > 0) ?? match;
const masked = redactToken(token);
if (token === match) {
return masked;
}
const tokenOffset = match.lastIndexOf(token);
if (tokenOffset < 0) {
return "***";
}
return `${match.slice(0, tokenOffset)}${masked}${match.slice(tokenOffset + token.length)}`;
}
export function redactToolDetail(detail: string): string {
let redacted = detail;
for (const pattern of SECRET_DETAIL_PATTERNS) {
redacted = redacted.replace(pattern, (...args: string[]) => {
const match = args[0] ?? "";
if (match.includes("PRIVATE KEY-----")) {
return redactPemBlock(match);
}
const groups = args.slice(1, -2);
const token = groups.findLast((group) => typeof group === "string" && group.length > 0);
return token ? match.replace(token, redactToken(token)) : "***";
});
redacted = redacted.replace(pattern, (...args: string[]) =>
redactMatch(args[0] ?? "", args.slice(1, -2)),
);
}
return redacted;
}