import net from "node:net"; import { getWebSearchConfigPath } from "../config/path.ts"; import { getConfig } from "../config/store.ts"; import { validateAllowRanges } from "./cidr.ts"; const CONFIG_PATH = getWebSearchConfigPath(); export interface SsrfConfig { allowRanges: string[]; trustEnvProxy: boolean; } export interface DomainPolicy { allow: string[]; deny: string[]; } export function loadFetchContentDomainPolicy(): DomainPolicy { const root = getConfig(); if (root.fetchContent === undefined || root.fetchContent === null) return { allow: [], deny: [] }; if (typeof root.fetchContent !== "object" || Array.isArray(root.fetchContent)) throw new Error(`fetchContent in ${CONFIG_PATH} must be an object`); const policy = (root.fetchContent as { domainPolicy?: unknown }).domainPolicy; if (policy === undefined || policy === null) return { allow: [], deny: [] }; if (typeof policy !== "object" || Array.isArray(policy)) throw new Error(`fetchContent.domainPolicy in ${CONFIG_PATH} must be an object`); const config = policy as { allow?: unknown; deny?: unknown }; return { allow: parseDomainEntries(config.allow, "allow"), deny: parseDomainEntries(config.deny, "deny") }; } export function loadSsrfConfig(): SsrfConfig { const root = getConfig(); if (root.ssrf === undefined || root.ssrf === null) return { allowRanges: [], trustEnvProxy: false }; if (typeof root.ssrf !== "object" || Array.isArray(root.ssrf)) throw new Error(`ssrf in ${CONFIG_PATH} must be an object`); const config = root.ssrf as { allowRanges?: unknown; trustEnvProxy?: unknown }; if (config.allowRanges !== undefined && config.allowRanges !== null && !Array.isArray(config.allowRanges)) throw new Error(`ssrf.allowRanges in ${CONFIG_PATH} must be an array of CIDR strings`); if (config.trustEnvProxy !== undefined && typeof config.trustEnvProxy !== "boolean") throw new Error(`ssrf.trustEnvProxy in ${CONFIG_PATH} must be a boolean`); const values: unknown[] = Array.isArray(config.allowRanges) ? config.allowRanges : []; const allowRanges = values.map((entry, index) => { if (typeof entry !== "string") throw new Error(`ssrf.allowRanges in ${CONFIG_PATH} must contain only CIDR strings; entry ${index + 1} is ${typeof entry}`); return entry.trim(); }).filter(Boolean); validateAllowRanges(allowRanges); return { allowRanges, trustEnvProxy: config.trustEnvProxy === true }; } export function assertDomainPolicy(hostname: string, policy?: DomainPolicy): void { if (!policy) return; if (policy.deny.some(entry => domainMatches(hostname, entry))) throw new Error(`Blocked hostname by web_fetch domain policy: ${hostname}`); if (policy.allow.length > 0 && !policy.allow.some(entry => domainMatches(hostname, entry))) throw new Error(`Hostname not allowed by web_fetch domain policy: ${hostname}`); } function parseDomainEntries(value: unknown, field: "allow" | "deny"): string[] { if (value === undefined || value === null) return []; if (!Array.isArray(value)) throw new Error(`fetchContent.domainPolicy.${field} in ${CONFIG_PATH} must be an array of hostnames`); return value.map((entry, index) => { if (typeof entry !== "string") throw new Error(`fetchContent.domainPolicy.${field} in ${CONFIG_PATH} must contain only hostnames; entry ${index + 1} is ${typeof entry}`); const hostname = normalizeDomainEntry(entry); if (!hostname) throw new Error(`fetchContent.domainPolicy.${field} in ${CONFIG_PATH} contains an invalid hostname: ${JSON.stringify(entry)}`); return hostname; }); } function normalizeDomainEntry(entry: string): string | null { const hostname = entry.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); if (!hostname || /\s|[\\/?:#@]/.test(hostname)) return null; if (net.isIP(hostname)) return hostname; if (hostname.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(hostname)) return null; return hostname; } function domainMatches(hostname: string, entry: string): boolean { return hostname === entry || hostname.endsWith(`.${entry}`); }