/** * Client-safe URL checks for the Playground. Mirrors the API's SSRF policy (`@fetcha/core` ssrf.ts, * which needs `node:dns` and therefore cannot be bundled for the browser). The server remains the * source of truth; this only gives users an early, friendly warning. */ const BLOCKED_HOSTNAMES = new Set(["localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback", "metadata.google.internal", "metadata", "instance-data", "kubernetes.default", "kubernetes.default.svc"]); const BLOCKED_SUFFIXES = [".localhost", ".local", ".internal", ".localdomain", ".home.arpa", ".in-addr.arpa", ".ip6.arpa"]; function isIPv4(h: string): boolean { return /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(h) && h.split(".").every((p) => Number(p) <= 255); } function privateIPv4(h: string): boolean { const [a, b] = h.split(".").map(Number) as [number, number, number, number]; if (a === 0 || a === 10 || a === 127) return true; if (a === 100 && b >= 64 && b <= 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 === 192 && b === 0) return true; if (a === 198 && (b === 18 || b === 19)) return true; if (a >= 224) return true; return false; } function privateIPv6(h: string): boolean { const l = h.toLowerCase(); return l === "::" || l === "::1" || l.startsWith("fe8") || l.startsWith("fe9") || l.startsWith("fea") || l.startsWith("feb") || l.startsWith("fc") || l.startsWith("fd") || l.startsWith("ff") || l.startsWith("::ffff:"); } export type UrlCheck = { level: "ok" } | { level: "error" | "warning"; message: string }; /** Validate a URL typed in the Playground. `error` blocks running; `warning` lets the user proceed. */ export function checkPlaygroundUrl(raw: string): UrlCheck { const value = raw.trim(); if (!value) return { level: "error", message: "Enter a URL to fetch." }; let url: URL; try { url = new URL(value); } catch { return { level: "error", message: "Enter a full URL, including https://." }; } if (url.protocol !== "http:" && url.protocol !== "https:") { return { level: "error", message: `Only http and https URLs are supported (got ${url.protocol.replace(":", "")}).` }; } if (url.username || url.password) return { level: "error", message: "Credentials in the URL are not allowed." }; const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase().replace(/\.$/, ""); if (!host) return { level: "error", message: "The URL has no host." }; if (BLOCKED_HOSTNAMES.has(host) || BLOCKED_SUFFIXES.some((s) => host.endsWith(s))) { return { level: "warning", message: "Local, private and internal hosts are blocked by the API. This request will fail with URL_NOT_ALLOWED." }; } if (isIPv4(host)) { if (privateIPv4(host)) return { level: "warning", message: "Private or reserved IP addresses are blocked by the API. This request will fail with URL_NOT_ALLOWED." }; return { level: "ok" }; } if (host.includes(":")) { if (privateIPv6(host)) return { level: "warning", message: "Private or reserved IPv6 addresses are blocked by the API." }; return { level: "ok" }; } if (/^[0-9x.]+$/i.test(host)) return { level: "warning", message: "Numeric host encodings are blocked by the API." }; if (!host.includes(".")) return { level: "warning", message: "Single-label hostnames are blocked by the API. Use a fully qualified domain." }; return { level: "ok" }; } /** Merge extra query parameters into a URL (keeps existing ones). Returns the input untouched if it is not parseable. */ export function mergeQueryParams(raw: string, params: Array<{ key: string; value: string }>): string { const active = params.filter((p) => p.key.trim()); if (!active.length) return raw; try { const url = new URL(raw.trim()); for (const p of active) url.searchParams.append(p.key.trim(), p.value); return url.toString(); } catch { return raw; } }