diff --git a/CHANGELOG.md b/CHANGELOG.md index 057a4997e65..c0d512e9c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh. - Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates. - Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path. +- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization. - Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark. - macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots. - Agents/compaction: preserve safeguard compaction summary language continuity via default and configurable custom instructions so persona drift is reduced after auto-compaction. (#10456) Thanks @keepitmello. diff --git a/src/security/external-content.test.ts b/src/security/external-content.test.ts index b943bdacf72..bdf8af0de46 100644 --- a/src/security/external-content.test.ts +++ b/src/security/external-content.test.ts @@ -236,6 +236,27 @@ describe("external-content security", () => { expect(result).not.toContain(endMarker); } }); + + it.each([ + ["U+200B zero width space", "\u200B"], + ["U+200C zero width non-joiner", "\u200C"], + ["U+200D zero width joiner", "\u200D"], + ["U+2060 word joiner", "\u2060"], + ["U+FEFF zero width no-break space", "\uFEFF"], + ["U+00AD soft hyphen", "\u00AD"], + ])("sanitizes boundary markers split by %s", (_name, ignorable) => { + const startMarker = `<<>>`; + const endMarker = `<<>>`; + const result = wrapWebContent( + `Before ${startMarker} middle ${endMarker} after`, + "web_search", + ); + + expect(result).toContain("[[MARKER_SANITIZED]]"); + expect(result).toContain("[[END_MARKER_SANITIZED]]"); + expect(result).not.toContain(startMarker); + expect(result).not.toContain(endMarker); + }); }); describe("buildSafeExternalPrompt", () => { diff --git a/src/security/external-content.ts b/src/security/external-content.ts index ff571871b5e..1c8a3dfb1b9 100644 --- a/src/security/external-content.ts +++ b/src/security/external-content.ts @@ -151,10 +151,18 @@ function foldMarkerChar(char: string): string { return char; } +const MARKER_IGNORABLE_CHAR_RE = /\u200B|\u200C|\u200D|\u2060|\uFEFF|\u00AD/g; + function foldMarkerText(input: string): string { - return input.replace( - /[\uFF21-\uFF3A\uFF41-\uFF5A\uFF1C\uFF1E\u2329\u232A\u3008\u3009\u2039\u203A\u27E8\u27E9\uFE64\uFE65\u00AB\u00BB\u300A\u300B\u27EA\u27EB\u27EC\u27ED\u27EE\u27EF\u276C\u276D\u276E\u276F\u02C2\u02C3]/g, - (char) => foldMarkerChar(char), + return ( + input + // Strip invisible format characters that can split marker tokens without changing + // how downstream models interpret the apparent boundary text. + .replace(MARKER_IGNORABLE_CHAR_RE, "") + .replace( + /[\uFF21-\uFF3A\uFF41-\uFF5A\uFF1C\uFF1E\u2329\u232A\u3008\u3009\u2039\u203A\u27E8\u27E9\uFE64\uFE65\u00AB\u00BB\u300A\u300B\u27EA\u27EB\u27EC\u27ED\u27EE\u27EF\u276C\u276D\u276E\u276F\u02C2\u02C3]/g, + (char) => foldMarkerChar(char), + ) ); }