import { lookup } from "node:dns/promises"; import { isIP } from "node:net"; import { Agent } from "undici"; // Some vendors (e.g. Google properties) send response headers above undici's 16 KiB default → "Headers Overflow". const dispatcher = new Agent({ maxHeaderSize: 256 * 1024, connect: { timeout: 10_000 } }); const TRACKING = /^(utm_\w+|fbclid|gclid|dclid|msclkid|mc_cid|mc_eid|igshid|ref|ref_src|source|_hsenc|_hsmi|yclid|twclid|si)$/i; /** Canonical form used for deduplication and storage: https, lower-case host, no default port, no tracking params, no fragment, sorted query, no trailing slash (except root). */ export function normalizeUrl(input: string): string { const u = new URL(input.trim()); if (!/^https?:$/.test(u.protocol)) throw new Error("unsupported scheme"); u.hash = ""; u.hostname = u.hostname.toLowerCase().replace(/\.$/, ""); if ((u.protocol === "https:" && u.port === "443") || (u.protocol === "http:" && u.port === "80")) u.port = ""; const keep = [...u.searchParams.entries()].filter(([k]) => !TRACKING.test(k)).sort(([a], [b]) => a.localeCompare(b)); u.search = keep.length ? "?" + new URLSearchParams(keep).toString() : ""; u.username = ""; u.password = ""; if (u.pathname !== "/" && u.pathname.endsWith("/")) u.pathname = u.pathname.replace(/\/+$/, ""); return u.toString(); } export function sameSite(a: string, b: string) { const h = (x: string) => new URL(x).hostname.replace(/^www\./, ""); return h(a) === h(b); } /** RFC1918, loopback, link-local, CGNAT, ULA, multicast, metadata — anything that must never be fetched server-side. */ export function isPrivateIp(ip: string): boolean { if (isIP(ip) === 4) { const [a, b] = ip.split(".").map(Number); return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224; } const v6 = ip.toLowerCase(); if (v6 === "::1" || v6 === "::") return true; if (v6.startsWith("fc") || v6.startsWith("fd") || v6.startsWith("fe8") || v6.startsWith("fe9") || v6.startsWith("fea") || v6.startsWith("feb") || v6.startsWith("ff")) return true; if (v6.startsWith("::ffff:")) return isPrivateIp(v6.slice(7)); return false; } const BLOCKED_HOSTS = new Set(["localhost", "metadata.google.internal", "169.254.169.254"]); export async function assertPublicHost(hostname: string) { const h = hostname.toLowerCase(); if (BLOCKED_HOSTS.has(h) || h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".maclustr.io") || h.endsWith(".localhost") || !h.includes(".")) throw new Error(`blocked host ${h}`); if (isIP(h)) { if (isPrivateIp(h)) throw new Error(`private address ${h}`); return; } const addrs = await lookup(h, { all: true, verbatim: true }).catch(() => []); if (!addrs.length) throw new Error(`DNS resolution failed for ${h}`); for (const a of addrs) if (isPrivateIp(a.address)) throw new Error(`host ${h} resolves to private address`); } export type SafeFetchResult = { ok: boolean; status: number; finalUrl: string; redirected: boolean; hops: string[]; contentType: string | null; body: string | null; error?: string; durationMs: number }; const UA = () => process.env.OUTBOUND_USER_AGENT ?? "tools-spboucher-ai-linkcheck/1.0"; // plain token: some CDNs (Apple) 404 on UAs containing URLs /** SSRF-safe fetch: validates every hop, caps redirects (5), body size (1 MiB) and time (15 s). Never sends credentials. */ export async function safeFetch(url: string, opts: { method?: "GET" | "HEAD"; maxBytes?: number; timeoutMs?: number } = {}): Promise { const started = Date.now(); const hops: string[] = []; let current = url; let redirected = false; const maxBytes = opts.maxBytes ?? 1_048_576; const timeoutMs = opts.timeoutMs ?? 20_000; try { for (let i = 0; i < 6; i++) { const u = new URL(current); if (!/^https?:$/.test(u.protocol)) throw new Error("unsupported scheme"); await assertPublicHost(u.hostname); hops.push(current); const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); let res: Response; try { res = await fetch(current, { method: opts.method ?? "GET", redirect: "manual", signal: ctrl.signal, dispatcher, headers: { "user-agent": UA(), accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8" }, credentials: "omit" as RequestCredentials } as RequestInit); } finally { clearTimeout(t); } if (res.status >= 300 && res.status < 400) { const loc = res.headers.get("location"); if (!loc) return { ok: false, status: res.status, finalUrl: current, redirected, hops, contentType: null, body: null, error: "redirect without location", durationMs: Date.now() - started }; const next = new URL(loc, current).toString(); const nu = new URL(next); // Many sites bounce https → http (same host) for a trailing slash and immediately back to https. Allow same-host // downgrades inside the chain; a cross-host downgrade is rejected and a final http URL is reported as insecure below. if (nu.protocol === "http:" && u.protocol === "https:" && nu.hostname !== u.hostname) throw new Error("https→http downgrade redirect rejected"); if (nu.toString() === current) { // self-redirect loop (bot challenge) — report as soft block return { ok: false, status: 403, finalUrl: current, redirected, hops, contentType: null, body: null, error: "self-redirect loop (likely bot challenge)", durationMs: Date.now() - started }; } redirected = true; current = next; continue; } let body: string | null = null; if ((opts.method ?? "GET") === "GET" && res.body) { const reader = res.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > maxBytes) { reader.cancel().catch(() => {}); break; } chunks.push(value); } body = new TextDecoder("utf-8", { fatal: false }).decode(Buffer.concat(chunks)); } const insecure = new URL(url).protocol === "https:" && u.protocol === "http:"; return { ok: res.status >= 200 && res.status < 300 && !insecure, status: res.status, finalUrl: current, redirected, hops, contentType: res.headers.get("content-type"), body, error: insecure ? "final URL is not https" : undefined, durationMs: Date.now() - started }; } throw new Error("too many redirects"); } catch (e) { const err = e as Error & { cause?: { code?: string; message?: string; errors?: { code?: string }[] } }; const cause = err.cause ? (err.cause.code ?? err.cause.errors?.map((x) => x.code).join("|") ?? err.cause.message) : null; return { ok: false, status: 0, finalUrl: current, redirected, hops, contentType: null, body: null, error: err.name === "AbortError" ? "timeout" : cause ? `${err.message} (${String(cause).slice(0, 80)})` : err.message, durationMs: Date.now() - started }; } } /** Extract and canonical link from HTML without a parser (cheap, best-effort). Input is untrusted. */ export function extractHtmlMeta(html: string): { title: string | null; canonical: string | null; description: string | null } { const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, " ").trim().slice(0, 300) ?? null; const canonical = html.match(/<link[^>]+rel=["']canonical["'][^>]*href=["']([^"']+)["']/i)?.[1] ?? html.match(/<link[^>]+href=["']([^"']+)["'][^>]*rel=["']canonical["']/i)?.[1] ?? null; const description = html.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i)?.[1]?.trim().slice(0, 500) ?? null; return { title: title ? decodeEntities(title) : null, canonical, description: description ? decodeEntities(description) : null }; } function decodeEntities(s: string) { return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/g, "'").replace(/ /g, " "); }