diff --git a/typescript/.changeset/olive-donkeys-repeat.md b/typescript/.changeset/olive-donkeys-repeat.md new file mode 100644 index 000000000..ef0a30875 --- /dev/null +++ b/typescript/.changeset/olive-donkeys-repeat.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": minor +--- + +Removed local filesystem reads from the flaunch and zora action providers. The `image` parameter previously treated any non-URL string as a local file path, read it off the agent host, and uploaded the contents to a third-party IPFS pinning service. It now accepts only remote URLs (`http(s)://` for flaunch, `https://` or `ipfs://` for zora) or a `data:` URI. To publish a local file, read it yourself and pass a data URI: `` image: `data:image/png;base64,${fs.readFileSync(path, "base64")}` `` diff --git a/typescript/agentkit/src/action-providers/flaunch/README.md b/typescript/agentkit/src/action-providers/flaunch/README.md index 5cb169b31..a8603a31b 100644 --- a/typescript/agentkit/src/action-providers/flaunch/README.md +++ b/typescript/agentkit/src/action-providers/flaunch/README.md @@ -32,7 +32,7 @@ flaunch/ - **Input**: - `name` (string): The name of the token - `symbol` (string): The symbol of the token - - `image` (string): Local image file path or URL to the token image + - `image` (string): HTTP(S) URL of the token image (a `data:` URI is also accepted) - `description` (string): Description of the token - `fairLaunchPercent` (number, optional): The percentage of tokens for fair launch (defaults to 60%) - `fairLaunchDuration` (number, optional): The duration of the fair launch in minutes (defaults to 30 minutes) @@ -134,4 +134,10 @@ The provider interacts with several key contracts: - Fee allocation can be split between creator and additional recipients - Premine percentage cannot exceed the fair launch percentage - Initial market cap is set in USD and converted to appropriate token pricing -- The provider supports both local image files and URLs for token images +- Token images must be given as an `http(s)://` URL. Local filesystem paths are not supported: + the image is uploaded to a third-party API and pinned to public IPFS, so reading + caller-supplied paths would let an agent exfiltrate arbitrary files from the host. To + publish a local file, read it yourself and pass a `data:` URI: + ```typescript + image: `data:image/png;base64,${fs.readFileSync("./logo.png", "base64")}`; + ``` diff --git a/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.test.ts b/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.test.ts index 45c6c91b4..8c450ee88 100644 --- a/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.test.ts @@ -244,6 +244,30 @@ describe("FlaunchActionProvider", () => { expect(parseResult.success).toBe(false); }); + it.each(["/root/.env", "/proc/self/environ", "../../etc/passwd", "./logo.png", "logo.png"])( + "should reject local file path %s as an image", + path => { + const parseResult = FlaunchSchema.safeParse({ + name: "Test Token", + symbol: "TEST", + image: path, + description: "A test token", + websiteUrl: null, + discordUrl: null, + twitterUrl: null, + telegramUrl: null, + fairLaunchPercent: null, + fairLaunchDuration: null, + initialMarketCapUSD: null, + creatorFeeAllocationPercent: null, + creatorSplitPercent: null, + splitReceivers: null, + preminePercent: null, + }); + expect(parseResult.success).toBe(false); + }, + ); + it("should validate buyCoinWithETHInput schema", () => { const validInput = { coinAddress: "0x1234567890123456789012345678901234567890", diff --git a/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.ts b/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.ts index 5bf2cdce7..617947780 100644 --- a/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.ts +++ b/typescript/agentkit/src/action-providers/flaunch/flaunchActionProvider.ts @@ -81,7 +81,7 @@ This tool allows launching a new memecoin using the flaunch protocol. It takes: - name: The name of the token - symbol: The symbol of the token -- image: Local image file path or URL to the token image +- image: HTTP(S) URL of the token image - description: Description of the token - fairLaunchPercent: The percentage of tokens for fair launch (defaults to 60%) - fairLaunchDuration: The duration of the fair launch in minutes (defaults to 30 minutes) diff --git a/typescript/agentkit/src/action-providers/flaunch/metadata_utils.test.ts b/typescript/agentkit/src/action-providers/flaunch/metadata_utils.test.ts new file mode 100644 index 000000000..9c0eafd2a --- /dev/null +++ b/typescript/agentkit/src/action-providers/flaunch/metadata_utils.test.ts @@ -0,0 +1,90 @@ +import { generateTokenUri } from "./metadata_utils"; + +describe("generateTokenUri", () => { + const fetchMock = jest.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + const buildParams = (image: string) => ({ + metadata: { + image, + description: "A test token", + }, + }); + + describe("local filesystem paths", () => { + // Regression: the image is uploaded to a third-party API and pinned to public IPFS. + // Reading caller-supplied paths here would let an agent exfiltrate arbitrary host files. + const localPaths = [ + "/root/.env", + "/proc/self/environ", + "../../etc/passwd", + "~/.aws/credentials", + "./logo.png", + "logo.png", + "file:///etc/passwd", + ]; + + it.each(localPaths)("rejects %s", async path => { + await expect(generateTokenUri("Test", "TEST", buildParams(path))).rejects.toThrow( + "Reading images from the local filesystem is not supported", + ); + }); + + it("does not make any network request when given a local path", async () => { + await expect(generateTokenUri("Test", "TEST", buildParams("/root/.env"))).rejects.toThrow(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("accepted image sources", () => { + it("accepts a data URI and forwards it to the upload API unchanged", async () => { + const dataUri = "data:image/png;base64,aGVsbG8="; + + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true, ipfsHash: "imageHash" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true, ipfsHash: "metadataHash" }), + }); + + const uri = await generateTokenUri("Test", "TEST", buildParams(dataUri)); + + expect(uri).toBe("ipfs://metadataHash"); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ base64Image: dataUri }); + }); + + it("accepts an https URL", async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + headers: { get: () => "image/png" }, + arrayBuffer: async () => new TextEncoder().encode("hello").buffer, + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true, ipfsHash: "imageHash" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true, ipfsHash: "metadataHash" }), + }); + + const uri = await generateTokenUri( + "Test", + "TEST", + buildParams("https://example.com/image.png"), + ); + + expect(uri).toBe("ipfs://metadataHash"); + expect(fetchMock.mock.calls[0][0]).toBe("https://example.com/image.png"); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/flaunch/metadata_utils.ts b/typescript/agentkit/src/action-providers/flaunch/metadata_utils.ts index d048755fc..b29d0123b 100644 --- a/typescript/agentkit/src/action-providers/flaunch/metadata_utils.ts +++ b/typescript/agentkit/src/action-providers/flaunch/metadata_utils.ts @@ -1,6 +1,3 @@ -import fs from "fs"; -import path from "path"; - /** * Upload response from Flaunch API */ @@ -43,37 +40,6 @@ interface TokenUriParams { }; } -/** - * Reads a local file and converts it to base64 - * - * @param imageFileName - Path to the local file - * @returns Base64 encoded file and mime type - */ -async function readFileAsBase64( - imageFileName: string, -): Promise<{ base64: string; mimeType: string }> { - return new Promise((resolve, reject) => { - fs.readFile(imageFileName, (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(); - let mimeType = "application/octet-stream"; // default - - if (extension === ".png") mimeType = "image/png"; - else if (extension === ".jpg" || extension === ".jpeg") mimeType = "image/jpeg"; - else if (extension === ".gif") mimeType = "image/gif"; - else if (extension === ".svg") mimeType = "image/svg+xml"; - - const base64 = data.toString("base64"); - resolve({ base64, mimeType }); - }); - }); -} - /** * Uploads a base64 image to IPFS using Flaunch API * Rate Limit: Maximum 4 image uploads per minute per IP address @@ -248,16 +214,24 @@ const generateTokenUriBase64Image = async (name: string, symbol: string, params: }; export const generateTokenUri = async (name: string, symbol: string, params: TokenUriParams) => { - // 1. get base64Image from image (url or local path) + // 1. get base64Image from image (remote url or data uri) let base64Image: string; const image = params.metadata.image; if (image.startsWith("https://") || image.startsWith("http://")) { base64Image = await convertImageUrlToBase64(image); + } else if (image.startsWith("data:")) { + base64Image = image; } else { - // assume local file - const { base64, mimeType } = await readFileAsBase64(image); - base64Image = `data:${mimeType};base64,${base64}`; + // Local filesystem paths are intentionally not supported: the image is uploaded to a + // third party and pinned to public IPFS, so reading caller-supplied paths here would + // let an agent exfiltrate arbitrary host files. Callers that want to publish a local + // file must read it themselves and pass a data URI. + throw new Error( + "Invalid image: expected an http(s):// URL or a data: URI. Reading images from the " + + "local filesystem is not supported. To publish a local file, read it yourself and " + + 'pass a data URI, e.g. `data:image/png;base64,${fs.readFileSync(path, "base64")}`.', + ); } // 2. generate token uri diff --git a/typescript/agentkit/src/action-providers/flaunch/schemas.ts b/typescript/agentkit/src/action-providers/flaunch/schemas.ts index f98c851c7..64504bda2 100644 --- a/typescript/agentkit/src/action-providers/flaunch/schemas.ts +++ b/typescript/agentkit/src/action-providers/flaunch/schemas.ts @@ -14,7 +14,13 @@ export const FlaunchSchema = z .object({ name: z.string().min(1).describe("The name of the token to flaunch"), symbol: z.string().min(1).describe("The symbol of the token to flaunch"), - image: z.string().describe("Local image file path or URL to the token image"), + image: z + .string() + .refine(val => /^https?:\/\//.test(val) || val.startsWith("data:"), { + message: + "image must be an http(s):// URL or a data: URI. Local file paths are not supported.", + }) + .describe("HTTP(S) URL of the token image"), description: z.string().describe("Description of the token"), websiteUrl: z.string().nullable().describe("URL to the token website"), discordUrl: z.string().nullable().describe("URL to the token Discord"), diff --git a/typescript/agentkit/src/action-providers/zora/README.md b/typescript/agentkit/src/action-providers/zora/README.md index f0ce130ff..3fb97699b 100644 --- a/typescript/agentkit/src/action-providers/zora/README.md +++ b/typescript/agentkit/src/action-providers/zora/README.md @@ -23,7 +23,7 @@ zora/ - `name`: The name of the coin to create - `symbol`: The symbol of the coin to create - `description`: The description of the coin - - `image`: Local image file path or URI (ipfs:// or https://) + - `image`: Image URI for the coin (`ipfs://` or `https://`) - `category` (optional): The category of the coin, defaults to 'social' - `payoutRecipient` (optional): The address that will receive creator earnings, defaults to wallet address - `platformReferrer` (optional): Platform referrer address that earns referral fees @@ -51,4 +51,13 @@ The Zora provider supports the following networks: ## Notes +Coin images must be given as an `https://` URL or an `ipfs://` URI. Local filesystem paths are +not supported: the image is uploaded to Pinata and pinned to public IPFS, so reading +caller-supplied paths would let an agent exfiltrate arbitrary files from the host. To publish a +local file, read it yourself and pass a `data:` URI: + +```typescript +image: `data:image/png;base64,${fs.readFileSync("./logo.png", "base64")}`; +``` + For more information on the **Zora protocol**, visit [Zora Documentation](https://docs.zora.co/coins). \ No newline at end of file diff --git a/typescript/agentkit/src/action-providers/zora/schemas.ts b/typescript/agentkit/src/action-providers/zora/schemas.ts index baf16f943..32ca4c212 100644 --- a/typescript/agentkit/src/action-providers/zora/schemas.ts +++ b/typescript/agentkit/src/action-providers/zora/schemas.ts @@ -5,7 +5,16 @@ 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() + .refine( + val => val.startsWith("https://") || val.startsWith("ipfs://") || val.startsWith("data:"), + { + message: + "image must be an https:// URL, an ipfs:// URI, or a data: URI. Local file paths are not supported.", + }, + ) + .describe("Image URI for the coin (ipfs:// or https://)"), 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..0e51694b6 --- /dev/null +++ b/typescript/agentkit/src/action-providers/zora/utils.test.ts @@ -0,0 +1,113 @@ +import { generateZoraTokenUri } from "./utils"; + +describe("generateZoraTokenUri", () => { + const fetchMock = jest.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + const buildParams = (image: string) => ({ + name: "Test Coin", + symbol: "TEST", + description: "A test coin", + image, + pinataConfig: { jwt: "test-jwt" }, + }); + + const mockPinataJsonUpload = () => + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + IpfsHash: "metadataHash", + PinSize: 1, + Timestamp: "2026-01-01T00:00:00Z", + }), + }); + + describe("local filesystem paths", () => { + // Regression: the image is uploaded to Pinata and pinned to public IPFS. Reading + // caller-supplied paths here would let an agent exfiltrate arbitrary host files. + const localPaths = [ + "/root/.env", + "/proc/self/environ", + "../../etc/passwd", + "~/.aws/credentials", + "./logo.png", + "logo.png", + "file:///etc/passwd", + "http://example.com/image.png", + ]; + + it.each(localPaths)("rejects %s", async path => { + await expect(generateZoraTokenUri(buildParams(path))).rejects.toThrow( + "Reading images from the local filesystem is not supported", + ); + }); + + it("does not make any network request when given a local path", async () => { + await expect(generateZoraTokenUri(buildParams("/root/.env"))).rejects.toThrow(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("accepted image sources", () => { + it("passes through an ipfs:// URI without uploading the image", async () => { + mockPinataJsonUpload(); + + const result = await generateZoraTokenUri(buildParams("ipfs://existingImageCID")); + + expect(result).toEqual({ + uri: "ipfs://metadataHash", + imageUri: "ipfs://existingImageCID", + }); + // Only the metadata upload; the image itself is never re-uploaded. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe("https://api.pinata.cloud/pinning/pinJSONToIPFS"); + }); + + it("passes through an https:// URL without uploading the image", async () => { + mockPinataJsonUpload(); + + const result = await generateZoraTokenUri(buildParams("https://example.com/image.png")); + + expect(result.imageUri).toBe("https://example.com/image.png"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("uploads a data URI to Pinata and uses the resulting CID", async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + IpfsHash: "imageHash", + PinSize: 1, + Timestamp: "2026-01-01T00:00:00Z", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + IpfsHash: "metadataHash", + PinSize: 1, + Timestamp: "2026-01-01T00:00:00Z", + }), + }); + + const result = await generateZoraTokenUri(buildParams("data:image/png;base64,aGVsbG8=")); + + expect(result).toEqual({ uri: "ipfs://metadataHash", imageUri: "ipfs://imageHash" }); + expect(fetchMock.mock.calls[0][0]).toBe("https://api.pinata.cloud/pinning/pinFileToIPFS"); + }); + + it("rejects a malformed data URI", async () => { + await expect(generateZoraTokenUri(buildParams("data:image/png,notbase64"))).rejects.toThrow( + "Invalid data URI", + ); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/zora/utils.ts b/typescript/agentkit/src/action-providers/zora/utils.ts index 8f155a379..d56898ac1 100644 --- a/typescript/agentkit/src/action-providers/zora/utils.ts +++ b/typescript/agentkit/src/action-providers/zora/utils.ts @@ -1,6 +1,3 @@ -import fs from "fs"; -import path from "path"; - /** * Configuration for Pinata */ @@ -42,40 +39,34 @@ interface TokenUriParams { name: string; symbol: string; description: string; - image: string; // Can be a local file path or a URI (https:// or ipfs://) + image: string; // A URI (https:// or ipfs://) or a data: URI category?: string; pinataConfig: PinataConfig; } +const MIME_TO_EXTENSION: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/svg+xml": "svg", +}; + /** - * Reads a local file and converts it to base64 + * Parses a base64-encoded data URI into its mime type and payload. * - * @param imageFileName - Path to the local file - * @returns Base64 encoded file and mime type + * @param dataUri - A data URI of the form `data:;base64,` + * @returns The decoded mime type and base64 payload */ -async function readFileAsBase64( - imageFileName: string, -): Promise<{ base64: string; mimeType: string }> { - return new Promise((resolve, reject) => { - fs.readFile(imageFileName, (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(); - let mimeType = "application/octet-stream"; // default +function parseBase64DataUri(dataUri: string): { base64: string; mimeType: string } { + const match = dataUri.match(/^data:([^;,]+);base64,([\s\S]*)$/); - if (extension === ".png") mimeType = "image/png"; - else if (extension === ".jpg" || extension === ".jpeg") mimeType = "image/jpeg"; - else if (extension === ".gif") mimeType = "image/gif"; - else if (extension === ".svg") mimeType = "image/svg+xml"; + if (!match) { + throw new Error( + "Invalid data URI: expected the form `data:;base64,` (e.g. data:image/png;base64,...).", + ); + } - const base64 = data.toString("base64"); - resolve({ base64, mimeType }); - }); - }); + return { mimeType: match[1], base64: match[2] }; } /** @@ -112,9 +103,10 @@ async function uploadFileToIPFS(params: { } const blob = new Blob(byteArrays, { type: params.mimeType }); - const file = new File([blob], params.fileName, { type: params.mimeType }); - formData.append("file", file); + // Appending the Blob with a filename rather than wrapping it in a `File`: `File` is not a + // global on Node 18, which this package still supports. + formData.append("file", blob, params.fileName); const pinataMetadata = { name: params.fileName, @@ -207,7 +199,7 @@ async function uploadJsonToIPFS(params: { } /** - * Generates a Zora token URI by handling local file or URI + * Generates a Zora token URI from a remote URI or a data URI * * @param params - Parameters for generating the token URI * @returns A promise that resolves to object containing the IPFS URI @@ -222,10 +214,12 @@ 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 { - // Handle local file - const { base64, mimeType } = await readFileAsBase64(params.image); - const fileName = path.basename(params.image); + } else if (params.image.startsWith("data:")) { + // Handle inline image data. Local filesystem paths are intentionally not supported: + // the image is uploaded to a third party and pinned to public IPFS, so reading + // caller-supplied paths here would let an agent exfiltrate arbitrary host files. + const { base64, mimeType } = parseBase64DataUri(params.image); + const fileName = `${params.symbol}.${MIME_TO_EXTENSION[mimeType] ?? "bin"}`; const imageRes = await uploadFileToIPFS({ pinataConfig: params.pinataConfig, @@ -235,6 +229,12 @@ export async function generateZoraTokenUri(params: TokenUriParams): Promise<{ }); imageUri = `ipfs://${imageRes.IpfsHash}`; + } else { + throw new Error( + "Invalid image: expected an https:// URL, an ipfs:// URI, or a data: URI. Reading " + + "images from the local filesystem is not supported. To publish a local file, read " + + 'it yourself and pass a data URI, e.g. `data:image/png;base64,${fs.readFileSync(path, "base64")}`.', + ); } // Create and upload the metadata diff --git a/typescript/agentkit/src/action-providers/zora/zoraActionProvider.test.ts b/typescript/agentkit/src/action-providers/zora/zoraActionProvider.test.ts index 98db4a488..e54f88687 100644 --- a/typescript/agentkit/src/action-providers/zora/zoraActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/zora/zoraActionProvider.test.ts @@ -139,6 +139,23 @@ describe("ZoraActionProvider", () => { expect(parseResult.success).toBe(false); }); + it.each(["/root/.env", "/proc/self/environ", "../../etc/passwd", "./logo.png", "logo.png"])( + "should reject local file path %s as an image", + path => { + const parseResult = CreateCoinSchema.safeParse({ + name: "Test Coin", + symbol: "TEST", + description: "A test coin", + image: path, + category: "social", + currency: "ZORA" as const, + payoutRecipient: null, + platformReferrer: null, + }); + expect(parseResult.success).toBe(false); + }, + ); + it("should successfully create a coin", async () => { const args = { name: "Test Coin", diff --git a/typescript/agentkit/src/action-providers/zora/zoraActionProvider.ts b/typescript/agentkit/src/action-providers/zora/zoraActionProvider.ts index f5bd8d4db..14d8e7a3b 100644 --- a/typescript/agentkit/src/action-providers/zora/zoraActionProvider.ts +++ b/typescript/agentkit/src/action-providers/zora/zoraActionProvider.ts @@ -44,7 +44,7 @@ This tool will create a new Zora coin. It takes the following parameters: - name: The name of the coin - symbol: The symbol of the coin -- image: Local image file path or URI (ipfs:// or https://) +- image: Image URI for the coin (ipfs:// or https://) - description: The description of the coin - payoutRecipient: The address that will receive creator earnings (optional, defaults to the wallet address) - platformReferrer: The address that will receive platform referrer fees (optional, defaults to 0x0000000000000000000000000000000000000000) @@ -59,7 +59,7 @@ The action will return the transaction hash, coin address, and deployment detail args: z.infer, ): Promise { try { - // Generate token URI from local file or URI + // Generate token URI from a remote URI or data URI const { uri, imageUri } = await generateZoraTokenUri({ name: args.name, symbol: args.symbol,