Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 94 additions & 15 deletions server.mjs
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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;
}
Expand Down
20 changes: 16 additions & 4 deletions test/smoke.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});