diff --git a/extensions/browser/src/browser/paths.test.ts b/extensions/browser/src/browser/paths.test.ts index 128b8e437cf..106c3b89750 100644 --- a/extensions/browser/src/browser/paths.test.ts +++ b/extensions/browser/src/browser/paths.test.ts @@ -324,6 +324,26 @@ describe("resolveExistingUploadPaths", () => { }); }); + it("rejects nested absolute inbound media paths", async () => { + await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => { + const nestedDir = path.join(inboundMediaDir, "nested"); + await fs.mkdir(nestedDir, { recursive: true }); + const nestedFile = path.join(nestedDir, "secret.pdf"); + await fs.writeFile(nestedFile, "secret", "utf8"); + + const result = await resolveExistingUploadPaths({ + uploadDir: uploadsDir, + inboundMediaDir, + requestedPaths: [nestedFile], + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("direct child of inbound media directory"); + } + }); + }); + it("rejects files outside both managed upload roots", async () => { await withFixtureRoot(async ({ baseDir, inboundMediaDir, uploadsDir }) => { const outsideFile = path.join(baseDir, "secret.txt"); @@ -458,6 +478,26 @@ describe("resolveStrictExistingUploadPaths", () => { } }); }); + + it("rejects nested absolute inbound media paths at use time", async () => { + await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => { + const nestedDir = path.join(inboundMediaDir, "nested"); + await fs.mkdir(nestedDir, { recursive: true }); + const nestedFile = path.join(nestedDir, "secret.pdf"); + await fs.writeFile(nestedFile, "secret", "utf8"); + + const result = await resolveStrictExistingUploadPaths({ + uploadDir: uploadsDir, + inboundMediaDir, + requestedPaths: [nestedFile], + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("direct child of inbound media directory"); + } + }); + }); }); describe("resolvePathWithinRoot", () => { diff --git a/extensions/browser/src/browser/paths.ts b/extensions/browser/src/browser/paths.ts index 96fefb084f7..2b612e4fd7b 100644 --- a/extensions/browser/src/browser/paths.ts +++ b/extensions/browser/src/browser/paths.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import path from "node:path"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { CONFIG_DIR } from "../utils.js"; @@ -130,6 +131,61 @@ function resolveManagedInboundMediaRefs(params: { return { ok: true, paths }; } +async function isDirectInboundMediaFile(params: { + inboundMediaDir: string; + resolvedPath: string; +}): Promise { + let inboundRoot: string; + try { + inboundRoot = await fs.realpath(params.inboundMediaDir); + } catch { + inboundRoot = path.resolve(params.inboundMediaDir); + } + const relativePath = path.relative(inboundRoot, params.resolvedPath); + return ( + Boolean(relativePath) && + relativePath !== ".." && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) && + !relativePath.includes("/") && + !relativePath.includes("\\") + ); +} + +async function resolveDirectInboundMediaPath(params: { + inboundMediaDir: string; + requestedPath: string; + strict: boolean; +}): Promise { + const inboundPathsResult = params.strict + ? await resolveStrictExistingPathsWithinRoot({ + rootDir: params.inboundMediaDir, + requestedPaths: [params.requestedPath], + scopeLabel: `inbound media directory (${params.inboundMediaDir})`, + }) + : await resolveExistingPathsWithinRoot({ + rootDir: params.inboundMediaDir, + requestedPaths: [params.requestedPath], + scopeLabel: `inbound media directory (${params.inboundMediaDir})`, + }); + if (!inboundPathsResult.ok) { + return inboundPathsResult; + } + const resolvedPath = inboundPathsResult.paths[0] ?? params.requestedPath; + if ( + !(await isDirectInboundMediaFile({ + inboundMediaDir: params.inboundMediaDir, + resolvedPath, + })) + ) { + return { + ok: false, + error: `Invalid media reference: must be a direct child of inbound media directory (${params.inboundMediaDir})`, + }; + } + return inboundPathsResult; +} + export async function resolveExistingUploadPaths({ requestedPaths, uploadDir = DEFAULT_UPLOAD_DIR, @@ -155,10 +211,10 @@ export async function resolveExistingUploadPaths({ continue; } - const inboundPathsResult = await resolveExistingPathsWithinRoot({ - rootDir: inboundMediaDir, - requestedPaths: [requestedPath], - scopeLabel: `inbound media directory (${inboundMediaDir})`, + const inboundPathsResult = await resolveDirectInboundMediaPath({ + inboundMediaDir, + requestedPath, + strict: false, }); if (!inboundPathsResult.ok) { return inboundPathsResult; @@ -193,10 +249,10 @@ export async function resolveStrictExistingUploadPaths({ continue; } - const inboundPathsResult = await resolveStrictExistingPathsWithinRoot({ - rootDir: inboundMediaDir, - requestedPaths: [requestedPath], - scopeLabel: `inbound media directory (${inboundMediaDir})`, + const inboundPathsResult = await resolveDirectInboundMediaPath({ + inboundMediaDir, + requestedPath, + strict: true, }); if (!inboundPathsResult.ok) { return inboundPathsResult;