fix(canvas): harden asset path resolution · openclaw/openclaw@6cfb62d
vincentkoc
·
2026-05-14
·
via Recent Commits to openclaw:main
File tree
extensions/canvas/src/host
| Original file line number | Diff line number | Diff line change |
|---|
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
|
12 | 12 | ### Fixes |
13 | 13 | |
14 | 14 | - iOS: restore first-use Contacts, Calendar, and Reminders permission prompts and add Privacy & Access status/actions in Settings. Thanks @BunsDev. |
| 15 | +- Canvas: return not found for malformed percent-encoded Canvas/A2UI asset paths and keep decoded parent traversal blocked before path normalization. |
15 | 16 | - 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. |
16 | 17 | - 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. |
17 | 18 | - 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. |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -47,7 +47,15 @@ describe("resolveFileWithinRoot", () => {
|
47 | 47 | |
48 | 48 | it("rejects traversal paths", async () => { |
49 | 49 | await withCanvasTemp("openclaw-canvas-resolver-", async (root) => { |
| 50 | +await fs.writeFile(path.join(root, "outside.txt"), "inside-root", "utf8"); |
50 | 51 | await expect(resolveFileWithinRoot(root, "/../outside.txt")).resolves.toBeNull(); |
| 52 | +await expect(resolveFileWithinRoot(root, "/%2e%2e%2foutside.txt")).resolves.toBeNull(); |
| 53 | +}); |
| 54 | +}); |
| 55 | + |
| 56 | +it("rejects malformed URL encoding as a missing file", async () => { |
| 57 | +await withCanvasTemp("openclaw-canvas-resolver-", async (root) => { |
| 58 | +await expect(resolveFileWithinRoot(root, "/%E0%A4%A")).resolves.toBeNull(); |
51 | 59 | }); |
52 | 60 | }); |
53 | 61 | |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -9,11 +9,46 @@ export function normalizeUrlPath(rawPath: string): string {
|
9 | 9 | return normalized.startsWith("/") ? normalized : `/${normalized}`; |
10 | 10 | } |
11 | 11 | |
| 12 | +function pathEscapesRoot(decodedPath: string): boolean { |
| 13 | +let depth = 0; |
| 14 | +for (const segment of decodedPath.split("/")) { |
| 15 | +if (segment === "" || segment === ".") { |
| 16 | +continue; |
| 17 | +} |
| 18 | +if (segment === "..") { |
| 19 | +if (depth === 0) { |
| 20 | +return true; |
| 21 | +} |
| 22 | +depth--; |
| 23 | +continue; |
| 24 | +} |
| 25 | +depth++; |
| 26 | +} |
| 27 | +return false; |
| 28 | +} |
| 29 | + |
| 30 | +function tryNormalizeUrlPath(rawPath: string): string | null { |
| 31 | +let decoded: string; |
| 32 | +try { |
| 33 | +decoded = decodeURIComponent(rawPath || "/"); |
| 34 | +} catch { |
| 35 | +return null; |
| 36 | +} |
| 37 | +if (pathEscapesRoot(decoded)) { |
| 38 | +return null; |
| 39 | +} |
| 40 | +const normalized = path.posix.normalize(decoded); |
| 41 | +return normalized.startsWith("/") ? normalized : `/${normalized}`; |
| 42 | +} |
| 43 | + |
12 | 44 | export async function resolveFileWithinRoot( |
13 | 45 | rootReal: string, |
14 | 46 | urlPath: string, |
15 | 47 | ): Promise<CanvasOpenResult | null> { |
16 | | -const normalized = normalizeUrlPath(urlPath); |
| 48 | +const normalized = tryNormalizeUrlPath(urlPath); |
| 49 | +if (normalized === null) { |
| 50 | +return null; |
| 51 | +} |
17 | 52 | const rel = normalized.replace(/^\/+/, ""); |
18 | 53 | if (rel.split("/").some((p) => p === "..")) { |
19 | 54 | return null; |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -232,6 +232,10 @@ describe("canvas host", () => {
|
232 | 232 | expect(response.body).toContain("v1"); |
233 | 233 | expect(response.body).toContain(CANVAS_WS_PATH); |
234 | 234 | |
| 235 | +const malformed = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/%E0%A4%A`); |
| 236 | +expect(malformed.status).toBe(404); |
| 237 | +expect(malformed.body).toBe("not found"); |
| 238 | + |
235 | 239 | const miss = await captureHandlerResponse(handler, "/"); |
236 | 240 | expect(miss.handled).toBe(false); |
237 | 241 | |
@@ -396,6 +400,9 @@ describe("canvas host", () => {
|
396 | 400 | const traversalRes = await captureA2uiResponse(`${A2UI_PATH}/%2e%2e%2fpackage.json`); |
397 | 401 | expect(traversalRes.status).toBe(404); |
398 | 402 | expect(traversalRes.body).toBe("not found"); |
| 403 | +const malformedRes = await captureA2uiResponse(`${A2UI_PATH}/%E0%A4%A`); |
| 404 | +expect(malformedRes.status).toBe(404); |
| 405 | +expect(malformedRes.body).toBe("not found"); |
399 | 406 | const symlinkRes = await captureA2uiResponse(`${A2UI_PATH}/${linkName}`); |
400 | 407 | expect(symlinkRes.status).toBe(404); |
401 | 408 | expect(symlinkRes.body).toBe("not found"); |
|
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。