import dnsPromises from "node:dns/promises"; export const dnsClient = { resolve4: dnsPromises.resolve4.bind(dnsPromises), resolve6: dnsPromises.resolve6.bind(dnsPromises), lookup: dnsPromises.lookup.bind(dnsPromises), }; import type { SearchResult } from "./providers/types.js"; export const MAX_DIRECT_RESPONSE_BYTES = 10 * 1024 * 1024; export function deduplicateResults(allResults: SearchResult[]): SearchResult[] { const seen = new Set(); const unique: SearchResult[] = []; for (const r of allResults) { const key = r.url.toLowerCase(); if (!seen.has(key)) { seen.add(key); unique.push(r); } } return unique; } type IPv4Address = [number, number, number, number]; function stripIpBrackets(ip: string): string { const trimmed = ip.trim(); return trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed; } function parseIPv4(ip: string): IPv4Address | null { const parts = ip.split("."); if (parts.length !== 4 || parts.some((part) => !/^\d+$/.test(part))) return null; const values = parts.map(Number); if (values.some((value) => value < 0 || value > 255)) return null; return values as IPv4Address; } function parseIPv6Groups(ip: string): number[] | null { const halves = ip.split("::"); if (halves.length > 2) return null; const parsePart = (part: string): number[] | null => { if (!part) return []; const groups = part.split(":"); const lastGroup = groups.at(-1); if (lastGroup?.includes(".")) { const ipv4 = parseIPv4(lastGroup); if (!ipv4) return null; groups.splice( groups.length - 1, 1, ((ipv4[0] << 8) | ipv4[1]).toString(16), ((ipv4[2] << 8) | ipv4[3]).toString(16), ); } const values = groups.map((group) => { if (!/^[0-9a-f]{1,4}$/i.test(group)) return -1; return Number.parseInt(group, 16); }); return values.some((value) => value < 0) ? null : values; }; const left = parsePart(halves[0]); const right = parsePart(halves.length === 2 ? halves[1] : ""); if (!left || !right) return null; const totalGroups = left.length + right.length; if (halves.length === 1) { return totalGroups === 8 ? [...left, ...right] : null; } if (totalGroups >= 8) return null; return [...left, ...new Array(8 - totalGroups).fill(0), ...right]; } function isPrivateIPv4([a, b]: IPv4Address): boolean { // IPv4 loopback, unspecified, RFC1918, and link-local ranges. return ( a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) ); } export function isPrivateIP(ip: string): boolean { const normalized = stripIpBrackets(ip).split("%", 1)[0]; const ipv4 = parseIPv4(normalized); if (ipv4) return isPrivateIPv4(ipv4); const ipv6 = parseIPv6Groups(normalized); if (!ipv6) return false; // IPv4-mapped IPv6 addresses must use the same IPv4 policy. const isMappedIPv4 = ipv6.slice(0, 5).every((group) => group === 0) && ipv6[5] === 0xffff; if (isMappedIPv4) { const mapped: IPv4Address = [ipv6[6] >> 8, ipv6[6] & 0xff, ipv6[7] >> 8, ipv6[7] & 0xff]; return isPrivateIPv4(mapped); } const isUnspecified = ipv6.every((group) => group === 0); const isLoopback = ipv6.slice(0, 7).every((group) => group === 0) && ipv6[7] === 1; const isLinkLocal = (ipv6[0] & 0xffc0) === 0xfe80; const isUniqueLocal = (ipv6[0] & 0xfe00) === 0xfc00; return isUnspecified || isLoopback || isLinkLocal || isUniqueLocal; } // 防 DNS 重绑定域名解析校验 export async function assertSafeDns(urlStr: string): Promise<{ url: string; ip: string }> { const url = new URL(urlStr); const hostname = url.hostname; // 若直接为 IP,则直接检验 const normalizedHostname = stripIpBrackets(hostname); const isIP = parseIPv4(normalizedHostname) !== null || parseIPv6Groups(normalizedHostname) !== null; if (isIP) { if (isPrivateIP(normalizedHostname)) throw new Error(`Blocked private IP access: ${hostname}`); return { url: urlStr, ip: normalizedHostname }; } // DNS 预解析:查询所有关联 of IPv4 & IPv6 地址,只要存在一个私有 IP 即刻拦截 const addresses: string[] = []; try { // 通过系统 getaddrinfo 查询所有映射地址(支持本地 hosts 文件解析) try { const lookupAll = await dnsClient.lookup(hostname, { all: true } as any); if (Array.isArray(lookupAll)) { addresses.push(...lookupAll.map((x) => x.address)); } else if (lookupAll && (lookupAll as any).address) { addresses.push((lookupAll as any).address); } } catch {} // 向 DNS 服务查询远程所有 A / AAAA 记录,确保抓全 try { const ips4 = await dnsClient.resolve4(hostname); addresses.push(...ips4); } catch {} try { const ips6 = await dnsClient.resolve6(hostname); addresses.push(...ips6); } catch {} if (addresses.length === 0) { throw new Error(`DNS resolution failed for ${hostname}`); } for (const addr of addresses) { if (isPrivateIP(addr)) { throw new Error(`SSRF Blocked: Resolving to private address: ${addr}`); } } } catch (err) { if (err instanceof Error && err.message.includes("SSRF Blocked")) throw err; throw err; } return { url: urlStr, ip: addresses[0] }; } interface CacheEntry { value: T; expiresAt: number; } export class SafeMemoryCache { private cache = new Map>(); constructor( private ttlMs: number = 300000, private maxEntries: number = 100, ) {} get(key: string): T | null { const entry = this.cache.get(key); if (!entry) return null; if (Date.now() > entry.expiresAt) { this.cache.delete(key); return null; } return entry.value; } set(key: string, value: T): void { if (this.cache.size >= this.maxEntries) { // FIFO 缓存淘汰 const firstKey = this.cache.keys().next().value; if (firstKey) this.cache.delete(firstKey); } this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs, }); } clear(): void { this.cache.clear(); } } import http from "node:http"; import https from "node:https"; // 锁定 IP 并防御 DNS Rebinding 的原生 fetch 替换实现,兼容 HTTPS 证书校验 export function safeFetch(urlStr: string, safeIp: string, options: RequestInit = {}): Promise { const url = new URL(urlStr); if (url.protocol !== "http:" && url.protocol !== "https:") { return Promise.reject(new Error(`Unsupported URL protocol: ${url.protocol}`)); } const isHttps = url.protocol === "https:"; const lib = isHttps ? https : http; const headers: Record = {}; if (options.headers) { if (options.headers instanceof Headers) { options.headers.forEach((val, key) => { headers[key] = val; }); } else if (Array.isArray(options.headers)) { for (const [key, val] of options.headers) { headers[key] = val; } } else { Object.assign(headers, options.headers); } } // 自定义 lookup 函数强行将解析指向经过 SSRF 校验的安全 IP const customLookup = ( _hostname: string, opts: any, callback: (err: Error | null, address: any, family?: number) => void, ) => { const family = safeIp.includes(":") ? 6 : 4; if (opts?.all) { callback(null, [{ address: safeIp, family }]); } else { callback(null, safeIp, family); } }; const reqOptions: http.RequestOptions = { method: options.method || "GET", headers, lookup: customLookup, signal: options.signal ?? undefined, }; return new Promise((resolve, reject) => { let settled = false; const fail = (err: Error) => { if (settled) return; settled = true; reject(err); }; const req = lib.request(urlStr, reqOptions, (res) => { const declaredLength = Number(res.headers["content-length"]); if (Number.isFinite(declaredLength) && declaredLength > MAX_DIRECT_RESPONSE_BYTES) { const err = new Error(`PAYLOAD_TOO_LARGE: response exceeds ${MAX_DIRECT_RESPONSE_BYTES} bytes`); res.destroy(err); req.destroy(err); fail(err); return; } const chunks: Buffer[] = []; let totalBytes = 0; res.on("data", (chunk: Buffer | Uint8Array | string) => { if (settled) return; const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); totalBytes += buffer.byteLength; if (totalBytes > MAX_DIRECT_RESPONSE_BYTES) { const err = new Error(`PAYLOAD_TOO_LARGE: response exceeds ${MAX_DIRECT_RESPONSE_BYTES} bytes`); res.destroy(err); req.destroy(err); fail(err); return; } chunks.push(buffer); }); res.on("error", fail); res.on("end", () => { if (settled) return; settled = true; const responseHeaders = new Headers(); for (const [key, val] of Object.entries(res.headers)) { if (Array.isArray(val)) { for (const v of val) responseHeaders.append(key, v); } else if (val !== undefined) { responseHeaders.set(key, val); } } const response = new Response(Buffer.concat(chunks, totalBytes), { status: res.statusCode, statusText: res.statusMessage, headers: responseHeaders, }); Object.defineProperty(response, "url", { value: urlStr }); resolve(response); }); }); req.on("error", fail); if (options.body) { if (typeof options.body === "string" || Buffer.isBuffer(options.body)) { req.write(options.body); } else { req.write(String(options.body)); } } req.end(); }); } // 8 秒网络请求硬超时控制 export async function fetchWithTimeout( url: string, options: RequestInit = {}, timeoutMs: number = 8000, safeIp?: string, ): Promise { const timeoutController = new AbortController(); let timeoutReject: ((error: Error) => void) | undefined; const timeoutPromise = new Promise((_resolve, reject) => { timeoutReject = reject; }); const timer = setTimeout(() => { timeoutController.abort(new Error("timeout")); timeoutReject?.(new Error(`Network request timed out after ${timeoutMs}ms`)); }, timeoutMs); const signal = options.signal ? AbortSignal.any([options.signal, timeoutController.signal]) : timeoutController.signal; try { const request = safeIp ? safeFetch(url, safeIp, { ...options, signal }) : fetch(url, { ...options, signal }); return await Promise.race([request, timeoutPromise]); } catch (err) { if (timeoutController.signal.aborted && !options.signal?.aborted) { throw new Error(`Network request timed out after ${timeoutMs}ms`, { cause: err }); } throw err; } finally { clearTimeout(timer); } } // 对 HTTP 429 指数退避重试以增强抖动抗性 export async function fetchWithRetry( url: string, options: RequestInit = {}, maxRetries: number = 1, safeIp?: string, ): Promise { let lastError: Error | null = null; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { if (attempt > 0) { const backoff = 1000 * 2 ** attempt + Math.random() * 200; await new Promise((resolve) => setTimeout(resolve, backoff)); } const res = await fetchWithTimeout(url, options, 8000, safeIp); if (res.status === 429 && attempt < maxRetries) { continue; } return res; } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); if (attempt === maxRetries) break; } } throw lastError ?? new Error(`Request failed`); } /** * 极简、高性能且防 ReDoS 阻塞的 HTML-to-Markdown 正文提取器。 * 针对超过 200KB 的超大网页自动进行大块非正则裁剪与纯文本提取降级,确保主线程安全。 */ export function cleanHtmlToMarkdown(html: string): string { if (!html) return ""; let text = html; const threshold = 200000; // 200KB // 若体积超标,尝试用高效的字符串非正则分割做粗过滤缩减体积 if (text.length > threshold) { // 优先截取
中的正文 for (const tag of ["main", "article", "body"]) { const startTag = `<${tag}`; const endTag = ``; const startIdx = text.indexOf(startTag); if (startIdx !== -1) { const endIdx = text.indexOf(endTag, startIdx); if (endIdx !== -1) { text = text.substring(startIdx, endIdx + endTag.length); break; } } } } // 如果截取后依然超过 200KB 则进行线性降级,不执行任何复杂的可能导致回溯的捕获正则 if (text.length > threshold) { // 仅移除 script, style 等并直接将 HTML 标签全部简单按行替换,剥离一切标签 return text .replace(/<(script|style|noscript|svg|iframe|head)[^>]*>([\s\S]*?)<\/\1>/gi, "") .replace(/<[^>]+>/g, " ") .replace(/\s+/g, " ") .trim(); } // 符合安全体积,执行轻量级正则 Markdown 转化 // 强力清除绝对无用的非展示标签与注释 text = text.replace(/<(script|style|noscript|svg|iframe|head)[^>]*>([\s\S]*?)<\/\1>/gi, ""); text = text.replace(//g, ""); // 清除 HTML5 语义化的页眉、页脚、导航、侧边栏标签及其内部所有内容 text = text.replace(/<(header|footer|nav|aside)[^>]*>([\s\S]*?)<\/\1>/gi, ""); // 清除带有特定广告、导航、侧边栏 ID/Class 属性的普通标签及其内部所有内容 text = text.replace( /<[^>]+(id|class)="[^"]*(sidebar|footer|nav|header|ad-|banner|menu)[^"]*"[^>]*>([\s\S]*?)<\/[^>]+>/gi, "", ); // 标题标签转化 text = text.replace( /]*>([\s\S]*?)<\/h[1-6]>/gi, (_, content) => `\n\n# ${content.replace(/<[^>]+>/g, "").trim()}\n`, ); // 超链接转化 text = text.replace(/]+href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (_, href, innerText) => { const cleanText = innerText.replace(/<[^>]+>/g, "").trim(); return cleanText ? ` [${cleanText}](${href}) ` : ""; }); // 代码块转化 text = text.replace(/]*>[\s\S]*?]*>([\s\S]*?)<\/code>[\s\S]*?<\/pre>/gi, (_, code) => { const cleanCode = code.replace(/<[^>]+>/g, ""); // 移除内部标签 return `\n\`\`\`\n${cleanCode}\n\`\`\`\n`; }); // 关键图片转化 text = text.replace(/]+)>/gi, (_, attrs) => { const srcMatch = attrs.match(/src="([^"]*)"/i); const altMatch = attrs.match(/alt="([^"]*)"/i); const src = srcMatch ? srcMatch[1] : ""; const alt = altMatch ? altMatch[1] : ""; return src ? ` ![${alt}](${src}) ` : ""; }); // 段落换行转化 text = text.replace(/]*>/gi, "\n\n").replace(/<\/p>/gi, ""); text = text.replace(//gi, "\n"); // 移除所有其余未处理的 HTML 标签,仅保留纯文本 text = text.replace(/<[^>]+>/g, " "); // 压缩连续换行与空白 text = text.replace(/[ \t]+/g, " "); text = text.replace(/\n\s*\n/g, "\n\n"); return text.trim(); }