From bbaf26524752e7ad72cf3add4c6bbabd0fc1acae Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Sun, 9 Aug 2026 15:01:14 +0700 Subject: [PATCH] fix(flaunch): fail closed on unsafe token image URLs and paths Agent-controlled image input used bare fetch (SSRF) or fs.readFile (arbitrary local read) before IPFS upload. Require https with a non-blocked host (no redirects), and confine local paths to cwd. --- .../flaunch/metadata_utils.test.ts | 64 ++++++++++++++ .../flaunch/metadata_utils.ts | 86 ++++++++++++++++++- .../src/action-providers/flaunch/schemas.ts | 6 +- 3 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 typescript/agentkit/src/action-providers/flaunch/metadata_utils.test.ts 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..f32a99778 --- /dev/null +++ b/typescript/agentkit/src/action-providers/flaunch/metadata_utils.test.ts @@ -0,0 +1,64 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { + assertSafeRemoteImageUrl, + isBlockedImageHost, + resolveSafeLocalImagePath, +} from "./metadata_utils"; + +describe("flaunch image URL / path guards", () => { + describe("isBlockedImageHost", () => { + it("blocks loopback, private, link-local, and CGNAT literals", () => { + expect(isBlockedImageHost("localhost")).toBe(true); + expect(isBlockedImageHost("127.0.0.1")).toBe(true); + expect(isBlockedImageHost("10.0.0.5")).toBe(true); + expect(isBlockedImageHost("192.168.1.10")).toBe(true); + expect(isBlockedImageHost("169.254.169.254")).toBe(true); + expect(isBlockedImageHost("100.64.0.1")).toBe(true); + expect(isBlockedImageHost("img.localhost")).toBe(true); + }); + + it("allows public hostnames and public IPs", () => { + expect(isBlockedImageHost("cdn.example.com")).toBe(false); + expect(isBlockedImageHost("8.8.8.8")).toBe(false); + }); + }); + + describe("assertSafeRemoteImageUrl", () => { + it("accepts https public hosts", () => { + const u = assertSafeRemoteImageUrl("https://cdn.example.com/token.png"); + expect(u.hostname).toBe("cdn.example.com"); + }); + + it("rejects http, credentials, and blocked hosts", () => { + expect(() => assertSafeRemoteImageUrl("http://cdn.example.com/a.png")).toThrow(/https/i); + expect(() => + assertSafeRemoteImageUrl("https://user:pass@cdn.example.com/a.png"), + ).toThrow(/credentials/i); + expect(() => assertSafeRemoteImageUrl("https://127.0.0.1/a.png")).toThrow(/not allowed/i); + expect(() => assertSafeRemoteImageUrl("https://169.254.169.254/latest")).toThrow( + /not allowed/i, + ); + }); + }); + + describe("resolveSafeLocalImagePath", () => { + it("allows files under cwd and rejects escapes", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "flaunch-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 }); + } + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/flaunch/metadata_utils.ts b/typescript/agentkit/src/action-providers/flaunch/metadata_utils.ts index d048755fc..43ee88276 100644 --- a/typescript/agentkit/src/action-providers/flaunch/metadata_utils.ts +++ b/typescript/agentkit/src/action-providers/flaunch/metadata_utils.ts @@ -43,6 +43,79 @@ interface TokenUriParams { }; } +/** + * True when hostname is a literal IP in a non-global range (loopback, RFC1918, + * link-local/metadata, CGNAT, benchmarking). DNS rebinding is out of scope + * here; hostname literals and https-only reduce the agent-tool SSRF surface. + */ +export function isBlockedImageHost(hostname: string): boolean { + const host = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if ( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host.endsWith(".localhost") || + host.endsWith(".local") + ) { + return true; + } + + const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); + if (v4) { + const [a, b] = [Number(v4[1]), Number(v4[2])]; + if (a === 0 || a === 10 || a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + if (a === 198 && (b === 18 || b === 19)) return true; + return false; + } + + // IPv6 unique-local / link-local + return host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:"); +} + +/** + * Fail closed before fetching a remote token image (SSRF). + * Only https:// with a non-blocked host is allowed. + */ +export function assertSafeRemoteImageUrl(imageUrl: string): URL { + let parsed: URL; + try { + parsed = new URL(imageUrl); + } catch { + throw new Error(`Invalid image URL: ${imageUrl}`); + } + if (parsed.protocol !== "https:") { + throw new Error("Remote token images must use https://"); + } + if (parsed.username || parsed.password) { + throw new Error("Image URL must not include credentials"); + } + if (!parsed.hostname || isBlockedImageHost(parsed.hostname)) { + throw new Error(`Image URL host is not allowed: ${parsed.hostname || "(empty)"}`); + } + return parsed; +} + +/** + * Resolve a local image path and require it to stay under process.cwd() + * (realpath), so agent-supplied paths cannot read arbitrary files for IPFS. + */ +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 * @@ -52,15 +125,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"; @@ -183,7 +257,10 @@ const uploadJsonToIPFS = async (params: { */ const convertImageUrlToBase64 = async (imageUrl: string): Promise => { try { - const response = await fetch(imageUrl); + assertSafeRemoteImageUrl(imageUrl); + // Do not follow redirects: Location could point at a blocked host after + // the initial URL check. + const response = await fetch(imageUrl, { redirect: "error" }); if (!response.ok) { throw new Error(`Failed to fetch image: ${response.statusText}`); @@ -253,9 +330,10 @@ export const generateTokenUri = async (name: string, symbol: string, params: Tok const image = params.metadata.image; if (image.startsWith("https://") || image.startsWith("http://")) { + // http:// rejected inside assertSafeRemoteImageUrl (https only). base64Image = await convertImageUrlToBase64(image); } else { - // assume local file + // Local file: path must resolve under process.cwd(). const { base64, mimeType } = await readFileAsBase64(image); base64Image = `data:${mimeType};base64,${base64}`; } diff --git a/typescript/agentkit/src/action-providers/flaunch/schemas.ts b/typescript/agentkit/src/action-providers/flaunch/schemas.ts index f98c851c7..69e453bf8 100644 --- a/typescript/agentkit/src/action-providers/flaunch/schemas.ts +++ b/typescript/agentkit/src/action-providers/flaunch/schemas.ts @@ -14,7 +14,11 @@ 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() + .describe( + "HTTPS URL of the token image, or a local image path under the working directory", + ), 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"),