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 // Suppress the entire protocol-assignment block, including public .9/.10, to avoid disclosing special-purpose addresses.
70 (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) ||
71 (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) ||
72 (parts[0] === 192 && parts[1] === 88 && parts[2] === 99) ||
73 (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) ||
74 (parts[0] === 203 && parts[1] === 0 && parts[2] === 113);
75
76export const isLocalLoopbackHost = (host: string): boolean => {
77 const normalized = normalizeHostname(host);
78 if (normalized === "localhost" || normalized === "::1") return true;
79 return parseIpv4Address(normalized)?.[0] === 127;
80};
81
82/** MagicDNS or fd7a:115c:a1e0::/48 identifies Tailscale; shared 100.64.0.0/10 addresses alone do not. */
83export const isTailnetHost = (host: string): boolean => {
84 const normalized = normalizeHostname(host);
85 if (normalized.endsWith(".ts.net")) return true;
86 const ipv6 = parseIpv6Address(normalized);
87 return ipv6 !== null && ipv6PrefixMatches(ipv6, [0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 0], 48);
88};
89
90export const isSharedAddressSpaceHost = (host: string): boolean => {
91 const parts = parseIpv4Address(host);
92 return parts !== null && parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127;
93};
94
95export const isPrivateNetworkHost = (host: string): boolean => {
96 const normalized = normalizeHostname(host);
97 if (
98 normalized === "::" ||
99 isLocalLoopbackHost(normalized) ||
100 normalized.endsWith(".localhost") ||
101 normalized.endsWith(".local") ||
102 normalized === "home.arpa" ||
103 normalized.endsWith(".home.arpa") ||
104 (!normalized.includes(".") && !normalized.includes(":"))
105 ) {
106 return true;
107 }
108 if (normalized.endsWith(".ts.net")) return true;
109 const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
110 if (parts) return isPrivateIpv4Address(parts);
111 const firstIpv6Token = normalized.split(":", 1)[0] ?? "";
112 if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false;
113 const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16);
114 return (
115 Number.isInteger(firstIpv6Hextet) &&
116 ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80)
117 );
118};
119
120// Includes private TLDs from RFC 6762 Appendix G.
121const PRIVATE_FAVICON_TLDS = [
122 ".alt",
123 ".corp",
124 ".example",
125 ".home",
126 ".internal",
127 ".intranet",
128 ".invalid",
129 ".lan",
130 ".onion",
131 ".private",
132 ".test",
133];
134const INTERNAL_HOST_LABELS: ReadonlySet<string> = new Set(["corp", "internal", "intranet"]);
135
136export const isPublicFaviconHost = (host: string): boolean => {
137 // One trailing DNS dot is valid; repeated dots can conceal legacy numeric addresses.
138 if (host.endsWith("..")) return false;
139 const normalized = normalizeHostname(host);
140 if (isPrivateNetworkHost(normalized)) return false;
141 if (
142 PRIVATE_FAVICON_TLDS.some(
143 (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix),
144 )
145 ) {
146 return false;
147 }
148 // Public domains can contain internal service labels; keep them off third-party requests.
149 if (normalized.split(".").some((label) => INTERNAL_HOST_LABELS.has(label))) return false;
150 const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
151 if (ipv4) return !isSpecialPurposeIpv4Address(ipv4);
152 if (!normalized.includes(":")) return true;
153 const ipv6 = parseIpv6Address(normalized);
154 if (!ipv6) return false;
155 if (ipv6PrefixMatches(ipv6, [0x0064, 0xff9b, 0, 0, 0, 0, 0, 0], 96)) {
156 const embeddedIpv4 = [ipv6[6]! >>> 8, ipv6[6]! & 0xff, ipv6[7]! >>> 8, ipv6[7]! & 0xff];
157 return !isSpecialPurposeIpv4Address(embeddedIpv4);
158 }
159 const first = ipv6[0]!;
160 if ((first & 0xe000) !== 0x2000) return false;
161 if (ipv6PrefixMatches(ipv6, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) {
162 const publicProtocolAssignment =
163 (ipv6[1] === 1 &&
164 ipv6.slice(2, 7).every((part) => part === 0) &&
165 [1, 2, 3].includes(ipv6[7]!)) ||
166 ipv6PrefixMatches(ipv6, [0x2001, 3, 0, 0, 0, 0, 0, 0], 32) ||
167 ipv6PrefixMatches(ipv6, [0x2001, 4, 0x0112, 0, 0, 0, 0, 0], 48) ||
168 ipv6PrefixMatches(ipv6, [0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28) ||
169 ipv6PrefixMatches(ipv6, [0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28);
170 return publicProtocolAssignment;
171 }
172 if (ipv6PrefixMatches(ipv6, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false;
173 if (ipv6PrefixMatches(ipv6, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false;
174 if (first === 0x3fff && (ipv6[1]! & 0xf000) === 0) return false;
175 return true;
176};
177
178export const isWildcardHost = (host: string | undefined): boolean =>
179 host === "0.0.0.0" || host === "::" || host === "[::]";
180
181export const formatHostForUrl = (host: string): string =>
182 host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;