Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions nemoclaw/src/security/snapshot-sanitizer-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,64 @@ 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==",
"AAB=",
"/w==",
"YWJj\n",
]) {
Comment thread
ericksoa marked this conversation as resolved.
expect(decodeDescriptorSnapshotContent(rejected)).toBeNull();
}
});

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" });
Expand Down
32 changes: 30 additions & 2 deletions nemoclaw/src/shared/snapshot-sanitizer-boundary.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -123,7 +125,8 @@ import secrets
import stat
import sys

MAX_FILE_BYTES = 16 * 1024 * 1024
MAX_FILE_BYTES = ${MAX_SNAPSHOT_FILE_BYTES}
Comment thread
ericksoa marked this conversation as resolved.
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)
Expand Down Expand Up @@ -498,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)
Expand Down Expand Up @@ -679,11 +686,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;
Expand Down
Loading