domainlooker
Version:
A fast CLI and MCP server for inspecting domains: WHOIS/RDAP, DNS, SSL, ports, subdomains, with CSV/JSON export.
78 lines • 3.2 kB
JavaScript
import { promises as dns } from 'dns';
import { isIP } from 'net';
/**
* Guards against pointing the network probes (port scan, TLS connect, HTTP) at
* internal infrastructure. The CLI trusts the human running it, but the MCP
* server is reachable by an AI agent whose "domain" argument may be attacker- or
* injection-controlled, so MCP tools must run every target through
* `assertPublicDomain` before touching the network.
*/
const BLOCKED_SUFFIXES = ['.localhost', '.local', '.internal', '.home.arpa'];
const BLOCKED_HOSTS = new Set(['localhost']);
/** Loose "does this look like a hostname" check (a dot, host chars, optional trailing dot). */
export function isDomainShaped(value) {
return /^(?=.{1,254}$)([a-zA-Z0-9_-]+\.)+[a-zA-Z0-9_-]+\.?$/.test(value);
}
export function isPrivateAddress(ip) {
const version = isIP(ip);
if (version === 4)
return isPrivateV4(ip);
if (version === 6)
return isPrivateV6(ip.toLowerCase());
return false;
}
function isPrivateV4(ip) {
const octets = ip.split('.').map(Number);
if (octets.length !== 4 || octets.some(o => Number.isNaN(o) || o < 0 || o > 255))
return true;
const [a, b] = octets;
return (a === 0 || // 0.0.0.0/8 "this host"
a === 10 || // private
a === 127 || // loopback
(a === 100 && b >= 64 && b <= 127) || // CGNAT 100.64.0.0/10
(a === 169 && b === 254) || // link-local / cloud metadata
(a === 172 && b >= 16 && b <= 31) || // private
(a === 192 && b === 168) // private
);
}
function isPrivateV6(ip) {
if (ip === '::1' || ip === '::')
return true;
// IPv4-mapped (::ffff:1.2.3.4) — judge by the embedded IPv4.
const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped)
return isPrivateV4(mapped[1]);
const head = ip.split(':')[0];
const prefix = parseInt(head || '0', 16);
if (Number.isNaN(prefix))
return true;
if ((prefix & 0xfe00) === 0xfc00)
return true; // fc00::/7 unique-local
if ((prefix & 0xffc0) === 0xfe80)
return true; // fe80::/10 link-local
return false;
}
/**
* Reject IP literals, localhost/internal names, non-domain-shaped input, and
* public names that resolve to private/loopback/link-local addresses.
* Throws an Error (with a caller-safe message) when the target is not allowed.
*/
export async function assertPublicDomain(domain) {
const host = domain.trim().toLowerCase().replace(/\.$/, '');
if (isIP(host) !== 0) {
throw new Error('IP addresses are not accepted; provide a domain name.');
}
if (BLOCKED_HOSTS.has(host) || BLOCKED_SUFFIXES.some(s => host.endsWith(s))) {
throw new Error(`Refusing to inspect internal host: ${domain}`);
}
if (!isDomainShaped(host)) {
throw new Error(`Not a valid domain: ${domain}`);
}
const addresses = await dns.lookup(host, { all: true }).catch(() => []);
for (const { address } of addresses) {
if (isPrivateAddress(address)) {
throw new Error(`Refusing to inspect ${domain}: it resolves to a private address (${address}).`);
}
}
}
//# sourceMappingURL=target-guard.js.map