UNPKG

camoufox-mcp-server

Version:

MCP server for browser automation using Camoufox - a privacy-focused Firefox fork with advanced anti-detection features

295 lines (294 loc) 9.67 kB
import { lookup } from "node:dns/promises"; import { isIP } from "node:net"; export function normalizeHostname(hostname) { return hostname .toLowerCase() .replace(/^\[/, "") .replace(/\]$/, "") .replace(/\.$/, ""); } export function isBlockedHostname(hostname) { return (hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "local" || hostname.endsWith(".local") || hostname === "ip6-localhost" || hostname === "ip6-loopback"); } function isTestLocalhostAllowed() { return process.env.NODE_ENV === "test" && process.env.CAMOUFOX_MCP_TEST_ALLOW_LOCALHOST === "1"; } function isAllowedTestLocalhostPort(port) { if (!isTestLocalhostAllowed() || !port) { return false; } const allowedPorts = (process.env.CAMOUFOX_MCP_TEST_ALLOWED_LOCALHOST_PORTS ?? "") .split(",") .map((allowedPort) => allowedPort.trim()) .filter(Boolean); return allowedPorts.includes(port); } function isAllowedTestLocalhost(hostname, port) { return isAllowedTestLocalhostPort(port) && (hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "host.docker.internal"); } export function isBlockedIpv4(address) { const parts = address.split(".").map((part) => Number.parseInt(part, 10)); if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part) || part < 0 || part > 255)) { return true; } const [first, second, third] = parts; return first === 0 || first === 10 || first === 127 || first >= 224 || (first === 100 && second >= 64 && second <= 127) || (first === 169 && second === 254) || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 0) || (first === 192 && second === 88 && third === 99) || (first === 192 && second === 168) || (first === 198 && second === 51 && third === 100) || (first === 198 && (second === 18 || second === 19)) || (first === 203 && second === 0 && third === 113); } function isAllowedTestLoopbackIp(address, port) { if (!isAllowedTestLocalhostPort(port)) { return false; } const normalized = normalizeHostname(address); const mappedIpv4 = ipv4FromMappedIpv6(normalized); if (mappedIpv4) { return isAllowedTestLoopbackIp(mappedIpv4, port); } if (normalized === "::1") { return true; } const parts = normalized.split(".").map((part) => Number.parseInt(part, 10)); return parts.length === 4 && parts.every((part) => Number.isFinite(part) && part >= 0 && part <= 255) && parts[0] === 127; } export function ipv4FromMappedIpv6(address) { const dotted = address.match(/^(?:::|0(?::0){4}:)ffff:(\d{1,3}(?:\.\d{1,3}){3})$/); if (dotted) { return dotted[1]; } const separatorParts = address.split("::"); if (separatorParts.length > 2) { return undefined; } const head = separatorParts[0] ? separatorParts[0].split(":") : []; const tail = separatorParts[1] ? separatorParts[1].split(":") : []; const fillCount = separatorParts.length === 2 ? 8 - head.length - tail.length : 0; if (fillCount < 0 || (separatorParts.length === 1 && head.length !== 8)) { return undefined; } const hextets = [ ...head, ...Array(fillCount).fill("0"), ...tail, ].map((hextet) => hextet.padStart(4, "0")); if (hextets.length !== 8 || !hextets.slice(0, 5).every((hextet) => hextet === "0000") || hextets[5] !== "ffff") { return undefined; } const high = Number.parseInt(hextets[6], 16); const low = Number.parseInt(hextets[7], 16); if (!Number.isFinite(high) || !Number.isFinite(low)) { return undefined; } return [ high >> 8, high & 255, low >> 8, low & 255, ].join("."); } function expandEmbeddedIpv4(address) { if (!address.includes(".")) { return address; } const lastColon = address.lastIndexOf(":"); if (lastColon < 0) { return undefined; } const ipv4 = address.slice(lastColon + 1); if (isIP(ipv4) !== 4 || isBlockedIpv4(ipv4)) { return undefined; } const [first, second, third, fourth] = ipv4.split(".").map((part) => Number.parseInt(part, 10)); const high = ((first << 8) | second).toString(16); const low = ((third << 8) | fourth).toString(16); return `${address.slice(0, lastColon)}:${high}:${low}`; } function parseIpv6ToBigInt(address) { const expanded = expandEmbeddedIpv4(address.toLowerCase()); if (!expanded) { return undefined; } const separatorParts = expanded.split("::"); if (separatorParts.length > 2) { return undefined; } const head = separatorParts[0] ? separatorParts[0].split(":") : []; const tail = separatorParts[1] ? separatorParts[1].split(":") : []; const allExplicit = [...head, ...tail]; if (allExplicit.some((hextet) => !/^[0-9a-f]{1,4}$/.test(hextet))) { return undefined; } const fillCount = separatorParts.length === 2 ? 8 - head.length - tail.length : 0; if (fillCount < 0 || (separatorParts.length === 1 && head.length !== 8)) { return undefined; } const hextets = [ ...head, ...Array(fillCount).fill("0"), ...tail, ]; if (hextets.length !== 8) { return undefined; } let value = 0n; for (const hextet of hextets) { value = (value << 16n) | BigInt(Number.parseInt(hextet, 16)); } return value; } function buildIpv6Cidr(base, prefix) { const value = parseIpv6ToBigInt(base); if (value === undefined) { throw new Error(`Invalid IPv6 CIDR base: ${base}`); } return { base: value, prefix }; } const BLOCKED_IPV6_CIDRS = [ buildIpv6Cidr("::", 128), buildIpv6Cidr("::1", 128), buildIpv6Cidr("64:ff9b::", 96), buildIpv6Cidr("64:ff9b:1::", 48), buildIpv6Cidr("100::", 64), buildIpv6Cidr("2001::", 23), buildIpv6Cidr("2001:db8::", 32), buildIpv6Cidr("2002::", 16), buildIpv6Cidr("fc00::", 7), buildIpv6Cidr("fe80::", 10), buildIpv6Cidr("ff00::", 8), ]; function isIpv6InCidr(address, cidr) { const shift = 128n - BigInt(cidr.prefix); return (address >> shift) === (cidr.base >> shift); } export function isBlockedIpv6(address) { const lower = address.toLowerCase(); const mappedIpv4 = ipv4FromMappedIpv6(lower); if (mappedIpv4) { return isBlockedIpv4(mappedIpv4); } const value = parseIpv6ToBigInt(lower); if (value === undefined) { return true; } return BLOCKED_IPV6_CIDRS.some((cidr) => isIpv6InCidr(value, cidr)); } export function isBlockedIp(address) { const normalized = normalizeHostname(address); const version = isIP(normalized); if (version === 4) { return isBlockedIpv4(normalized); } if (version === 6) { return isBlockedIpv6(normalized); } return true; } export function parseAndValidateTargetUrl(rawUrl) { let parsed; try { parsed = new URL(rawUrl); } catch { throw new Error("URL must be fully qualified."); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error("Only http and https URLs are allowed."); } const hostname = normalizeHostname(parsed.hostname); if (!hostname) { throw new Error("URL host is required."); } if (isAllowedTestLocalhost(hostname, parsed.port)) { return { parsed, hostname, needsDnsCheck: false, }; } if (isBlockedHostname(hostname)) { throw new Error("Local hostnames are not allowed."); } if (isIP(hostname)) { if (isAllowedTestLoopbackIp(hostname, parsed.port)) { return { parsed, hostname, needsDnsCheck: false, }; } if (isBlockedIp(hostname)) { throw new Error("Private, local, or reserved IP addresses are not allowed."); } return { parsed, hostname, needsDnsCheck: false, }; } return { parsed, hostname, needsDnsCheck: true, }; } export async function validateTargetUrl(rawUrl) { const { parsed, hostname, needsDnsCheck } = parseAndValidateTargetUrl(rawUrl); if (!needsDnsCheck) { return parsed; } let records; try { records = await lookup(hostname, { all: true, verbatim: true }); } catch { throw new Error("Could not resolve URL host."); } if (records.length === 0) { throw new Error("URL host did not resolve to an address."); } if (records.some((record) => isBlockedIp(record.address))) { throw new Error("URL host resolves to a private, local, or reserved address."); } return parsed; } export function browserRequestPolicyUrl(rawUrl) { let parsed; try { parsed = new URL(rawUrl); } catch { throw new Error("URL must be fully qualified."); } if (parsed.protocol === "ws:") { parsed.protocol = "http:"; } else if (parsed.protocol === "wss:") { parsed.protocol = "https:"; } return parsed.toString(); } export function parseAndValidateBrowserRequestUrl(rawUrl) { return parseAndValidateTargetUrl(browserRequestPolicyUrl(rawUrl)); } export async function validateBrowserRequestUrl(rawUrl) { return validateTargetUrl(browserRequestPolicyUrl(rawUrl)); }