From a94c80e509fca63a28870ce06b5d7911f6f9d51e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 2 Aug 2026 10:05:14 -0700 Subject: [PATCH 1/2] fix(security): validate snapshot base64 linearly Signed-off-by: Aaron Erickson --- .../snapshot-sanitizer-failure.test.ts | 37 +++++++++++++++++++ .../shared/snapshot-sanitizer-boundary.cts | 27 +++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts b/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts index 95dc6c747ea..d75673343f5 100644 --- a/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts +++ b/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts @@ -183,6 +183,43 @@ describe("migration snapshot sanitizer fallbacks", () => { ).toBe(true); }); + it("decodes a maximum-size canonical helper payload without overflowing", () => { + const raw = "a".repeat(16 * 1024 * 1024); + const encoded = Buffer.from(raw, "utf-8").toString("base64"); + + expect(decodeDescriptorSnapshotContent(encoded)).toBe(raw); + }); + + it("rejects large malformed and oversized helper payloads without overflowing", () => { + const largeCanonical = Buffer.alloc(4 * 1024 * 1024, 0x61).toString("base64"); + const malformed = `${largeCanonical.slice(0, -4)}AA=A`; + const oversized = Buffer.alloc(16 * 1024 * 1024 + 1, 0x61).toString("base64"); + + expect(decodeDescriptorSnapshotContent(malformed)).toBeNull(); + expect(decodeDescriptorSnapshotContent(oversized)).toBeNull(); + }); + + it("preserves canonical base64 and UTF-8 boundary rules", () => { + expect(decodeDescriptorSnapshotContent("")).toBe(""); + expect(decodeDescriptorSnapshotContent("Zg==")).toBe("f"); + expect(decodeDescriptorSnapshotContent("Zm8=")).toBe("fo"); + expect(decodeDescriptorSnapshotContent("Zm9v")).toBe("foo"); + expect(decodeDescriptorSnapshotContent("aGVsbG8=")).toBe("hello"); + for (const rejected of [ + "A", + "AAAAA", + "AA=A", + "A===", + "====", + "YWJj=", + "AB==", + "/w==", + "YWJj\n", + ]) { + expect(decodeDescriptorSnapshotContent(rejected)).toBeNull(); + } + }); + it("fails closed when sanitized output cannot be installed", () => { const configPath = path.join(makeRoot(), "openclaw.json"); const original = JSON.stringify({ apiKey: "sk-secret-value" }); diff --git a/nemoclaw/src/shared/snapshot-sanitizer-boundary.cts b/nemoclaw/src/shared/snapshot-sanitizer-boundary.cts index 8adfe1e41b1..3201cb1b6c2 100644 --- a/nemoclaw/src/shared/snapshot-sanitizer-boundary.cts +++ b/nemoclaw/src/shared/snapshot-sanitizer-boundary.cts @@ -7,6 +7,8 @@ import path from "node:path"; const HELPER_TIMEOUT_MS = 60_000; const HELPER_MAX_BUFFER_BYTES = 48 * 1024 * 1024; +const MAX_SNAPSHOT_FILE_BYTES = 16 * 1024 * 1024; +const MAX_SNAPSHOT_FILE_BASE64_LENGTH = Math.ceil(MAX_SNAPSHOT_FILE_BYTES / 3) * 4; const TRUSTED_PYTHON_LOCATIONS = [ "/usr/bin/python3", "/usr/local/bin/python3", @@ -123,7 +125,7 @@ import secrets import stat import sys -MAX_FILE_BYTES = 16 * 1024 * 1024 +MAX_FILE_BYTES = ${MAX_SNAPSHOT_FILE_BYTES} MAX_TOTAL_BYTES = 32 * 1024 * 1024 MAX_ENTRIES = 100_000 O_DIRECTORY = getattr(os, "O_DIRECTORY", 0) @@ -679,11 +681,32 @@ export function applyDescriptorSnapshotActions( export function decodeDescriptorSnapshotContent(content: string | undefined): string | null { if ( content === undefined || - !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(content) + content.length % 4 !== 0 || + content.length > MAX_SNAPSHOT_FILE_BASE64_LENGTH ) { return null; } + const paddingLength = content.endsWith("==") ? 2 : content.endsWith("=") ? 1 : 0; + const unpaddedLength = content.length - paddingLength; + for (let index = 0; index < unpaddedLength; index += 1) { + const code = content.charCodeAt(index); + if ( + !( + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a) || + (code >= 0x30 && code <= 0x39) || + code === 0x2b || + code === 0x2f + ) + ) { + return null; + } + } + for (let index = unpaddedLength; index < content.length; index += 1) { + if (content.charCodeAt(index) !== 0x3d) return null; + } const decoded = Buffer.from(content, "base64"); + if (decoded.length > MAX_SNAPSHOT_FILE_BYTES) return null; if (decoded.toString("base64") !== content) return null; const utf8 = decoded.toString("utf-8"); if (!Buffer.from(utf8, "utf-8").equals(decoded)) return null; From 19cfe1a7626c2078109a95240ba75113befa3ebd Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 2 Aug 2026 11:45:05 -0700 Subject: [PATCH 2/2] fix(security): validate snapshot apply payloads Signed-off-by: Aaron Erickson --- .../snapshot-sanitizer-failure.test.ts | 21 +++++++++++++++++++ .../shared/snapshot-sanitizer-boundary.cts | 5 +++++ 2 files changed, 26 insertions(+) diff --git a/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts b/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts index d75673343f5..f6d44b9d89b 100644 --- a/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts +++ b/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts @@ -213,6 +213,7 @@ describe("migration snapshot sanitizer fallbacks", () => { "====", "YWJj=", "AB==", + "AAB=", "/w==", "YWJj\n", ]) { @@ -220,6 +221,26 @@ describe("migration snapshot sanitizer fallbacks", () => { } }); + it("rejects non-canonical base64 at the descriptor apply boundary", () => { + const rootPath = makeRoot(); + const configPath = path.join(rootPath, "config.json"); + writeFileSync(configPath, "original"); + const root = inspectDescriptorSnapshotRoot(rootPath)!; + const scan = scanDescriptorSnapshot(root, new Set())!; + const config = scan.files.find((file) => file.path === "config.json")!; + + expect(scan).not.toBeNull(); + expect(config).toBeDefined(); + for (const content of ["AB==", "AAB="]) { + expect( + applyDescriptorSnapshotActions(root, scan, [ + { kind: "replace", path: config.path, metadata: config.metadata, content }, + ]), + ).toBe(false); + expect(readFileSync(configPath, "utf-8")).toBe("original"); + } + }); + it("fails closed when sanitized output cannot be installed", () => { const configPath = path.join(makeRoot(), "openclaw.json"); const original = JSON.stringify({ apiKey: "sk-secret-value" }); diff --git a/nemoclaw/src/shared/snapshot-sanitizer-boundary.cts b/nemoclaw/src/shared/snapshot-sanitizer-boundary.cts index 3201cb1b6c2..28a909e7301 100644 --- a/nemoclaw/src/shared/snapshot-sanitizer-boundary.cts +++ b/nemoclaw/src/shared/snapshot-sanitizer-boundary.cts @@ -126,6 +126,7 @@ import stat import sys MAX_FILE_BYTES = ${MAX_SNAPSHOT_FILE_BYTES} +MAX_FILE_BASE64_LENGTH = ((MAX_FILE_BYTES + 2) // 3) * 4 MAX_TOTAL_BYTES = 32 * 1024 * 1024 MAX_ENTRIES = 100_000 O_DIRECTORY = getattr(os, "O_DIRECTORY", 0) @@ -500,10 +501,14 @@ def apply(root_path, plan): raw = action.get("content") if not isinstance(raw, str): fail("snapshot replacement content is invalid") + if len(raw) > MAX_FILE_BASE64_LENGTH: + fail("snapshot replacement content exceeds the encoded size limit") try: payload = base64.b64decode(raw, validate=True) except ValueError: fail("snapshot replacement content is invalid") + if base64.b64encode(payload).decode("ascii") != raw: + fail("snapshot replacement content is not canonical base64") if len(payload) > MAX_FILE_BYTES: fail("snapshot replacement content exceeds the size limit") replace_file(parent_fd, name, expected, payload)