From afbd6e74e63236580f44a8d018a7dbcad3c85dad Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Sun, 9 Aug 2026 15:15:06 +0700 Subject: [PATCH] fix(zora): confine local image paths and reject non-https remotes Agent-controlled Zora image fell through to fs.readFile for anything that was not ipfs:// or https://, including absolute paths and http:// URLs, before Pinata upload. Twin of flaunch #1424: cwd realpath confine + scheme guard. --- .../src/action-providers/zora/schemas.ts | 6 ++- .../src/action-providers/zora/utils.test.ts | 41 +++++++++++++++++++ .../src/action-providers/zora/utils.ts | 28 +++++++++++-- 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 typescript/agentkit/src/action-providers/zora/utils.test.ts diff --git a/typescript/agentkit/src/action-providers/zora/schemas.ts b/typescript/agentkit/src/action-providers/zora/schemas.ts index baf16f943..548c96512 100644 --- a/typescript/agentkit/src/action-providers/zora/schemas.ts +++ b/typescript/agentkit/src/action-providers/zora/schemas.ts @@ -5,7 +5,11 @@ export const CreateCoinSchema = z name: z.string().describe("The name of the coin to create"), symbol: z.string().describe("The symbol of the coin to create"), description: z.string().describe("The description of the coin"), - image: z.string().describe("Local image file path or URI (ipfs:// or https://)"), + image: z + .string() + .describe( + "HTTPS or ipfs:// URI of the coin image, or a local image path under the working directory", + ), category: z .string() .nullable() diff --git a/typescript/agentkit/src/action-providers/zora/utils.test.ts b/typescript/agentkit/src/action-providers/zora/utils.test.ts new file mode 100644 index 000000000..b228a159d --- /dev/null +++ b/typescript/agentkit/src/action-providers/zora/utils.test.ts @@ -0,0 +1,41 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { generateZoraTokenUri, resolveSafeLocalImagePath } from "./utils"; + +describe("zora image path / URI guards", () => { + describe("resolveSafeLocalImagePath", () => { + it("allows files under cwd and rejects escapes", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "zora-img-")); + const prev = process.cwd(); + try { + process.chdir(tmp); + const inside = path.join(tmp, "token.png"); + fs.writeFileSync(inside, "x"); + expect(resolveSafeLocalImagePath("token.png")).toBe(fs.realpathSync(inside)); + expect(() => resolveSafeLocalImagePath("../outside.png")).toThrow(/working directory/i); + expect(() => resolveSafeLocalImagePath("/etc/passwd")).toThrow(/working directory/i); + } finally { + process.chdir(prev); + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + }); + + describe("generateZoraTokenUri", () => { + const pinataConfig = { jwt: "test-jwt" }; + + it("rejects non-https remote schemes before reading as a local file", async () => { + await expect( + generateZoraTokenUri({ + name: "t", + symbol: "T", + description: "d", + image: "http://127.0.0.1/secret.png", + pinataConfig, + }), + ).rejects.toThrow(/https:\/\/ or ipfs:\/\//i); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/zora/utils.ts b/typescript/agentkit/src/action-providers/zora/utils.ts index 8f155a379..ef82dfea4 100644 --- a/typescript/agentkit/src/action-providers/zora/utils.ts +++ b/typescript/agentkit/src/action-providers/zora/utils.ts @@ -47,6 +47,24 @@ interface TokenUriParams { pinataConfig: PinataConfig; } +/** + * Resolve a local image path and require it to stay under process.cwd() + * (realpath), so agent-supplied paths cannot read arbitrary files for Pinata. + * Twin of flaunch resolveSafeLocalImagePath (#1424). + */ +export function resolveSafeLocalImagePath(imageFileName: string): string { + const root = fs.realpathSync(process.cwd()); + const resolved = path.resolve(root, imageFileName); + if (resolved !== root && !resolved.startsWith(root + path.sep)) { + throw new Error("Local image path must be within the working directory"); + } + const real = fs.realpathSync(resolved); + if (real !== root && !real.startsWith(root + path.sep)) { + throw new Error("Local image path escapes the working directory"); + } + return real; +} + /** * Reads a local file and converts it to base64 * @@ -56,15 +74,16 @@ interface TokenUriParams { async function readFileAsBase64( imageFileName: string, ): Promise<{ base64: string; mimeType: string }> { + const safePath = resolveSafeLocalImagePath(imageFileName); return new Promise((resolve, reject) => { - fs.readFile(imageFileName, (err, data) => { + fs.readFile(safePath, (err, data) => { if (err) { reject(new Error(`Failed to read file: ${err.message}`)); return; } // Determine mime type based on file extension - const extension = path.extname(imageFileName).toLowerCase(); + const extension = path.extname(safePath).toLowerCase(); let mimeType = "application/octet-stream"; // default if (extension === ".png") mimeType = "image/png"; @@ -222,8 +241,11 @@ export async function generateZoraTokenUri(params: TokenUriParams): Promise<{ // Check if image is already a URI (ipfs:// or https://) if (params.image.startsWith("ipfs://") || params.image.startsWith("https://")) { imageUri = params.image; + } else if (params.image.includes("://")) { + // http:// and other schemes must not fall through to fs.readFile + throw new Error("Remote Zora images must use https:// or ipfs://"); } else { - // Handle local file + // Local file: path must resolve under process.cwd() const { base64, mimeType } = await readFileAsBase64(params.image); const fileName = path.basename(params.image);