Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions typescript/.changeset/olive-donkeys-repeat.md
Original file line number Diff line number Diff line change
@@ -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")}` ``
10 changes: 8 additions & 2 deletions typescript/agentkit/src/action-providers/flaunch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")}`;
```
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import fs from "fs";
import path from "path";

/**
* Upload response from Flaunch API
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
11 changes: 10 additions & 1 deletion typescript/agentkit/src/action-providers/zora/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
11 changes: 10 additions & 1 deletion typescript/agentkit/src/action-providers/zora/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading