1export const normalizeHostname = (host: string): string =>
2 host
3 .toLowerCase()
4 .replace(/^\[|\]$/g, "")
5 .replace(/\.+$/u, "");
6
7const 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
15const 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
28const 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
43const 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
56const 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
66const 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
78export 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 */
89export 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 */
100export 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
105export 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.
132const 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];
145const 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. */
148export 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};