mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 07:54:07 +00:00
refactor: remove old net policy sources
This commit is contained in:
@@ -1 +0,0 @@
|
||||
export const blockedIpv6MulticastLiterals = ["ff02::1", "ff05::1:3", "[ff02::1]"] as const;
|
||||
@@ -1,187 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { blockedIpv6MulticastLiterals } from "./ip-test-fixtures.js";
|
||||
import {
|
||||
extractEmbeddedIpv4FromIpv6,
|
||||
isBlockedSpecialUseIpv4Address,
|
||||
isBlockedSpecialUseIpv6Address,
|
||||
isCanonicalDottedDecimalIPv4,
|
||||
isCarrierGradeNatIpv4Address,
|
||||
isCloudMetadataIpAddress,
|
||||
isIpInCidr,
|
||||
isIpv4Address,
|
||||
isIpv6Address,
|
||||
isLegacyIpv4Literal,
|
||||
isLinkLocalIpAddress,
|
||||
isLoopbackIpAddress,
|
||||
isPrivateOrLoopbackIpAddress,
|
||||
isRfc1918Ipv4Address,
|
||||
normalizeIpAddress,
|
||||
parseCanonicalIpAddress,
|
||||
parseLooseIpAddress,
|
||||
} from "./ip.js";
|
||||
|
||||
describe("shared ip helpers", () => {
|
||||
it("distinguishes canonical dotted IPv4 from legacy forms", () => {
|
||||
expect(isCanonicalDottedDecimalIPv4("127.0.0.1")).toBe(true);
|
||||
expect(isCanonicalDottedDecimalIPv4("0177.0.0.1")).toBe(false);
|
||||
expect(isLegacyIpv4Literal("0177.0.0.1")).toBe(true);
|
||||
expect(isLegacyIpv4Literal("127.1")).toBe(true);
|
||||
expect(isLegacyIpv4Literal("example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches both IPv4 and IPv6 CIDRs", () => {
|
||||
expect(isIpInCidr("10.42.0.59", "10.42.0.0/24")).toBe(true);
|
||||
expect(isIpInCidr("10.43.0.59", "10.42.0.0/24")).toBe(false);
|
||||
expect(isIpInCidr("2001:db8::1234", "2001:db8::/32")).toBe(true);
|
||||
expect(isIpInCidr("2001:db9::1234", "2001:db8::/32")).toBe(false);
|
||||
expect(isIpInCidr("::ffff:127.0.0.1", "127.0.0.1")).toBe(true);
|
||||
expect(isIpInCidr("127.0.0.1", "::ffff:127.0.0.2")).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts embedded IPv4 for transition prefixes", () => {
|
||||
const cases = [
|
||||
["::ffff:127.0.0.1", "127.0.0.1"],
|
||||
["::127.0.0.1", "127.0.0.1"],
|
||||
["64:ff9b::8.8.8.8", "8.8.8.8"],
|
||||
["64:ff9b:1::10.0.0.1", "10.0.0.1"],
|
||||
["2002:0808:0808::", "8.8.8.8"],
|
||||
["2001::f7f7:f7f7", "8.8.8.8"],
|
||||
["2001:4860:1::5efe:7f00:1", "127.0.0.1"],
|
||||
] as const;
|
||||
for (const [ipv6Literal, expectedIpv4] of cases) {
|
||||
const parsed = parseCanonicalIpAddress(ipv6Literal);
|
||||
expect(parsed?.kind(), ipv6Literal).toBe("ipv6");
|
||||
if (!parsed || !isIpv6Address(parsed)) {
|
||||
continue;
|
||||
}
|
||||
expect(extractEmbeddedIpv4FromIpv6(parsed)?.toString(), ipv6Literal).toBe(expectedIpv4);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats blocked IPv6 classes as private/internal", () => {
|
||||
expect(isPrivateOrLoopbackIpAddress("fec0::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackIpAddress("2001:db8::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackIpAddress("2001:2::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackIpAddress("100::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackIpAddress("2001:20::1")).toBe(true);
|
||||
for (const literal of blockedIpv6MulticastLiterals) {
|
||||
expect(isPrivateOrLoopbackIpAddress(literal)).toBe(true);
|
||||
}
|
||||
expect(isPrivateOrLoopbackIpAddress("2001:4860:4860::8888")).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes canonical IP strings and loopback detection", () => {
|
||||
expect(normalizeIpAddress("[::FFFF:127.0.0.1]")).toBe("127.0.0.1");
|
||||
expect(normalizeIpAddress(" [2001:DB8::1] ")).toBe("2001:db8::1");
|
||||
expect(isLoopbackIpAddress("::ffff:127.0.0.1")).toBe(true);
|
||||
expect(isLoopbackIpAddress("198.18.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects link-local addresses without treating normal private ranges as link-local", () => {
|
||||
expect(isLinkLocalIpAddress("169.254.169.254")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("::ffff:169.254.169.254")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("2852039166")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("0xa9fea9fe")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("0xa9.0xfe.0xa9.0xfe")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("64:ff9b::169.254.169.254")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("64:ff9b:1::a9fe:a9fe")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("2002:a9fe:a9fe::")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("fe80::1%lo0")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("[fe80::1]")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("10.0.0.5")).toBe(false);
|
||||
expect(isLinkLocalIpAddress("127.0.0.1")).toBe(false);
|
||||
expect(isLinkLocalIpAddress("fd00::1")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects known non-link-local cloud metadata IPs", () => {
|
||||
expect(isCloudMetadataIpAddress("100.100.100.200")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("::ffff:100.100.100.200")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("64:ff9b::100.100.100.200")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("64:ff9b:1::6464:64c8")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("2002:6464:64c8::")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("1684301000")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("fd00:ec2::254")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("[fd00:ec2::254]")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("100.100.100.201")).toBe(false);
|
||||
expect(isCloudMetadataIpAddress("169.254.169.254")).toBe(false);
|
||||
expect(isCloudMetadataIpAddress("fd00::1")).toBe(false);
|
||||
});
|
||||
|
||||
it("parses loose legacy IPv4 literals that canonical parsing rejects", () => {
|
||||
expect(parseCanonicalIpAddress("0177.0.0.1")).toBeUndefined();
|
||||
expect(parseLooseIpAddress("0177.0.0.1")?.toString()).toBe("127.0.0.1");
|
||||
expect(parseLooseIpAddress("[::1]")?.toString()).toBe("::1");
|
||||
});
|
||||
|
||||
it("classifies RFC1918 and carrier-grade-nat IPv4 ranges", () => {
|
||||
expect(isRfc1918Ipv4Address("10.42.0.59")).toBe(true);
|
||||
expect(isRfc1918Ipv4Address("100.64.0.1")).toBe(false);
|
||||
expect(isCarrierGradeNatIpv4Address("100.64.0.1")).toBe(true);
|
||||
expect(isCarrierGradeNatIpv4Address("10.42.0.59")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks special-use IPv4 ranges while allowing optional RFC2544 benchmark addresses", () => {
|
||||
const loopback = parseCanonicalIpAddress("127.0.0.1");
|
||||
const benchmark = parseCanonicalIpAddress("198.18.0.1");
|
||||
|
||||
expect(loopback?.kind()).toBe("ipv4");
|
||||
expect(benchmark?.kind()).toBe("ipv4");
|
||||
if (!loopback || !isIpv4Address(loopback) || !benchmark || !isIpv4Address(benchmark)) {
|
||||
throw new Error("expected ipv4 fixtures");
|
||||
}
|
||||
|
||||
expect(isBlockedSpecialUseIpv4Address(loopback)).toBe(true);
|
||||
expect(isBlockedSpecialUseIpv4Address(benchmark)).toBe(true);
|
||||
expect(isBlockedSpecialUseIpv4Address(benchmark, { allowRfc2544BenchmarkRange: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks IPv6 unique-local addresses by default and exempts them on opt-in (#74351)", () => {
|
||||
// fc00::/7 is the IPv6 ULA range. Sing-box / Clash / Surge fake-ip
|
||||
// proxies resolve foreign domains here, alongside the IPv4 198.18.0.0/15
|
||||
// benchmark range. Operators using those proxies need both ranges
|
||||
// exempted to keep web_fetch working.
|
||||
const ula = parseCanonicalIpAddress("fc00::1");
|
||||
expect(ula?.kind()).toBe("ipv6");
|
||||
if (!ula || !isIpv6Address(ula)) {
|
||||
throw new Error("expected ipv6 fixture");
|
||||
}
|
||||
|
||||
// Default policy (no options) must continue to block the ULA range.
|
||||
expect(isBlockedSpecialUseIpv6Address(ula)).toBe(true);
|
||||
expect(isBlockedSpecialUseIpv6Address(ula, {})).toBe(true);
|
||||
expect(isBlockedSpecialUseIpv6Address(ula, { allowUniqueLocalRange: false })).toBe(true);
|
||||
|
||||
// Opt-in flag — the only path the SSRF policy uses to thread fake-ip
|
||||
// proxy intent through to the address classifier.
|
||||
expect(isBlockedSpecialUseIpv6Address(ula, { allowUniqueLocalRange: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("opt-in unique-local exemption does NOT bleed into other special-use IPv6 ranges (#74351)", () => {
|
||||
// The exemption must be scoped: loopback (::1), unspecified (::), and
|
||||
// multicast (ff00::/8) all stay blocked even when `allowUniqueLocalRange`
|
||||
// is set, otherwise the flag silently widens the SSRF escape hatch
|
||||
// beyond what operators opted into.
|
||||
const loopback = parseCanonicalIpAddress("::1");
|
||||
const multicast = parseCanonicalIpAddress("ff02::1");
|
||||
const siteLocal = parseCanonicalIpAddress("fec0::1"); // deprecated fec0::/10
|
||||
|
||||
if (
|
||||
!loopback ||
|
||||
!isIpv6Address(loopback) ||
|
||||
!multicast ||
|
||||
!isIpv6Address(multicast) ||
|
||||
!siteLocal ||
|
||||
!isIpv6Address(siteLocal)
|
||||
) {
|
||||
throw new Error("expected ipv6 fixtures");
|
||||
}
|
||||
|
||||
for (const options of [{}, { allowUniqueLocalRange: true }] as const) {
|
||||
expect(isBlockedSpecialUseIpv6Address(loopback, options)).toBe(true);
|
||||
expect(isBlockedSpecialUseIpv6Address(multicast, options)).toBe(true);
|
||||
expect(isBlockedSpecialUseIpv6Address(siteLocal, options)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,412 +0,0 @@
|
||||
import ipaddr from "ipaddr.js";
|
||||
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.js";
|
||||
|
||||
export type ParsedIpAddress = ipaddr.IPv4 | ipaddr.IPv6;
|
||||
type Ipv4Range = ReturnType<ipaddr.IPv4["range"]>;
|
||||
type Ipv6Range = ReturnType<ipaddr.IPv6["range"]>;
|
||||
type BlockedIpv6Range = Ipv6Range | "discard";
|
||||
|
||||
const BLOCKED_IPV4_SPECIAL_USE_RANGES = new Set<Ipv4Range>([
|
||||
"unspecified",
|
||||
"broadcast",
|
||||
"multicast",
|
||||
"linkLocal",
|
||||
"loopback",
|
||||
"carrierGradeNat",
|
||||
"private",
|
||||
"reserved",
|
||||
]);
|
||||
|
||||
const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set<Ipv4Range>([
|
||||
"loopback",
|
||||
"private",
|
||||
"linkLocal",
|
||||
"carrierGradeNat",
|
||||
]);
|
||||
|
||||
const BLOCKED_IPV6_SPECIAL_USE_RANGES = new Set<BlockedIpv6Range>([
|
||||
"unspecified",
|
||||
"loopback",
|
||||
"linkLocal",
|
||||
"uniqueLocal",
|
||||
"multicast",
|
||||
"reserved",
|
||||
"benchmarking",
|
||||
"discard",
|
||||
"orchid2",
|
||||
]);
|
||||
const RFC2544_BENCHMARK_PREFIX: [ipaddr.IPv4, number] = [ipaddr.IPv4.parse("198.18.0.0"), 15];
|
||||
const CLOUD_METADATA_IP_ADDRESSES = new Set(["100.100.100.200", "fd00:ec2::254"]);
|
||||
export type Ipv4SpecialUseBlockOptions = {
|
||||
allowRfc2544BenchmarkRange?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-call exemptions for `isBlockedSpecialUseIpv6Address`. Mirror of
|
||||
* {@link Ipv4SpecialUseBlockOptions} for the IPv6 side. Currently only
|
||||
* `allowUniqueLocalRange` is exposed (#74351); other reserved IPv6 ranges stay
|
||||
* unconditionally blocked because they have no documented fake-ip / proxy
|
||||
* use case.
|
||||
*/
|
||||
export type Ipv6SpecialUseBlockOptions = {
|
||||
/**
|
||||
* When true, exempt addresses in `fc00::/7` (the IPv6 Unique Local Address
|
||||
* block, RFC 4193) from the SSRF private-IP block. Sing-box / Clash / Surge
|
||||
* fake-ip implementations resolve foreign domains to ULA addresses
|
||||
* alongside RFC 2544 benchmark IPv4 addresses, and operators using those
|
||||
* proxy stacks need both ranges exempted to keep `web_fetch` working.
|
||||
*/
|
||||
allowUniqueLocalRange?: boolean;
|
||||
};
|
||||
|
||||
const EMBEDDED_IPV4_SENTINEL_RULES: Array<{
|
||||
matches: (parts: number[]) => boolean;
|
||||
toHextets: (parts: number[]) => [high: number, low: number];
|
||||
}> = [
|
||||
{
|
||||
// IPv4-compatible form ::w.x.y.z (deprecated, but still seen in parser edge-cases).
|
||||
matches: (parts) =>
|
||||
parts[0] === 0 &&
|
||||
parts[1] === 0 &&
|
||||
parts[2] === 0 &&
|
||||
parts[3] === 0 &&
|
||||
parts[4] === 0 &&
|
||||
parts[5] === 0,
|
||||
toHextets: (parts) => [parts[6], parts[7]],
|
||||
},
|
||||
{
|
||||
// NAT64 local-use prefix: 64:ff9b:1::/48.
|
||||
matches: (parts) =>
|
||||
parts[0] === 0x0064 &&
|
||||
parts[1] === 0xff9b &&
|
||||
parts[2] === 0x0001 &&
|
||||
parts[3] === 0 &&
|
||||
parts[4] === 0 &&
|
||||
parts[5] === 0,
|
||||
toHextets: (parts) => [parts[6], parts[7]],
|
||||
},
|
||||
{
|
||||
// 6to4 prefix: 2002::/16 (IPv4 lives in hextets 1..2).
|
||||
matches: (parts) => parts[0] === 0x2002,
|
||||
toHextets: (parts) => [parts[1], parts[2]],
|
||||
},
|
||||
{
|
||||
// Teredo prefix: 2001:0000::/32 (client IPv4 XOR 0xffff in hextets 6..7).
|
||||
matches: (parts) => parts[0] === 0x2001 && parts[1] === 0x0000,
|
||||
toHextets: (parts) => [parts[6] ^ 0xffff, parts[7] ^ 0xffff],
|
||||
},
|
||||
{
|
||||
// ISATAP IID marker: ....:0000:5efe:w.x.y.z with u/g bits allowed in hextet 4.
|
||||
matches: (parts) => (parts[4] & 0xfcff) === 0 && parts[5] === 0x5efe,
|
||||
toHextets: (parts) => [parts[6], parts[7]],
|
||||
},
|
||||
];
|
||||
|
||||
function stripIpv6Brackets(value: string): string {
|
||||
if (value.startsWith("[") && value.endsWith("]")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isNumericIpv4LiteralPart(value: string): boolean {
|
||||
return /^[0-9]+$/.test(value) || /^0x[0-9a-f]+$/i.test(value);
|
||||
}
|
||||
|
||||
function parseIpv6WithEmbeddedIpv4(raw: string): ipaddr.IPv6 | undefined {
|
||||
if (!raw.includes(":") || !raw.includes(".")) {
|
||||
return undefined;
|
||||
}
|
||||
const match = /^(.*:)([^:%]+(?:\.[^:%]+){3})(%[0-9A-Za-z]+)?$/i.exec(raw);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const [, prefix, embeddedIpv4, zoneSuffix = ""] = match;
|
||||
if (!ipaddr.IPv4.isValidFourPartDecimal(embeddedIpv4)) {
|
||||
return undefined;
|
||||
}
|
||||
const octets = embeddedIpv4.split(".").map((part) => Number.parseInt(part, 10));
|
||||
const high = ((octets[0] << 8) | octets[1]).toString(16);
|
||||
const low = ((octets[2] << 8) | octets[3]).toString(16);
|
||||
const normalizedIpv6 = `${prefix}${high}:${low}${zoneSuffix}`;
|
||||
if (!ipaddr.IPv6.isValid(normalizedIpv6)) {
|
||||
return undefined;
|
||||
}
|
||||
return ipaddr.IPv6.parse(normalizedIpv6);
|
||||
}
|
||||
|
||||
export function isIpv4Address(address: ParsedIpAddress): address is ipaddr.IPv4 {
|
||||
return address.kind() === "ipv4";
|
||||
}
|
||||
|
||||
export function isIpv6Address(address: ParsedIpAddress): address is ipaddr.IPv6 {
|
||||
return address.kind() === "ipv6";
|
||||
}
|
||||
|
||||
function normalizeIpv4MappedAddress(address: ParsedIpAddress): ParsedIpAddress {
|
||||
if (!isIpv6Address(address)) {
|
||||
return address;
|
||||
}
|
||||
if (!address.isIPv4MappedAddress()) {
|
||||
return address;
|
||||
}
|
||||
return address.toIPv4Address();
|
||||
}
|
||||
|
||||
function normalizeIpParseInput(raw: string | undefined): string | undefined {
|
||||
const trimmed = normalizeOptionalString(raw);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return stripIpv6Brackets(trimmed);
|
||||
}
|
||||
|
||||
export function parseCanonicalIpAddress(raw: string | undefined): ParsedIpAddress | undefined {
|
||||
const normalized = normalizeIpParseInput(raw);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (ipaddr.IPv4.isValid(normalized)) {
|
||||
if (!ipaddr.IPv4.isValidFourPartDecimal(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return ipaddr.IPv4.parse(normalized);
|
||||
}
|
||||
if (ipaddr.IPv6.isValid(normalized)) {
|
||||
return ipaddr.IPv6.parse(normalized);
|
||||
}
|
||||
return parseIpv6WithEmbeddedIpv4(normalized);
|
||||
}
|
||||
|
||||
export function parseLooseIpAddress(raw: string | undefined): ParsedIpAddress | undefined {
|
||||
const normalized = normalizeIpParseInput(raw);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (ipaddr.isValid(normalized)) {
|
||||
return ipaddr.parse(normalized);
|
||||
}
|
||||
return parseIpv6WithEmbeddedIpv4(normalized);
|
||||
}
|
||||
|
||||
export function normalizeIpAddress(raw: string | undefined): string | undefined {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeIpv4MappedAddress(parsed);
|
||||
return normalizeLowercaseStringOrEmpty(normalized.toString());
|
||||
}
|
||||
|
||||
export function isCanonicalDottedDecimalIPv4(raw: string | undefined): boolean {
|
||||
const trimmed = normalizeOptionalString(raw);
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = stripIpv6Brackets(trimmed);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return ipaddr.IPv4.isValidFourPartDecimal(normalized);
|
||||
}
|
||||
|
||||
export function isLegacyIpv4Literal(raw: string | undefined): boolean {
|
||||
const trimmed = normalizeOptionalString(raw);
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = stripIpv6Brackets(trimmed);
|
||||
if (!normalized || normalized.includes(":")) {
|
||||
return false;
|
||||
}
|
||||
if (isCanonicalDottedDecimalIPv4(normalized)) {
|
||||
return false;
|
||||
}
|
||||
const parts = normalized.split(".");
|
||||
if (parts.length === 0 || parts.length > 4) {
|
||||
return false;
|
||||
}
|
||||
if (parts.some((part) => part.length === 0)) {
|
||||
return false;
|
||||
}
|
||||
if (!parts.every((part) => isNumericIpv4LiteralPart(part))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isLoopbackIpAddress(raw: string | undefined): boolean {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeIpv4MappedAddress(parsed);
|
||||
return normalized.range() === "loopback";
|
||||
}
|
||||
|
||||
export function isLinkLocalIpAddress(raw: string | undefined): boolean {
|
||||
const parsed = parseLooseIpAddress(raw);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeIpv4MappedAddress(parsed);
|
||||
if (isIpv4Address(normalized)) {
|
||||
return normalized.range() === "linkLocal";
|
||||
}
|
||||
const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(normalized);
|
||||
if (embeddedIpv4?.range() === "linkLocal") {
|
||||
return true;
|
||||
}
|
||||
return normalized.range() === "linkLocal";
|
||||
}
|
||||
|
||||
export function isCloudMetadataIpAddress(raw: string | undefined): boolean {
|
||||
const parsed = parseLooseIpAddress(raw);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeIpv4MappedAddress(parsed);
|
||||
if (isIpv6Address(normalized)) {
|
||||
const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(normalized);
|
||||
if (embeddedIpv4 && CLOUD_METADATA_IP_ADDRESSES.has(embeddedIpv4.toString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return CLOUD_METADATA_IP_ADDRESSES.has(normalized.toString());
|
||||
}
|
||||
|
||||
export function isPrivateOrLoopbackIpAddress(raw: string | undefined): boolean {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeIpv4MappedAddress(parsed);
|
||||
if (isIpv4Address(normalized)) {
|
||||
return PRIVATE_OR_LOOPBACK_IPV4_RANGES.has(normalized.range());
|
||||
}
|
||||
return isBlockedSpecialUseIpv6Address(normalized);
|
||||
}
|
||||
|
||||
export function isBlockedSpecialUseIpv6Address(
|
||||
address: ipaddr.IPv6,
|
||||
options: Ipv6SpecialUseBlockOptions = {},
|
||||
): boolean {
|
||||
// ipaddr.js returns "discard" at runtime for 100::/64, but its published
|
||||
// TypeScript IPv6Range union omits that literal.
|
||||
const range = address.range() as BlockedIpv6Range;
|
||||
if (range === "uniqueLocal" && options.allowUniqueLocalRange === true) {
|
||||
// Operators running fake-ip proxy stacks (sing-box, Clash, Surge) opt in
|
||||
// to fc00::/7 reaching the network — same intent as
|
||||
// `allowRfc2544BenchmarkRange` for the IPv4 side (#74351).
|
||||
return false;
|
||||
}
|
||||
if (BLOCKED_IPV6_SPECIAL_USE_RANGES.has(range)) {
|
||||
return true;
|
||||
}
|
||||
// ipaddr.js does not classify deprecated site-local fec0::/10 as private.
|
||||
return (address.parts[0] & 0xffc0) === 0xfec0;
|
||||
}
|
||||
|
||||
export function isRfc1918Ipv4Address(raw: string | undefined): boolean {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed || !isIpv4Address(parsed)) {
|
||||
return false;
|
||||
}
|
||||
return parsed.range() === "private";
|
||||
}
|
||||
|
||||
export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed || !isIpv4Address(parsed)) {
|
||||
return false;
|
||||
}
|
||||
return parsed.range() === "carrierGradeNat";
|
||||
}
|
||||
|
||||
export function isBlockedSpecialUseIpv4Address(
|
||||
address: ipaddr.IPv4,
|
||||
options: Ipv4SpecialUseBlockOptions = {},
|
||||
): boolean {
|
||||
const inRfc2544BenchmarkRange = address.match(RFC2544_BENCHMARK_PREFIX);
|
||||
if (inRfc2544BenchmarkRange && options.allowRfc2544BenchmarkRange === true) {
|
||||
return false;
|
||||
}
|
||||
return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()) || inRfc2544BenchmarkRange;
|
||||
}
|
||||
|
||||
function decodeIpv4FromHextets(high: number, low: number): ipaddr.IPv4 {
|
||||
const octets: [number, number, number, number] = [
|
||||
(high >>> 8) & 0xff,
|
||||
high & 0xff,
|
||||
(low >>> 8) & 0xff,
|
||||
low & 0xff,
|
||||
];
|
||||
return ipaddr.IPv4.parse(octets.join("."));
|
||||
}
|
||||
|
||||
export function extractEmbeddedIpv4FromIpv6(address: ipaddr.IPv6): ipaddr.IPv4 | undefined {
|
||||
if (address.isIPv4MappedAddress()) {
|
||||
return address.toIPv4Address();
|
||||
}
|
||||
if (address.range() === "rfc6145") {
|
||||
return decodeIpv4FromHextets(address.parts[6], address.parts[7]);
|
||||
}
|
||||
if (address.range() === "rfc6052") {
|
||||
return decodeIpv4FromHextets(address.parts[6], address.parts[7]);
|
||||
}
|
||||
for (const rule of EMBEDDED_IPV4_SENTINEL_RULES) {
|
||||
if (!rule.matches(address.parts)) {
|
||||
continue;
|
||||
}
|
||||
const [high, low] = rule.toHextets(address.parts);
|
||||
return decodeIpv4FromHextets(high, low);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isIpInCidr(ip: string, cidr: string): boolean {
|
||||
const normalizedIp = parseCanonicalIpAddress(ip);
|
||||
if (!normalizedIp) {
|
||||
return false;
|
||||
}
|
||||
const candidate = cidr.trim();
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
const comparableIp = normalizeIpv4MappedAddress(normalizedIp);
|
||||
if (!candidate.includes("/")) {
|
||||
const exact = parseCanonicalIpAddress(candidate);
|
||||
if (!exact) {
|
||||
return false;
|
||||
}
|
||||
const comparableExact = normalizeIpv4MappedAddress(exact);
|
||||
return (
|
||||
comparableIp.kind() === comparableExact.kind() &&
|
||||
comparableIp.toString() === comparableExact.toString()
|
||||
);
|
||||
}
|
||||
|
||||
let parsedCidr: [ParsedIpAddress, number];
|
||||
try {
|
||||
parsedCidr = ipaddr.parseCIDR(candidate);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [baseAddress, prefixLength] = parsedCidr;
|
||||
const comparableBase = normalizeIpv4MappedAddress(baseAddress);
|
||||
if (comparableIp.kind() !== comparableBase.kind()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (isIpv4Address(comparableIp) && isIpv4Address(comparableBase)) {
|
||||
return comparableIp.match([comparableBase, prefixLength]);
|
||||
}
|
||||
if (isIpv6Address(comparableIp) && isIpv6Address(comparableBase)) {
|
||||
return comparableIp.match([comparableBase, prefixLength]);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateDottedDecimalIPv4Input, validateIPv4AddressInput } from "./ipv4.js";
|
||||
|
||||
describe("shared/net/ipv4", () => {
|
||||
it("requires a value for custom bind mode", () => {
|
||||
expect(validateDottedDecimalIPv4Input(undefined)).toBe(
|
||||
"IP address is required for custom bind mode",
|
||||
);
|
||||
expect(validateDottedDecimalIPv4Input("")).toBe("IP address is required for custom bind mode");
|
||||
expect(validateDottedDecimalIPv4Input(" ")).toBe(
|
||||
"Invalid IPv4 address (e.g., 192.168.1.100)",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts canonical dotted-decimal ipv4 only", () => {
|
||||
expect(validateDottedDecimalIPv4Input("0.0.0.0")).toBeUndefined();
|
||||
expect(validateDottedDecimalIPv4Input("192.168.1.100")).toBeUndefined();
|
||||
expect(validateDottedDecimalIPv4Input(" 192.168.1.100 ")).toBeUndefined();
|
||||
expect(validateDottedDecimalIPv4Input("0177.0.0.1")).toBe(
|
||||
"Invalid IPv4 address (e.g., 192.168.1.100)",
|
||||
);
|
||||
expect(validateDottedDecimalIPv4Input("[192.168.1.100]")).toBeUndefined();
|
||||
expect(validateDottedDecimalIPv4Input("127.1")).toBe(
|
||||
"Invalid IPv4 address (e.g., 192.168.1.100)",
|
||||
);
|
||||
expect(validateDottedDecimalIPv4Input("example.com")).toBe(
|
||||
"Invalid IPv4 address (e.g., 192.168.1.100)",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the backward-compatible alias wired to the same validation", () => {
|
||||
expect(validateIPv4AddressInput("192.168.1.100")).toBeUndefined();
|
||||
expect(validateIPv4AddressInput("bad-ip")).toBe("Invalid IPv4 address (e.g., 192.168.1.100)");
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { isCanonicalDottedDecimalIPv4 } from "./ip.js";
|
||||
|
||||
export function validateDottedDecimalIPv4Input(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return "IP address is required for custom bind mode";
|
||||
}
|
||||
if (isCanonicalDottedDecimalIPv4(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return "Invalid IPv4 address (e.g., 192.168.1.100)";
|
||||
}
|
||||
|
||||
/** @deprecated Use validateDottedDecimalIPv4Input. */
|
||||
export function validateIPv4AddressInput(value: string | undefined): string | undefined {
|
||||
return validateDottedDecimalIPv4Input(value);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isSensitiveUrlQueryParamName,
|
||||
isSensitiveUrlConfigPath,
|
||||
SENSITIVE_URL_HINT_TAG,
|
||||
hasSensitiveUrlHintTag,
|
||||
redactSensitiveUrl,
|
||||
redactSensitiveUrlLikeString,
|
||||
} from "./redact-sensitive-url.js";
|
||||
|
||||
describe("redactSensitiveUrl", () => {
|
||||
it("redacts userinfo and sensitive query params from valid URLs", () => {
|
||||
expect(redactSensitiveUrl("https://user:pass@example.com/mcp?token=secret&safe=value")).toBe(
|
||||
"https://***:***@example.com/mcp?token=***&safe=value",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats query param names case-insensitively", () => {
|
||||
expect(redactSensitiveUrl("https://example.com/mcp?Access_Token=secret")).toBe(
|
||||
"https://example.com/mcp?Access_Token=***",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps non-sensitive URLs unchanged", () => {
|
||||
expect(redactSensitiveUrl("https://example.com/mcp?safe=value")).toBe(
|
||||
"https://example.com/mcp?safe=value",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactSensitiveUrlLikeString", () => {
|
||||
it("redacts invalid URL-like strings", () => {
|
||||
expect(redactSensitiveUrlLikeString("//user:pass@example.com/mcp?client_secret=secret")).toBe(
|
||||
"//***:***@example.com/mcp?client_secret=***",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts every URL-like userinfo occurrence in arbitrary text", () => {
|
||||
expect(
|
||||
redactSensitiveUrlLikeString(
|
||||
"fatal https://a:b@github.com/one.git and https://c:d@github.com/two.git",
|
||||
),
|
||||
).toBe("fatal https://***:***@github.com/one.git and https://***:***@github.com/two.git");
|
||||
});
|
||||
|
||||
it("redacts protocol URLs that are too malformed to parse", () => {
|
||||
expect(
|
||||
redactSensitiveUrlLikeString(
|
||||
"wss://fallback-user:fallback-pass@[bad-host/socket?token=fallback-secret&keep=visible)",
|
||||
),
|
||||
).toBe("wss://***:***@[bad-host/socket?token=***&keep=visible)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSensitiveUrlQueryParamName", () => {
|
||||
it("matches the auth-oriented query params used by MCP SSE config redaction", () => {
|
||||
expect(isSensitiveUrlQueryParamName("token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("refresh_token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("access-token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("hook-token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("passwd")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("signature")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("safe")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sensitive URL config metadata", () => {
|
||||
it("recognizes config paths that may embed URL secrets", () => {
|
||||
expect(isSensitiveUrlConfigPath("models.providers.*.baseUrl")).toBe(true);
|
||||
expect(isSensitiveUrlConfigPath("mcp.servers.remote.url")).toBe(true);
|
||||
expect(isSensitiveUrlConfigPath("gateway.remote.url")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes cdpUrl config paths as sensitive (browser CDP URLs can embed credentials)", () => {
|
||||
expect(isSensitiveUrlConfigPath("browser.cdpUrl")).toBe(true);
|
||||
expect(isSensitiveUrlConfigPath("browser.profiles.remote.cdpUrl")).toBe(true);
|
||||
expect(isSensitiveUrlConfigPath("browser.profiles.staging.cdpUrl")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses an explicit url-secret hint tag", () => {
|
||||
expect(SENSITIVE_URL_HINT_TAG).toBe("url-secret");
|
||||
expect(hasSensitiveUrlHintTag({ tags: [SENSITIVE_URL_HINT_TAG] })).toBe(true);
|
||||
expect(hasSensitiveUrlHintTag({ tags: ["security"] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { ConfigUiHint } from "../config-ui-hints-types.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.js";
|
||||
|
||||
export const SENSITIVE_URL_HINT_TAG = "url-secret";
|
||||
|
||||
const SENSITIVE_URL_QUERY_PARAM_NAMES = new Set([
|
||||
"token",
|
||||
"key",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"secret",
|
||||
"access_token",
|
||||
"auth_token",
|
||||
"password",
|
||||
"pass",
|
||||
"passwd",
|
||||
"auth",
|
||||
"client_secret",
|
||||
"hook_token",
|
||||
"refresh_token",
|
||||
"signature",
|
||||
]);
|
||||
|
||||
export function isSensitiveUrlQueryParamName(name: string): boolean {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(name).replaceAll("-", "_");
|
||||
return SENSITIVE_URL_QUERY_PARAM_NAMES.has(normalized);
|
||||
}
|
||||
|
||||
export function isSensitiveUrlConfigPath(path: string): boolean {
|
||||
if (path.endsWith(".baseUrl") || path.endsWith(".httpUrl")) {
|
||||
return true;
|
||||
}
|
||||
if (path.endsWith(".cdpUrl")) {
|
||||
return true;
|
||||
}
|
||||
if (path.endsWith(".request.proxy.url")) {
|
||||
return true;
|
||||
}
|
||||
return /^mcp\.servers\.(?:\*|[^.]+)\.url$/.test(path);
|
||||
}
|
||||
|
||||
export function hasSensitiveUrlHintTag(hint: Pick<ConfigUiHint, "tags"> | undefined): boolean {
|
||||
return hint?.tags?.includes(SENSITIVE_URL_HINT_TAG) === true;
|
||||
}
|
||||
|
||||
export function redactSensitiveUrl(value: string): string {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
let mutated = false;
|
||||
if (parsed.username || parsed.password) {
|
||||
parsed.username = parsed.username ? "***" : "";
|
||||
parsed.password = parsed.password ? "***" : "";
|
||||
mutated = true;
|
||||
}
|
||||
for (const key of Array.from(parsed.searchParams.keys())) {
|
||||
if (isSensitiveUrlQueryParamName(key)) {
|
||||
parsed.searchParams.set(key, "***");
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
return mutated ? parsed.toString() : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function redactSensitiveUrlLikeString(value: string): string {
|
||||
const redactedUrl = redactSensitiveUrl(value);
|
||||
if (redactedUrl !== value) {
|
||||
return redactedUrl;
|
||||
}
|
||||
return value
|
||||
.replace(/\/\/([^@/?#\s]+)@/g, "//***:***@")
|
||||
.replace(/([?&])([^=&]+)=([^&]*)/g, (match, prefix: string, key: string) =>
|
||||
isSensitiveUrlQueryParamName(key) ? `${prefix}${key}=***` : match,
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export function stripUrlUserInfo(value: string): string {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (!parsed.username && !parsed.password) {
|
||||
return value;
|
||||
}
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user