Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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";
Expand Down Expand Up @@ -183,7 +257,10 @@ const uploadJsonToIPFS = async (params: {
*/
const convertImageUrlToBase64 = async (imageUrl: string): Promise<string> => {
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}`);
Expand Down Expand Up @@ -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}`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading