SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
3.9 KB · 78 lines typescript
Raw Blame History
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