From b73d01f13bb2040f0665b035e2b06d9df7b4cc53 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 14 May 2026 13:22:59 +0800 Subject: [PATCH] fix(canvas): reject malformed document paths --- CHANGELOG.md | 2 +- extensions/canvas/src/documents.test.ts | 22 ++++++++++++++++++++++ extensions/canvas/src/documents.ts | 21 +++++++++++---------- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81e85eab869..605f4821781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Docs: https://docs.openclaw.ai - CLI tables: preserve muted/color styling on wrapped continuation lines after multiline cells, keeping `openclaw plugins list` descriptions readable. - iOS: restore first-use Contacts, Calendar, and Reminders permission prompts and add Privacy & Access status/actions in Settings. Thanks @BunsDev. -- Canvas: return not found for malformed percent-encoded Canvas/A2UI asset paths and keep decoded parent traversal blocked before path normalization. +- Canvas: return not found for malformed percent-encoded Canvas/A2UI/document asset paths and keep decoded parent traversal blocked before path normalization. - Agents: allow dot-dot-prefixed filenames such as `..note.txt` through sandbox FS bridge, remote sandbox reads, and apply_patch summaries without mistaking the name for parent traversal. - CLI/migrate: humanize Codex conflict-status messaging across the migrate UI so selection prompts and plan/result rows say "Codex skill already installed in workspace" instead of surfacing internal `MIGRATION_REASON_*` codes. Thanks @sjf. - CLI/migrate: render migrate result rows with distinct glyphs for manual-review (🔍) and archive (📖) items instead of the misleading "skipped" and "migrated" checkmarks, so users can see which entries still need attention versus which were filed away. Thanks @sjf. diff --git a/extensions/canvas/src/documents.test.ts b/extensions/canvas/src/documents.test.ts index addd6aabc3f..8fa98ca8094 100644 --- a/extensions/canvas/src/documents.test.ts +++ b/extensions/canvas/src/documents.test.ts @@ -239,4 +239,26 @@ describe("canvas documents", () => { ), ).toBeNull(); }); + + it("rejects malformed encoded hosted canvas document paths", async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-")); + tempDirs.push(stateDir); + const documentId = "cv_malformed"; + const documentDir = resolveCanvasDocumentDir(documentId, { stateDir }); + await mkdir(documentDir, { recursive: true }); + await writeFile(path.join(documentDir, "%E0%A4%A.html"), "literal-percent-name", "utf8"); + + expect( + resolveCanvasHttpPathToLocalPath( + `/__openclaw__/canvas/documents/${documentId}/%E0%A4%A.html`, + { stateDir }, + ), + ).toBeNull(); + expect( + resolveCanvasHttpPathToLocalPath( + `/__openclaw__/canvas/documents/${documentId}/%25E0%25A4%25A.html`, + { stateDir }, + ), + ).toBe(path.join(documentDir, "%E0%A4%A.html")); + }); }); diff --git a/extensions/canvas/src/documents.ts b/extensions/canvas/src/documents.ts index 046e9e2a934..32dcfc5af0d 100644 --- a/extensions/canvas/src/documents.ts +++ b/extensions/canvas/src/documents.ts @@ -153,16 +153,17 @@ export function resolveCanvasHttpPathToLocalPath( } const pathWithoutQuery = trimmed.replace(/[?#].*$/, ""); const relative = pathWithoutQuery.slice(prefix.length); - const segments = relative - .split("/") - .map((segment) => { - try { - return decodeURIComponent(segment); - } catch { - return segment; - } - }) - .filter(Boolean); + const segments: string[] = []; + for (const segment of relative.split("/")) { + if (!segment) { + continue; + } + try { + segments.push(decodeURIComponent(segment)); + } catch { + return null; + } + } if (segments.length < 2) { return null; }