TypeScript 97.5%
SQL 1.4%
Python 0.8%
1/**2 * Client-safe URL checks for the Playground. Mirrors the API's SSRF policy (`@fetcha/core` ssrf.ts,3 * which needs `node:dns` and therefore cannot be bundled for the browser). The server remains the4 * source of truth; this only gives users an early, friendly warning.5 */67const BLOCKED_HOSTNAMES = new Set(["localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback", "metadata.google.internal", "metadata", "instance-data", "kubernetes.default", "kubernetes.default.svc"]);8const BLOCKED_SUFFIXES = [".localhost", ".local", ".internal", ".localdomain", ".home.arpa", ".in-addr.arpa", ".ip6.arpa"];910function isIPv4(h: string): boolean {11 return /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(h) && h.split(".").every((p) => Number(p) <= 255);12}1314function privateIPv4(h: string): boolean {15 const [a, b] = h.split(".").map(Number) as [number, number, number, number];16 if (a === 0 || a === 10 || a === 127) return true;17 if (a === 100 && b >= 64 && b <= 127) return true;18 if (a === 169 && b === 254) return true;19 if (a === 172 && b >= 16 && b <= 31) return true;20 if (a === 192 && b === 168) return true;21 if (a === 192 && b === 0) return true;22 if (a === 198 && (b === 18 || b === 19)) return true;23 if (a >= 224) return true;24 return false;25}2627function privateIPv6(h: string): boolean {28 const l = h.toLowerCase();29 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:");30}3132export type UrlCheck = { level: "ok" } | { level: "error" | "warning"; message: string };3334/** Validate a URL typed in the Playground. `error` blocks running; `warning` lets the user proceed. */35export function checkPlaygroundUrl(raw: string): UrlCheck {36 const value = raw.trim();37 if (!value) return { level: "error", message: "Enter a URL to fetch." };38 let url: URL;39 try {40 url = new URL(value);41 } catch {42 return { level: "error", message: "Enter a full URL, including https://." };43 }44 if (url.protocol !== "http:" && url.protocol !== "https:") {45 return { level: "error", message: `Only http and https URLs are supported (got ${url.protocol.replace(":", "")}).` };46 }47 if (url.username || url.password) return { level: "error", message: "Credentials in the URL are not allowed." };48 const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase().replace(/\.$/, "");49 if (!host) return { level: "error", message: "The URL has no host." };50 if (BLOCKED_HOSTNAMES.has(host) || BLOCKED_SUFFIXES.some((s) => host.endsWith(s))) {51 return { level: "warning", message: "Local, private and internal hosts are blocked by the API. This request will fail with URL_NOT_ALLOWED." };52 }53 if (isIPv4(host)) {54 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." };55 return { level: "ok" };56 }57 if (host.includes(":")) {58 if (privateIPv6(host)) return { level: "warning", message: "Private or reserved IPv6 addresses are blocked by the API." };59 return { level: "ok" };60 }61 if (/^[0-9x.]+$/i.test(host)) return { level: "warning", message: "Numeric host encodings are blocked by the API." };62 if (!host.includes(".")) return { level: "warning", message: "Single-label hostnames are blocked by the API. Use a fully qualified domain." };63 return { level: "ok" };64}6566/** Merge extra query parameters into a URL (keeps existing ones). Returns the input untouched if it is not parseable. */67export function mergeQueryParams(raw: string, params: Array<{ key: string; value: string }>): string {68 const active = params.filter((p) => p.key.trim());69 if (!active.length) return raw;70 try {71 const url = new URL(raw.trim());72 for (const p of active) url.searchParams.append(p.key.trim(), p.value);73 return url.toString();74 } catch {75 return raw;76 }77}78