diff --git a/src/shared/net/ip-test-fixtures.ts b/src/shared/net/ip-test-fixtures.ts deleted file mode 100644 index d2fa9cd5436..00000000000 --- a/src/shared/net/ip-test-fixtures.ts +++ /dev/null @@ -1 +0,0 @@ -export const blockedIpv6MulticastLiterals = ["ff02::1", "ff05::1:3", "[ff02::1]"] as const; diff --git a/src/shared/net/ip.test.ts b/src/shared/net/ip.test.ts deleted file mode 100644 index 49deee39335..00000000000 --- a/src/shared/net/ip.test.ts +++ /dev/null @@ -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); - } - }); -}); diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts deleted file mode 100644 index 9426a691d2a..00000000000 --- a/src/shared/net/ip.ts +++ /dev/null @@ -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; -type Ipv6Range = ReturnType; -type BlockedIpv6Range = Ipv6Range | "discard"; - -const BLOCKED_IPV4_SPECIAL_USE_RANGES = new Set([ - "unspecified", - "broadcast", - "multicast", - "linkLocal", - "loopback", - "carrierGradeNat", - "private", - "reserved", -]); - -const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set([ - "loopback", - "private", - "linkLocal", - "carrierGradeNat", -]); - -const BLOCKED_IPV6_SPECIAL_USE_RANGES = new Set([ - "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; - } -} diff --git a/src/shared/net/ipv4.test.ts b/src/shared/net/ipv4.test.ts deleted file mode 100644 index 40dd024138f..00000000000 --- a/src/shared/net/ipv4.test.ts +++ /dev/null @@ -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)"); - }); -}); diff --git a/src/shared/net/ipv4.ts b/src/shared/net/ipv4.ts deleted file mode 100644 index 22638783dbc..00000000000 --- a/src/shared/net/ipv4.ts +++ /dev/null @@ -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); -} diff --git a/src/shared/net/redact-sensitive-url.test.ts b/src/shared/net/redact-sensitive-url.test.ts deleted file mode 100644 index a849475e208..00000000000 --- a/src/shared/net/redact-sensitive-url.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/shared/net/redact-sensitive-url.ts b/src/shared/net/redact-sensitive-url.ts deleted file mode 100644 index 13701541ec5..00000000000 --- a/src/shared/net/redact-sensitive-url.ts +++ /dev/null @@ -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 | 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, - ); -} diff --git a/src/shared/net/url-userinfo.ts b/src/shared/net/url-userinfo.ts deleted file mode 100644 index d9374a3d4c2..00000000000 --- a/src/shared/net/url-userinfo.ts +++ /dev/null @@ -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; - } -}