mcp-searxng
Version:
MCP server for SearXNG integration
99 lines (98 loc) • 4.24 kB
JavaScript
import { isIP } from "node:net";
import { createURLSecurityPolicyError } from "./error-handler.js";
import { getHttpSecurityConfig } from "./http-security.js";
export const URL_SECURITY_POLICY_DNS_ERROR = "URLSecurityPolicyDnsError";
export function isPrivateHostname(hostname) {
const lower = hostname.toLowerCase().replace(/\.+$/, "");
return lower === "localhost" || lower.endsWith(".localhost");
}
function ipv4ToInt(ip) {
return ip.split(".").reduce((acc, octet) => (acc << 8) + Number(octet), 0) >>> 0;
}
// Blocked IPv4 ranges — RFC1918 private space plus IANA special-purpose ranges
// (RFC 6890). Kept as a single CIDR table so every range is enforced by the same
// integer match and the full blocklist is auditable at a glance. Sorted by network.
const BLOCKED_V4_CIDRS = [
[ipv4ToInt("0.0.0.0"), 8], // "this" network / unspecified
[ipv4ToInt("10.0.0.0"), 8], // RFC1918 private
[ipv4ToInt("100.64.0.0"), 10], // CGNAT (RFC 6598) - Tailscale default, overlays
[ipv4ToInt("127.0.0.0"), 8], // loopback
[ipv4ToInt("169.254.0.0"), 16], // link-local
[ipv4ToInt("172.16.0.0"), 12], // RFC1918 private
[ipv4ToInt("192.0.0.0"), 24], // IETF protocol assignments
[ipv4ToInt("192.0.2.0"), 24], // TEST-NET-1
[ipv4ToInt("192.88.99.0"), 24], // 6to4 relay anycast (RFC 7526, deprecated)
[ipv4ToInt("192.168.0.0"), 16], // RFC1918 private
[ipv4ToInt("198.18.0.0"), 15], // benchmarking (RFC 2544)
[ipv4ToInt("198.51.100.0"), 24], // TEST-NET-2
[ipv4ToInt("203.0.113.0"), 24], // TEST-NET-3
[ipv4ToInt("224.0.0.0"), 4], // multicast
[ipv4ToInt("240.0.0.0"), 4], // reserved / 255.255.255.255 broadcast
];
export function isPrivateIpv4(hostname) {
if (isIP(hostname) !== 4) {
return false;
}
const ip = ipv4ToInt(hostname);
return BLOCKED_V4_CIDRS.some(([net, bits]) => ((ip ^ net) >>> (32 - bits)) === 0);
}
export function isPrivateIPv6(hostname) {
// url.hostname wraps IPv6 in brackets (e.g. "[::1]") - strip them first
const addr = (hostname.startsWith("[") && hostname.endsWith("]")
? hostname.slice(1, -1)
: hostname).toLowerCase();
if (isIP(addr) !== 6)
return false;
if (addr === "::1")
return true; // loopback
if (addr === "::")
return true; // unspecified
if (/^f[cd]/i.test(addr))
return true; // ULA fc00::/7
if (/^fe[89ab][0-9a-f]:/i.test(addr))
return true; // link-local fe80::/10
// IPv4-mapped ::ffff:<ipv4> - delegate to the IPv4 check
const mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (mapped)
return isPrivateIpv4(mapped[1]);
// IPv4-mapped ::ffff:<hhhh>:<hhhh> - convert the hex segments to dotted decimal
const hexMapped = addr.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (hexMapped) {
const high = parseInt(hexMapped[1], 16);
const low = parseInt(hexMapped[2], 16);
const ipv4 = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
return isPrivateIpv4(ipv4);
}
return false;
}
export function isPrivateAddress(address) {
return isPrivateIpv4(address) || isPrivateIPv6(address);
}
export function assertUrlAllowed(url) {
const security = getHttpSecurityConfig();
if (security.allowPrivateUrls) {
return;
}
if (isPrivateHostname(url.hostname) || isPrivateIpv4(url.hostname) || isPrivateIPv6(url.hostname)) {
throw createURLSecurityPolicyError(url.toString());
}
}
export function createUrlSecurityPolicyDnsError(hostname) {
const error = new Error(`Resolved private address blocked by security policy for ${hostname}`);
error.name = URL_SECURITY_POLICY_DNS_ERROR;
error.code = URL_SECURITY_POLICY_DNS_ERROR;
return error;
}
export function isUrlSecurityPolicyDnsError(error) {
let current = error;
while (current) {
if (current.name === URL_SECURITY_POLICY_DNS_ERROR || current.code === URL_SECURITY_POLICY_DNS_ERROR) {
return true;
}
if (Array.isArray(current.errors) && current.errors.some(isUrlSecurityPolicyDnsError)) {
return true;
}
current = current.cause;
}
return false;
}