UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

278 lines • 12 kB
import { lookup } from "node:dns/promises"; import { unreachableHostMessage, ValidationError } from "../utils/errors.js"; import { logger } from "../utils/logger.js"; /** Default cap on how long we wait for a workflow URL to respond. */ const DEFAULT_TIMEOUT_MS = 15_000; /** Default cap on the workflow payload size (workflows are JSON, rarely > a few MB). */ const DEFAULT_MAX_BYTES = 25 * 1024 * 1024; /** Default cap on the DNS lookup so a slow/hanging resolver can't stall the tool. */ const DEFAULT_DNS_TIMEOUT_MS = 5_000; /** * Known "share" hosts that serve an HTML page (or require an authenticated API * call) rather than raw workflow JSON. We can't resolve these to a graph, so we * fail fast with a clear instruction instead of fetching a web page and choking * on the HTML. */ const UNSUPPORTED_SHARE_HOSTS = [ "comfyworkflows.com", "openart.ai", "civitai.com", "comfy.icu", "pixfor.us", ]; /** * Normalize well-known workflow URLs to their raw-JSON equivalent, and reject * share hosts we can't resolve. * * - GitHub blob pages (`github.com/o/r/blob/ref/path.json`) → the raw file on * `raw.githubusercontent.com`. * - GitHub `?raw=true` blob links → raw host too. * - Known share hosts (comfyworkflows, openart, civitai, …) → throw with a hint * to paste the raw `.json` URL. * * Anything else is returned unchanged. */ export function normalizeWorkflowUrl(rawUrl) { let u; try { u = new URL(rawUrl); } catch { // Leave parsing/validation errors to assertSafeUrl, which gives a better message. return rawUrl; } const host = u.hostname.toLowerCase(); // github.com/<owner>/<repo>/blob/<ref>/<path...> → raw.githubusercontent.com if (host === "github.com" || host === "www.github.com") { const parts = u.pathname.split("/").filter(Boolean); const blobIdx = parts.indexOf("blob"); if (blobIdx >= 2 && blobIdx < parts.length - 1) { const owner = parts[0]; const repo = parts[1]; const rest = parts.slice(blobIdx + 1).join("/"); return `https://raw.githubusercontent.com/${owner}/${repo}/${rest}`; } } // Refuse share hosts that don't serve raw JSON. if (UNSUPPORTED_SHARE_HOSTS.some((h) => host === h || host.endsWith("." + h))) { throw new ValidationError(`Unsupported share host "${host}" — it serves a web page, not raw workflow JSON, ` + `and we can't resolve it without that site's API. Open the workflow there, then paste ` + `the direct raw .json URL (e.g. a raw.githubusercontent.com link or a "Save (API Format)" ` + `export hosted as a .json file).`); } return rawUrl; } /** * Reject any URL that isn't a plain http(s) request to a public host. Blocks * file:// and other schemes, loopback, link-local, private, and CGNAT ranges to * avoid SSRF (e.g. tricking the server into fetching its own /admin or the cloud * metadata endpoint at 169.254.169.254). * * This is the LITERAL-host check; `fetchWorkflowFromUrl` additionally resolves * the hostname via DNS and re-runs these checks against every resolved address * (defeating DNS-rebinding) and refuses to follow redirects. */ export function assertSafeUrl(rawUrl) { let u; try { u = new URL(rawUrl); } catch { throw new ValidationError(`Invalid URL: ${rawUrl}`); } if (u.protocol !== "http:" && u.protocol !== "https:") { throw new ValidationError(`Only http/https URLs are supported (got "${u.protocol}"). ` + `file://, data:, and other schemes are rejected.`); } const host = u.hostname.toLowerCase(); if (isBlockedHost(host)) { throw new ValidationError(`Refusing to fetch from internal/loopback host "${host}" (SSRF guard). ` + `Only public http/https hosts are allowed.`); } return u; } /** * True for hostnames/IPs that point at the local machine or a private network. * Exported for unit testing. */ export function isBlockedHost(host) { if (!host) return true; // Hostname-based internal names. if (host === "localhost" || host.endsWith(".localhost")) return true; if (host.endsWith(".local") || host.endsWith(".internal")) return true; // IPv6 (URL.hostname strips the surrounding brackets). if (host.includes(":")) { const h = host.replace(/^\[|\]$/g, ""); const low = canonicalIpv6Host(h); if (low === "::1" || low === "::") return true; // loopback / unspecified // Unique-local fc00::/7 (fc.. / fd..) and link-local fe80::/10 (fe8/fe9/fea/feb). if (/^f[cd]/.test(low)) return true; if (/^fe[89ab]/.test(low)) return true; // IPv4-mapped IPv6 (::ffff:127.0.0.1 and ::ffff:7f00:1) — URL canonicalizes // both spellings to the hexadecimal form, so classify the embedded v4 value. const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(low); if (mapped) { const hi = Number.parseInt(mapped[1], 16); const lo = Number.parseInt(mapped[2], 16); return isBlockedIpv4(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`); } return false; } // IPv4 literal. if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) { return isBlockedIpv4(host); } return false; } function canonicalIpv6Host(host) { const low = host.toLowerCase(); try { return new URL(`http://[${low}]/`).hostname.replace(/^\[|\]$/g, ""); } catch { return low; } } function isBlockedIpv4(ip) { const parts = ip.split(".").map(Number); if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n > 255)) { return true; // malformed → reject } const [a, b] = parts; if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private if (a === 169 && b === 254) return true; // link-local + cloud metadata (169.254.169.254) if (a === 192 && b === 168) return true; // private if (a === 172 && b >= 16 && b <= 31) return true; // private if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT return false; } /** Reject a promise that doesn't settle within `ms`, with a clear label. */ function withTimeout(p, ms, label) { return new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); p.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); }); }); } /** * Resolve a hostname and reject if ANY resolved address is internal/private. * This defeats DNS-rebinding: a hostname that passes the literal-host check but * resolves to loopback/RFC1918/link-local/metadata/CGNAT/IPv6-ULA is blocked. * IP literals resolve to themselves, so this is a no-op-but-safe for those. * Bounded so a slow resolver can't hang the tool. Exported for testing. */ export async function assertHostResolvesSafe(host, timeoutMs = DEFAULT_DNS_TIMEOUT_MS) { let results; try { results = await withTimeout(lookup(host, { all: true }), timeoutMs, `DNS lookup for "${host}"`); } catch (err) { const e = err; throw new ValidationError(`Could not resolve host "${host}": ${e?.message ?? err}`); } if (!results || results.length === 0) { throw new ValidationError(`Host "${host}" did not resolve to any address.`); } for (const r of results) { if (isBlockedHost(r.address)) { throw new ValidationError(`Refusing to fetch "${host}" — it resolves to internal/private address ` + `${r.address} (SSRF guard / DNS-rebinding protection).`); } } } /** * Fetch and JSON-parse a workflow from a remote URL with SSRF, timeout, and * size guards. Throws ValidationError (never a raw fetch error) on any failure * so callers can surface a clean message via errorToToolResult. * * SSRF defenses: http/https only; literal-host check; DNS resolution with every * resolved address re-checked against the private/internal ranges; and redirects * are NOT followed (a 30x to an internal target is rejected — raw workflow-JSON * hosts don't need redirects, and GitHub blob→raw is normalized up front). */ export async function fetchWorkflowFromUrl(rawUrl, opts) { const normalized = normalizeWorkflowUrl(rawUrl); const u = assertSafeUrl(normalized); await assertHostResolvesSafe(u.hostname, opts?.dnsTimeoutMs ?? DEFAULT_DNS_TIMEOUT_MS); const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS; const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); let res; try { res = await fetch(u, { signal: controller.signal, // Do NOT auto-follow redirects — a public URL could 30x to an internal // target that bypasses the DNS/host checks above. redirect: "manual", headers: { Accept: "application/json, text/plain;q=0.9, */*;q=0.5" }, }); } catch (err) { const e = err; if (e?.name === "AbortError") { throw new ValidationError(`Timed out after ${timeoutMs}ms fetching workflow from "${u.hostname}".`); } // #1136 — reaching here means `fetch` REJECTED, so no response was received. // Whether the TCP handshake completed (TLS error, RST) is invisible to the // user and irrelevant to the harm: nothing was retrieved, so nothing follows // about whether the workflow exists. The shared helper says exactly that and // names the host; it also separates "did not respond in time" from "could // not reach" using the undici timeout codes, which a hand-rolled code list // kept getting wrong. throw new ValidationError(unreachableHostMessage(err, u.toString(), "fetching the workflow").message); } finally { clearTimeout(timer); } // redirect:"manual" surfaces redirects as an opaqueredirect response (status 0 // in Node/undici) or, in test doubles, a literal 3xx. Reject either rather // than following into a possibly-internal Location. if (res.type === "opaqueredirect" || res.status === 0 || (res.status >= 300 && res.status < 400)) { throw new ValidationError(`Workflow URL responded with a redirect (status ${res.status}) — redirects are ` + `not followed (SSRF guard). Use the final direct .json URL instead.`); } if (!res.ok) { throw new ValidationError(`Workflow URL returned ${res.status} ${res.statusText}. ` + `Check the link points at a public raw .json file.`); } // Pre-flight size check via Content-Length when present. const declared = Number(res.headers.get("content-length") ?? 0); if (declared && declared > maxBytes) { throw new ValidationError(`Workflow payload too large (${declared} bytes > ${maxBytes} byte limit).`); } const text = await res.text(); if (text.length > maxBytes) { throw new ValidationError(`Workflow payload too large (${text.length} bytes > ${maxBytes} byte limit).`); } let json; try { json = JSON.parse(text); } catch { const preview = text.slice(0, 120).replace(/\s+/g, " ").trim(); throw new ValidationError(`URL did not return valid JSON (got: "${preview}…"). ` + `If this is a share/preview page, paste the raw workflow JSON URL instead.`); } logger.info("Fetched workflow from URL", { finalUrl: u.toString(), bytes: text.length }); return { json, finalUrl: u.toString() }; } //# sourceMappingURL=workflow-url.js.map