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
JavaScript
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));
}