| 1 | export const normalizeHostname = (host: string): string => |
| 2 | host |
| 3 | .toLowerCase() |
| 4 | .replace(/^\[|\]$/g, "") |
| 5 | .replace(/\.+$/u, ""); |
| 6 | |
| 7 | const parseIpv4Address = (host: string): readonly number[] | null => { |
| 8 | const parts = normalizeHostname(host).split(".").map(Number); |
| 9 | return parts.length === 4 && |
| 10 | parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) |
| 11 | ? parts |
| 12 | : null; |
| 13 | }; |
| 14 | |
| 15 | const parseIpv4MappedIpv6Address = (host: string): readonly number[] | null => { |
| 16 | const normalized = normalizeHostname(host); |
| 17 | if (!normalized.startsWith("::ffff:")) return null; |
| 18 | const suffix = normalized.slice("::ffff:".length); |
| 19 | const dotted = parseIpv4Address(suffix); |
| 20 | if (dotted) return dotted; |
| 21 | const hextets = suffix.split(":"); |
| 22 | if (hextets.length !== 2 || hextets.some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null; |
| 23 | const high = Number.parseInt(hextets[0]!, 16); |
| 24 | const low = Number.parseInt(hextets[1]!, 16); |
| 25 | return [high >>> 8, high & 0xff, low >>> 8, low & 0xff]; |
| 26 | }; |
| 27 | |
| 28 | const parseIpv6Address = (host: string): readonly number[] | null => { |
| 29 | const normalized = normalizeHostname(host); |
| 30 | if (!normalized.includes(":")) return null; |
| 31 | const halves = normalized.split("::"); |
| 32 | if (halves.length > 2) return null; |
| 33 | const head = halves[0] ? halves[0].split(":") : []; |
| 34 | const tail = halves[1] ? halves[1].split(":") : []; |
| 35 | if ([...head, ...tail].some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null; |
| 36 | const missing = 8 - head.length - tail.length; |
| 37 | if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) return null; |
| 38 | return [...head, ...Array.from({ length: missing }, () => "0"), ...tail].map((part) => |
| 39 | Number.parseInt(part, 16), |
| 40 | ); |
| 41 | }; |
| 42 | |
| 43 | const ipv6PrefixMatches = ( |
| 44 | address: readonly number[], |
| 45 | prefix: readonly number[], |
| 46 | prefixLength: number, |
| 47 | ): boolean => { |
| 48 | const fullHextets = Math.floor(prefixLength / 16); |
| 49 | if (address.slice(0, fullHextets).some((part, index) => part !== prefix[index])) return false; |
| 50 | const remainingBits = prefixLength % 16; |
| 51 | if (remainingBits === 0) return true; |
| 52 | const mask = (0xffff << (16 - remainingBits)) & 0xffff; |
| 53 | return (address[fullHextets]! & mask) === (prefix[fullHextets]! & mask); |
| 54 | }; |
| 55 | |
| 56 | const isPrivateIpv4Address = (parts: readonly number[]): boolean => |
| 57 | parts[0] === 0 || |
| 58 | parts[0] === 10 || |
| 59 | parts[0] === 127 || |
| 60 | (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || |
| 61 | (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || |
| 62 | (parts[0] === 192 && parts[1] === 168) || |
| 63 | (parts[0] === 169 && parts[1] === 254) || |
| 64 | (parts[0] === 198 && parts[1]! >= 18 && parts[1]! <= 19); |
| 65 | |
| 66 | const isSpecialPurposeIpv4Address = (parts: readonly number[]): boolean => |
| 67 | isPrivateIpv4Address(parts) || |
| 68 | parts[0]! >= 224 || |
| 69 | // Deliberately suppress the whole protocol-assignment block. IANA marks |
| 70 | // .9 and .10 globally reachable, but privacy-safe false negatives are |
| 71 | // preferable to disclosing another special-purpose address by mistake. |
| 72 | (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) || |
| 73 | (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) || |
| 74 | (parts[0] === 192 && parts[1] === 88 && parts[2] === 99) || |
| 75 | (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) || |
| 76 | (parts[0] === 203 && parts[1] === 0 && parts[2] === 113); |
| 77 | |
| 78 | export const isLocalLoopbackHost = (host: string): boolean => { |
| 79 | const normalized = normalizeHostname(host); |
| 80 | if (normalized === "localhost" || normalized === "::1") return true; |
| 81 | return parseIpv4Address(normalized)?.[0] === 127; |
| 82 | }; |
| 83 | |
| 84 | /** |
| 85 | * A name or address only Tailscale uses: a MagicDNS name or its IPv6 range |
| 86 | * fd7a:115c:a1e0::/48. Its IPv4 addresses come from the shared 100.64.0.0/10 |
| 87 | * range, so they prove nothing on their own. |
| 88 | */ |
| 89 | export const isTailnetHost = (host: string): boolean => { |
| 90 | const normalized = normalizeHostname(host); |
| 91 | if (normalized.endsWith(".ts.net")) return true; |
| 92 | const ipv6 = parseIpv6Address(normalized); |
| 93 | return ipv6 !== null && ipv6PrefixMatches(ipv6, [0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 0], 48); |
| 94 | }; |
| 95 | |
| 96 | /** |
| 97 | * An IPv4 address in 100.64.0.0/10. Tailscale, Cloudflare WARP and Mesh, other |
| 98 | * VPNs, and carrier-grade NAT all assign from this range. |
| 99 | */ |
| 100 | export const isSharedAddressSpaceHost = (host: string): boolean => { |
| 101 | const parts = parseIpv4Address(host); |
| 102 | return parts !== null && parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127; |
| 103 | }; |
| 104 | |
| 105 | export const isPrivateNetworkHost = (host: string): boolean => { |
| 106 | const normalized = normalizeHostname(host); |
| 107 | if ( |
| 108 | normalized === "::" || |
| 109 | isLocalLoopbackHost(normalized) || |
| 110 | normalized.endsWith(".localhost") || |
| 111 | normalized.endsWith(".local") || |
| 112 | normalized === "home.arpa" || |
| 113 | normalized.endsWith(".home.arpa") || |
| 114 | (!normalized.includes(".") && !normalized.includes(":")) |
| 115 | ) { |
| 116 | return true; |
| 117 | } |
| 118 | if (normalized.endsWith(".ts.net")) return true; |
| 119 | const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); |
| 120 | if (parts) return isPrivateIpv4Address(parts); |
| 121 | const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; |
| 122 | if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; |
| 123 | const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); |
| 124 | return ( |
| 125 | Number.isInteger(firstIpv6Hextet) && |
| 126 | ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80) |
| 127 | ); |
| 128 | }; |
| 129 | |
| 130 | // Reserved and special-use names, plus the private TLDs RFC 6762 Appendix G |
| 131 | // records as common on internal networks. |
| 132 | const PRIVATE_FAVICON_TLDS = [ |
| 133 | ".alt", |
| 134 | ".corp", |
| 135 | ".example", |
| 136 | ".home", |
| 137 | ".internal", |
| 138 | ".intranet", |
| 139 | ".invalid", |
| 140 | ".lan", |
| 141 | ".onion", |
| 142 | ".private", |
| 143 | ".test", |
| 144 | ]; |
| 145 | const INTERNAL_HOST_LABELS: ReadonlySet<string> = new Set(["corp", "internal", "intranet"]); |
| 146 | |
| 147 | /** Whether a hostname is eligible to be disclosed to a public favicon provider. */ |
| 148 | export const isPublicFaviconHost = (host: string): boolean => { |
| 149 | // A single trailing dot is a valid absolute DNS name. Repeated trailing |
| 150 | // dots are malformed and can conceal legacy numeric forms such as 127.1. |
| 151 | if (host.endsWith("..")) return false; |
| 152 | const normalized = normalizeHostname(host); |
| 153 | if (isPrivateNetworkHost(normalized)) return false; |
| 154 | if ( |
| 155 | PRIVATE_FAVICON_TLDS.some( |
| 156 | (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix), |
| 157 | ) |
| 158 | ) { |
| 159 | return false; |
| 160 | } |
| 161 | // Company networks often nest internal services under a public domain |
| 162 | // (`grafana.internal.acme.com`); never send those names to a third party. |
| 163 | if (normalized.split(".").some((label) => INTERNAL_HOST_LABELS.has(label))) return false; |
| 164 | const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); |
| 165 | if (ipv4) return !isSpecialPurposeIpv4Address(ipv4); |
| 166 | if (!normalized.includes(":")) return true; |
| 167 | const ipv6 = parseIpv6Address(normalized); |
| 168 | if (!ipv6) return false; |
| 169 | if (ipv6PrefixMatches(ipv6, [0x0064, 0xff9b, 0, 0, 0, 0, 0, 0], 96)) { |
| 170 | const embeddedIpv4 = [ipv6[6]! >>> 8, ipv6[6]! & 0xff, ipv6[7]! >>> 8, ipv6[7]! & 0xff]; |
| 171 | return !isSpecialPurposeIpv4Address(embeddedIpv4); |
| 172 | } |
| 173 | const first = ipv6[0]!; |
| 174 | if ((first & 0xe000) !== 0x2000) return false; |
| 175 | if (ipv6PrefixMatches(ipv6, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) { |
| 176 | const publicProtocolAssignment = |
| 177 | (ipv6[1] === 1 && |
| 178 | ipv6.slice(2, 7).every((part) => part === 0) && |
| 179 | [1, 2, 3].includes(ipv6[7]!)) || |
| 180 | ipv6PrefixMatches(ipv6, [0x2001, 3, 0, 0, 0, 0, 0, 0], 32) || |
| 181 | ipv6PrefixMatches(ipv6, [0x2001, 4, 0x0112, 0, 0, 0, 0, 0], 48) || |
| 182 | ipv6PrefixMatches(ipv6, [0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28) || |
| 183 | ipv6PrefixMatches(ipv6, [0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28); |
| 184 | return publicProtocolAssignment; |
| 185 | } |
| 186 | if (ipv6PrefixMatches(ipv6, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false; |
| 187 | if (ipv6PrefixMatches(ipv6, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false; |
| 188 | if (first === 0x3fff && (ipv6[1]! & 0xf000) === 0) return false; |
| 189 | return true; |
| 190 | }; |