diff --git a/server.mjs b/server.mjs index 8339202..a136041 100644 --- a/server.mjs +++ b/server.mjs @@ -1,6 +1,7 @@ import http from "node:http"; import { readFile } from "node:fs/promises"; import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; import { extname, join, normalize } from "node:path"; import { fileURLToPath } from "node:url"; @@ -215,30 +216,108 @@ function termResult(input) { return profile ? { ...genericResult(input), ...profile } : genericResult(input); } -function isPrivateHost(hostname) { - const host = hostname.toLowerCase().replace(/^\[|\]$/g, ""); - if (["localhost", "0.0.0.0", "::1"].includes(host) || host.endsWith(".local")) return true; - if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host)) return true; - const match = host.match(/^172\.(\d+)\./); - return Boolean(match && Number(match[1]) >= 16 && Number(match[1]) <= 31); +const blockedIpv4Ranges = [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.88.99.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["198.51.100.0", 24], + ["203.0.113.0", 24], + ["224.0.0.0", 4], + ["240.0.0.0", 4] +]; + +const blockedIpv6Ranges = [ + ["64:ff9b:1::", 48], + ["100::", 64], + ["2001:2::", 48], + ["2001:10::", 28], + ["2001:db8::", 32], + ["fc00::", 7], + ["fe80::", 10], + ["fec0::", 10], + ["ff00::", 8] +]; + +function ipv4ToBigInt(address) { + const parts = address.split(".").map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null; + return parts.reduce((value, part) => (value << 8n) + BigInt(part), 0n); } -function isPrivateAddress(address) { - const normalized = address.toLowerCase(); - if (isPrivateHost(normalized)) return true; - if (normalized === "::" || normalized === "::1" || normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd")) return true; - const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); - return Boolean(mapped && isPrivateHost(mapped[1])); +function ipv6ToBigInt(address) { + let normalized = address.toLowerCase().replace(/^\[|\]$/g, "").split("%")[0]; + if (normalized.includes(".")) { + const separator = normalized.lastIndexOf(":"); + const ipv4 = ipv4ToBigInt(normalized.slice(separator + 1)); + if (separator < 0 || ipv4 === null) return null; + normalized = `${normalized.slice(0, separator)}:${(ipv4 >> 16n).toString(16)}:${(ipv4 & 0xffffn).toString(16)}`; + } + + const halves = normalized.split("::"); + if (halves.length > 2) return null; + const left = halves[0] ? halves[0].split(":") : []; + const right = halves[1] ? halves[1].split(":") : []; + const missing = 8 - left.length - right.length; + if ((halves.length === 1 && missing !== 0) || missing < 0) return null; + const parts = [...left, ...Array(missing).fill("0"), ...right]; + if (parts.length !== 8 || parts.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) return null; + return parts.reduce((value, part) => (value << 16n) + BigInt(`0x${part}`), 0n); +} + +function isInRange(value, network, prefixLength, bitLength) { + const shift = BigInt(bitLength - prefixLength); + return value >> shift === network >> shift; +} + +function isBlockedIpv4(address) { + const value = ipv4ToBigInt(address); + return value !== null && blockedIpv4Ranges.some(([network, prefix]) => { + const networkValue = ipv4ToBigInt(network); + return isInRange(value, networkValue, prefix, 32); + }); +} + +function embeddedIpv4(value, prefix, prefixLength) { + const network = ipv6ToBigInt(prefix); + if (!isInRange(value, network, prefixLength, 128)) return false; + const ipv4 = Number(value & 0xffffffffn); + return isBlockedIpv4([24, 16, 8, 0].map((shift) => (ipv4 >>> shift) & 255).join(".")); +} + +function isBlockedAddress(address) { + const normalized = address.toLowerCase().replace(/^\[|\]$/g, "").split("%")[0]; + const version = isIP(normalized); + if (version === 4) return isBlockedIpv4(normalized); + if (version !== 6) return false; + + const value = ipv6ToBigInt(normalized); + if (value === null) return true; + if (embeddedIpv4(value, "::", 96) || embeddedIpv4(value, "::ffff:0:0", 96) || embeddedIpv4(value, "64:ff9b::", 96)) return true; + return blockedIpv6Ranges.some(([network, prefix]) => isInRange(value, ipv6ToBigInt(network), prefix, 128)); +} + +function isBlockedHost(hostname) { + const host = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true; + return isIP(host) > 0 && isBlockedAddress(host); } async function assertPublicUrl(target) { const url = new URL(target); - if (!["http:", "https:"].includes(url.protocol) || isPrivateHost(url.hostname)) { + if (!["http:", "https:"].includes(url.protocol) || isBlockedHost(url.hostname)) { throw new Error("只支持公开的 HTTP 或 HTTPS 网页"); } const addresses = await lookup(url.hostname, { all: true, verbatim: true }); - if (!addresses.length || addresses.some(({ address }) => isPrivateAddress(address))) { - throw new Error("不能读取本机或局域网地址"); + if (!addresses.length || addresses.some(({ address }) => isBlockedAddress(address))) { + throw new Error("不能读取本机、内网或特殊用途地址"); } return url; } diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index a6efa28..31a3d58 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -87,8 +87,20 @@ test("answers usage and example follow-ups", async () => { assert.match(example.text, /MCP Server/); }); -test("does not fetch private network pages", async () => { - const result = await post("/api/analyze", { input: "http://127.0.0.1:4173" }); - assert.equal(result.category, "网页概览"); - assert.match(result.source.warning, /只支持公开/); +test("does not fetch non-public network pages", async () => { + const blockedUrls = [ + "http://127.0.0.1:4173", + "http://169.254.169.254/latest/meta-data/", + "http://100.64.0.1/", + "http://[::1]/", + "http://[fe80::1]/", + "http://[fc00::1]/", + "http://[::ffff:169.254.169.254]/" + ]; + + for (const input of blockedUrls) { + const result = await post("/api/analyze", { input }); + assert.equal(result.category, "网页概览", input); + assert.match(result.source.warning, /只支持公开/, input); + } });