authorgravatar for 67972456+fe-franco@users.noreply.github.comFelipe Franco <67972456+fe-franco@users.noreply.github.com> 2026-09-04 21:36:28-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 17:36:28-07:00
logb6f72681da394369274121c4d1216f5d64a7a4bb
tree6e886798bb445f863e2a157ae0f2270a6560b8d8
parentfce8508456e7f9218eebb02ecd764f9b3a47df6b
signature Signed by PGP key B5690EEEBB952194

fix: stop favicon requests for private link hosts on web and mobile (#5838)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

10 files changed, 377 insertions(+), 179 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+8-4
...@@ -86,6 +86,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";...@@ -86,6 +86,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
86import { tryOpenExternalUrl } from "../../lib/openExternalUrl";86import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
87import { downloadAndShareAttachment } from "../../lib/attachmentDownload";87import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
88import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";88import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
89import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
89import {90import {
90 hasNativeSelectableMarkdownText,91 hasNativeSelectableMarkdownText,
91 SelectableMarkdownText,92 SelectableMarkdownText,
...@@ -596,7 +597,8 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {...@@ -596,7 +597,8 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
596 readonly href: string;597 readonly href: string;
597 readonly onPress: (href: string) => void;598 readonly onPress: (href: string) => void;
598}) {599}) {
599 const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host));600 const [failedHost, setFailedHost] = useState<string | null>(null);
601 const faviconUrl = faviconUrlForOrigin(`https://${props.host}`);
600602
601 return (603 return (
602 <NativeText604 <NativeText
...@@ -607,15 +609,17 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {...@@ -607,15 +609,17 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
607 textDecorationLine: "none",609 textDecorationLine: "none",
608 }}610 }}
609 >611 >
610 {!failed ? (612 {faviconUrl !== null &&
613 failedHost !== props.host &&
614 !failedMarkdownFaviconHosts.has(props.host) ? (
611 <Image615 <Image
612 source={{616 source={{
613 uri: `https://www.google.com/s2/favicons?domain=${encodeURIComponent(props.host)}&sz=32`,617 uri: faviconUrl,
614 }}618 }}
615 style={[markdownLinkStyles.inlineIcon, markdownLinkStyles.favicon]}619 style={[markdownLinkStyles.inlineIcon, markdownLinkStyles.favicon]}
616 onError={() => {620 onError={() => {
617 failedMarkdownFaviconHosts.add(props.host);621 failedMarkdownFaviconHosts.add(props.host);
618 setFailed(true);622 setFailedHost(props.host);
619 }}623 }}
620 />624 />
621 ) : (625 ) : (
apps/web/src/browser/browserTargetResolver.ts+7-149
...@@ -4,158 +4,16 @@ import type {...@@ -4,158 +4,16 @@ import type {
4 PreviewUrlResolution,4 PreviewUrlResolution,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";6import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";
7import { isLocalLoopbackHost, isPrivateNetworkHost } from "@t3tools/shared/hostClassification";
78
8import { readPreparedConnection } from "~/state/session";9import { readPreparedConnection } from "~/state/session";
910
10export const normalizeHostname = (host: string): string =>11export {
11 host12 normalizeHostname,
12 .toLowerCase()13 isLocalLoopbackHost,
13 .replace(/^\[|\]$/g, "")14 isPrivateNetworkHost,
14 .replace(/\.+$/u, "");15 isPublicFaviconHost,
1516} from "@t3tools/shared/hostClassification";
16const parseIpv4Address = (host: string): readonly number[] | null => {
17 const parts = normalizeHostname(host).split(".").map(Number);
18 return parts.length === 4 &&
19 parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)
20 ? parts
21 : null;
22};
23
24const parseIpv4MappedIpv6Address = (host: string): readonly number[] | null => {
25 const normalized = normalizeHostname(host);
26 if (!normalized.startsWith("::ffff:")) return null;
27 const suffix = normalized.slice("::ffff:".length);
28 const dotted = parseIpv4Address(suffix);
29 if (dotted) return dotted;
30 const hextets = suffix.split(":");
31 if (hextets.length !== 2 || hextets.some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null;
32 const high = Number.parseInt(hextets[0]!, 16);
33 const low = Number.parseInt(hextets[1]!, 16);
34 return [high >>> 8, high & 0xff, low >>> 8, low & 0xff];
35};
36
37const parseIpv6Address = (host: string): readonly number[] | null => {
38 const normalized = normalizeHostname(host);
39 if (!normalized.includes(":")) return null;
40 const halves = normalized.split("::");
41 if (halves.length > 2) return null;
42 const head = halves[0] ? halves[0].split(":") : [];
43 const tail = halves[1] ? halves[1].split(":") : [];
44 if ([...head, ...tail].some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null;
45 const missing = 8 - head.length - tail.length;
46 if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) return null;
47 return [...head, ...Array.from({ length: missing }, () => "0"), ...tail].map((part) =>
48 Number.parseInt(part, 16),
49 );
50};
51
52const ipv6PrefixMatches = (
53 address: readonly number[],
54 prefix: readonly number[],
55 prefixLength: number,
56): boolean => {
57 const fullHextets = Math.floor(prefixLength / 16);
58 if (address.slice(0, fullHextets).some((part, index) => part !== prefix[index])) return false;
59 const remainingBits = prefixLength % 16;
60 if (remainingBits === 0) return true;
61 const mask = (0xffff << (16 - remainingBits)) & 0xffff;
62 return (address[fullHextets]! & mask) === (prefix[fullHextets]! & mask);
63};
64
65const isPrivateIpv4Address = (parts: readonly number[]): boolean =>
66 parts[0] === 0 ||
67 parts[0] === 10 ||
68 parts[0] === 127 ||
69 (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) ||
70 (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) ||
71 (parts[0] === 192 && parts[1] === 168) ||
72 (parts[0] === 169 && parts[1] === 254) ||
73 (parts[0] === 198 && parts[1]! >= 18 && parts[1]! <= 19);
74
75const isSpecialPurposeIpv4Address = (parts: readonly number[]): boolean =>
76 isPrivateIpv4Address(parts) ||
77 parts[0]! >= 224 ||
78 // Deliberately suppress the whole protocol-assignment block. IANA marks
79 // .9 and .10 globally reachable, but privacy-safe false negatives are
80 // preferable to disclosing another special-purpose address by mistake.
81 (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) ||
82 (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) ||
83 (parts[0] === 192 && parts[1] === 88 && parts[2] === 99) ||
84 (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) ||
85 (parts[0] === 203 && parts[1] === 0 && parts[2] === 113);
86
87export const isLocalLoopbackHost = (host: string): boolean => {
88 const normalized = normalizeHostname(host);
89 if (normalized === "localhost" || normalized === "::1") return true;
90 return parseIpv4Address(normalized)?.[0] === 127;
91};
92
93export const isPrivateNetworkHost = (host: string): boolean => {
94 const normalized = normalizeHostname(host);
95 if (
96 normalized === "::" ||
97 isLocalLoopbackHost(normalized) ||
98 normalized.endsWith(".localhost") ||
99 normalized.endsWith(".local") ||
100 normalized === "home.arpa" ||
101 normalized.endsWith(".home.arpa") ||
102 (!normalized.includes(".") && !normalized.includes(":"))
103 ) {
104 return true;
105 }
106 if (normalized.endsWith(".ts.net")) return true;
107 const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
108 if (parts) return isPrivateIpv4Address(parts);
109 const firstIpv6Token = normalized.split(":", 1)[0] ?? "";
110 if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false;
111 const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16);
112 return (
113 Number.isInteger(firstIpv6Hextet) &&
114 ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80)
115 );
116};
117
118/** Whether a hostname is eligible to be disclosed to a public favicon provider. */
119export const isPublicFaviconHost = (host: string): boolean => {
120 // A single trailing dot is a valid absolute DNS name. Repeated trailing
121 // dots are malformed and can conceal legacy numeric forms such as 127.1.
122 if (host.endsWith("..")) return false;
123 const normalized = normalizeHostname(host);
124 if (isPrivateNetworkHost(normalized)) return false;
125 if (
126 [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some(
127 (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix),
128 )
129 ) {
130 return false;
131 }
132 const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
133 if (ipv4) return !isSpecialPurposeIpv4Address(ipv4);
134 if (!normalized.includes(":")) return true;
135 const ipv6 = parseIpv6Address(normalized);
136 if (!ipv6) return false;
137 if (ipv6PrefixMatches(ipv6, [0x0064, 0xff9b, 0, 0, 0, 0, 0, 0], 96)) {
138 const embeddedIpv4 = [ipv6[6]! >>> 8, ipv6[6]! & 0xff, ipv6[7]! >>> 8, ipv6[7]! & 0xff];
139 return !isSpecialPurposeIpv4Address(embeddedIpv4);
140 }
141 const first = ipv6[0]!;
142 if ((first & 0xe000) !== 0x2000) return false;
143 if (ipv6PrefixMatches(ipv6, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) {
144 const publicProtocolAssignment =
145 (ipv6[1] === 1 &&
146 ipv6.slice(2, 7).every((part) => part === 0) &&
147 [1, 2, 3].includes(ipv6[7]!)) ||
148 ipv6PrefixMatches(ipv6, [0x2001, 3, 0, 0, 0, 0, 0, 0], 32) ||
149 ipv6PrefixMatches(ipv6, [0x2001, 4, 0x0112, 0, 0, 0, 0, 0], 48) ||
150 ipv6PrefixMatches(ipv6, [0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28) ||
151 ipv6PrefixMatches(ipv6, [0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28);
152 return publicProtocolAssignment;
153 }
154 if (ipv6PrefixMatches(ipv6, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false;
155 if (ipv6PrefixMatches(ipv6, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false;
156 if (first === 0x3fff && (ipv6[1]! & 0xf000) === 0) return false;
157 return true;
158};
15917
160const readEnvironmentUrl = (environmentId: EnvironmentId): URL => {18const readEnvironmentUrl = (environmentId: EnvironmentId): URL => {
161 const connection = readPreparedConnection(environmentId);19 const connection = readPreparedConnection(environmentId);
apps/web/src/components/ChatMarkdown.test.tsx+31
...@@ -72,6 +72,37 @@ function codeButton(renderer: ReactTestRenderer, label: string) {...@@ -72,6 +72,37 @@ function codeButton(renderer: ReactTestRenderer, label: string) {
72 return button.props as ComponentProps<typeof Button>;72 return button.props as ComponentProps<typeof Button>;
73}73}
7474
75describe("ChatMarkdown favicon privacy", () => {
76 it("suppresses private link images while preserving public links across updates", async () => {
77 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
78 let renderer: ReactTestRenderer | undefined;
79 const markdown = (url: string) => <ChatMarkdown cwd="/tmp/project" text={`[Link](${url})`} />;
80 try {
81 await act(async () => {
82 renderer = create(markdown("https://github.com"));
83 });
84 expect(renderer!.root.findAllByType("img").map((image) => image.props.src)).toEqual([
85 "https://www.google.com/s2/favicons?domain=github.com&sz=32",
86 ]);
87 for (const url of ["http://192.168.1.10:8080", "http://localhost:3000", "http://home.arpa"]) {
88 await act(async () => {
89 renderer!.update(markdown(url));
90 });
91 expect(renderer!.root.findAllByType("img")).toHaveLength(0);
92 }
93 await act(async () => {
94 renderer!.update(markdown("https://github.com"));
95 });
96 expect(renderer!.root.findAllByType("img")).toHaveLength(1);
97 } finally {
98 await act(async () => {
99 renderer?.unmount();
100 });
101 vi.unstubAllGlobals();
102 }
103 });
104});
105
75describe("ChatMarkdown streaming", () => {106describe("ChatMarkdown streaming", () => {
76 it("preserves code controls and details without highlighting an unchanged fence again", async () => {107 it("preserves code controls and details without highlighting an unchanged fence again", async () => {
77 const highlighter = await getSyntaxHighlighterPromise("text");108 const highlighter = await getSyntaxHighlighterPromise("text");
apps/web/src/components/ChatMarkdown.tsx+4-2
...@@ -28,6 +28,7 @@ import type {...@@ -28,6 +28,7 @@ import type {
28 ServerProviderSkill,28 ServerProviderSkill,
29 ThreadLinkedPullRequest,29 ThreadLinkedPullRequest,
30} from "@t3tools/contracts";30} from "@t3tools/contracts";
31import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
31import {32import {
32 isAtomCommandInterrupted,33 isAtomCommandInterrupted,
33 squashAtomCommandFailure,34 squashAtomCommandFailure,
...@@ -1133,16 +1134,17 @@ const failedFaviconHosts = new Set<string>();...@@ -1133,16 +1134,17 @@ const failedFaviconHosts = new Set<string>();
11331134
1134const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) {1135const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) {
1135 const [failedHost, setFailedHost] = useState<string | null>(null);1136 const [failedHost, setFailedHost] = useState<string | null>(null);
1137 const faviconUrl = faviconUrlForOrigin(`https://${host}`);
1136 return (1138 return (
1137 <span1139 <span
1138 className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]"1140 className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]"
1139 aria-hidden1141 aria-hidden
1140 >1142 >
1141 {failedHost === host || failedFaviconHosts.has(host) ? (1143 {faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? (
1142 <GlobeIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />1144 <GlobeIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />
1143 ) : (1145 ) : (
1144 <img1146 <img
1145 src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=32`}1147 src={faviconUrl}
1146 alt=""1148 alt=""
1147 loading="lazy"1149 loading="lazy"
1148 draggable={false}1150 draggable={false}
apps/web/src/lib/favicon.ts+1-23
...@@ -1,23 +1 @@...@@ -1,23 +1 @@
1import { isPublicFaviconHost } from "~/browser/browserTargetResolver";1export { faviconUrlForOrigin } from "@t3tools/shared/favicon";
2
3/**
4 * Favicon helpers for the preview tab strip.
5 *
6 * Uses Google's s2 favicon endpoint (same approach as ami's tab strip).
7 * Callers should always render a `<Globe />` fallback when the returned URL
8 * fails to load via an `onError` handler.
9 */
10const FAVICON_PROVIDER = "https://www.google.com/s2/favicons";
11
12export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null {
13 if (!rawUrl) return null;
14 try {
15 const url = new URL(rawUrl);
16 if (!url.host) return null;
17 if (url.protocol !== "http:" && url.protocol !== "https:") return null;
18 if (!isPublicFaviconHost(url.hostname)) return null;
19 return `${FAVICON_PROVIDER}?domain=${encodeURIComponent(url.host)}&sz=${size}`;
20 } catch {
21 return null;
22 }
23}
packages/shared/package.json+4
...@@ -266,6 +266,10 @@...@@ -266,6 +266,10 @@
266 "./nodeSqliteClient": {266 "./nodeSqliteClient": {
267 "types": "./src/nodeSqliteClient.ts",267 "types": "./src/nodeSqliteClient.ts",
268 "import": "./src/nodeSqliteClient.ts"268 "import": "./src/nodeSqliteClient.ts"
269 },
270 "./hostClassification": {
271 "types": "./src/hostClassification.ts",
272 "import": "./src/hostClassification.ts"
269 }273 }
270 },274 },
271 "scripts": {275 "scripts": {
packages/shared/src/favicon.test.ts+45-1
...@@ -1,6 +1,50 @@...@@ -1,6 +1,50 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
22
3import { explicitFaviconUrl, faviconUrlForPage, toolActivityFaviconUrl } from "./favicon.ts";3import {
4 explicitFaviconUrl,
5 faviconUrlForOrigin,
6 faviconUrlForPage,
7 toolActivityFaviconUrl,
8} from "./favicon.ts";
9
10describe("faviconUrlForOrigin", () => {
11 it.each([
12 "http://192.168.1.10:8080",
13 "http://localhost:3000",
14 "http://home.arpa",
15 "https://printer.local.",
16 "https://api.internal",
17 "https://box.tailnet.ts.net",
18 "http://127.1",
19 "http://0x7f000001",
20 "http://[::]",
21 "http://[::1]",
22 "http://[::ffff:192.168.1.10]",
23 "http://[fd00::1]",
24 "http://[fe80::1]",
25 "http://100.64.0.1",
26 "http://198.51.100.1",
27 "http://[2001:db8::1]",
28 "http://service.test",
29 "http://private.onion",
30 "http://127.1..",
31 ])("does not disclose %s to the favicon provider", (origin) => {
32 expect(faviconUrlForOrigin(origin)).toBeNull();
33 });
34
35 it("keeps the public origin, port and requested size", () => {
36 expect(faviconUrlForOrigin("https://github.com:8443/pingdotgg/t3code?private=query", 64)).toBe(
37 "https://www.google.com/s2/favicons?domain=github.com%3A8443&sz=64",
38 );
39 });
40
41 it.each([null, undefined, "", "invalid URL", "file:///tmp/private", "data:text/plain,private"])(
42 "rejects an invalid or unsupported origin %s",
43 (origin) => {
44 expect(faviconUrlForOrigin(origin)).toBeNull();
45 },
46 );
47});
448
5describe("faviconUrlForPage", () => {49describe("faviconUrlForPage", () => {
6 it("uses the page origin instead of a third-party favicon service", () => {50 it("uses the page origin instead of a third-party favicon service", () => {
packages/shared/src/favicon.ts+16
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { isPublicFaviconHost } from "./hostClassification.ts";
2
1/**3/**
2 * Mirrors Codex's generic Browser Use fallback: ask the page origin for its4 * Mirrors Codex's generic Browser Use fallback: ask the page origin for its
3 * conventional favicon and let the image element fall back to a browser glyph.5 * conventional favicon and let the image element fall back to a browser glyph.
...@@ -79,3 +81,17 @@ export function toolActivityFaviconUrl(...@@ -79,3 +81,17 @@ export function toolActivityFaviconUrl(
79 faviconUrlForPage(icon.pageUrl, size)81 faviconUrlForPage(icon.pageUrl, size)
80 );82 );
81}83}
84
85/** Return a public favicon URL without disclosing private or reserved hosts. */
86export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null {
87 if (!rawUrl) return null;
88 try {
89 const url = new URL(rawUrl);
90 if (!url.host) return null;
91 if (url.protocol !== "http:" && url.protocol !== "https:") return null;
92 if (!isPublicFaviconHost(url.hostname)) return null;
93 return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.host)}&sz=${size}`;
94 } catch {
95 return null;
96 }
97}
packages/shared/src/hostClassification.test.ts created+112
...@@ -0,0 +1,112 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { isPublicFaviconHost } from "./hostClassification.ts";
4
5describe("isPublicFaviconHost", () => {
6 it("treats public hosts as public", () => {
7 for (const host of [
8 "github.com",
9 "www.google.com",
10 "t3.chat",
11 "sub.domain.example.co.uk",
12 "8.8.8.8",
13 "1.1.1.1",
14 "100.200.1.1",
15 "172.32.0.1",
16 "192.167.1.1",
17 "11.0.0.1",
18 ]) {
19 expect(isPublicFaviconHost(host), host).toBe(true);
20 }
21 });
22
23 it("detects private IPv4 ranges", () => {
24 for (const host of [
25 "0.0.0.0",
26 "10.0.0.1",
27 "10.255.255.255",
28 "127.0.0.1",
29 "192.168.1.10",
30 "172.16.0.1",
31 "172.31.255.255",
32 "169.254.1.1",
33 ]) {
34 expect(isPublicFaviconHost(host), host).toBe(false);
35 }
36 });
37
38 it("detects the Tailscale 100.64.0.0/10 range", () => {
39 for (const host of ["100.64.0.1", "100.100.100.100", "100.126.17.15", "100.127.255.255"]) {
40 expect(isPublicFaviconHost(host), host).toBe(false);
41 }
42 expect(isPublicFaviconHost("100.63.255.255")).toBe(true);
43 expect(isPublicFaviconHost("100.128.0.1")).toBe(true);
44 });
45
46 it("detects private host names and suffixes", () => {
47 for (const host of [
48 "localhost",
49 "air",
50 "printer.local",
51 "api.internal",
52 "router.home.arpa",
53 "home.arpa",
54 "box.tailnet.ts.net",
55 "AIR.TAILE8BEA7.TS.NET",
56 ]) {
57 expect(isPublicFaviconHost(host), host).toBe(false);
58 }
59 });
60
61 it("detects private IPv6 addresses", () => {
62 for (const host of ["::1", "[::1]", "fd00::1", "fc00::1", "fe80::1", "FD12:3456::1"]) {
63 expect(isPublicFaviconHost(host), host).toBe(false);
64 }
65 expect(isPublicFaviconHost("2606:4700:4700::1111")).toBe(true);
66 });
67
68 it("detects IPv4-mapped IPv6 addresses in both spellings", () => {
69 for (const host of [
70 "::ffff:192.168.1.10",
71 "::ffff:10.0.0.1",
72 "::ffff:100.126.17.15",
73 "[::ffff:192.168.1.10]",
74 // c0a8:010a is 192.168.1.10, 0a00:0001 is 10.0.0.1.
75 "::ffff:c0a8:010a",
76 "::ffff:a00:1",
77 ]) {
78 expect(isPublicFaviconHost(host), host).toBe(false);
79 }
80 expect(isPublicFaviconHost("::ffff:8.8.8.8")).toBe(true);
81 expect(isPublicFaviconHost("::ffff:808:808")).toBe(true);
82 });
83
84 it("ignores a trailing DNS root label", () => {
85 for (const host of [
86 "localhost.",
87 "printer.local.",
88 "api.internal.",
89 "box.tailnet.ts.net.",
90 "air.",
91 ]) {
92 expect(isPublicFaviconHost(host), host).toBe(false);
93 }
94 expect(isPublicFaviconHost("github.com.")).toBe(true);
95 });
96
97 it("detects names under .localhost", () => {
98 for (const host of ["app.localhost", "api.app.localhost", "APP.LOCALHOST"]) {
99 expect(isPublicFaviconHost(host), host).toBe(false);
100 }
101 });
102
103 it("treats an empty host as private", () => {
104 expect(isPublicFaviconHost("")).toBe(false);
105 expect(isPublicFaviconHost(" ")).toBe(false);
106 });
107
108 it("rejects malformed IPv4 text as a public host", () => {
109 expect(isPublicFaviconHost("10.0.0.999")).toBe(true);
110 expect(isPublicFaviconHost("10.0.0")).toBe(true);
111 });
112});
packages/shared/src/hostClassification.ts created+149
...@@ -0,0 +1,149 @@
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
84export const isPrivateNetworkHost = (host: string): boolean => {
85 const normalized = normalizeHostname(host);
86 if (
87 normalized === "::" ||
88 isLocalLoopbackHost(normalized) ||
89 normalized.endsWith(".localhost") ||
90 normalized.endsWith(".local") ||
91 normalized === "home.arpa" ||
92 normalized.endsWith(".home.arpa") ||
93 (!normalized.includes(".") && !normalized.includes(":"))
94 ) {
95 return true;
96 }
97 if (normalized.endsWith(".ts.net")) return true;
98 const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
99 if (parts) return isPrivateIpv4Address(parts);
100 const firstIpv6Token = normalized.split(":", 1)[0] ?? "";
101 if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false;
102 const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16);
103 return (
104 Number.isInteger(firstIpv6Hextet) &&
105 ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80)
106 );
107};
108
109/** Whether a hostname is eligible to be disclosed to a public favicon provider. */
110export const isPublicFaviconHost = (host: string): boolean => {
111 // A single trailing dot is a valid absolute DNS name. Repeated trailing
112 // dots are malformed and can conceal legacy numeric forms such as 127.1.
113 if (host.endsWith("..")) return false;
114 const normalized = normalizeHostname(host);
115 if (isPrivateNetworkHost(normalized)) return false;
116 if (
117 [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some(
118 (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix),
119 )
120 ) {
121 return false;
122 }
123 const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
124 if (ipv4) return !isSpecialPurposeIpv4Address(ipv4);
125 if (!normalized.includes(":")) return true;
126 const ipv6 = parseIpv6Address(normalized);
127 if (!ipv6) return false;
128 if (ipv6PrefixMatches(ipv6, [0x0064, 0xff9b, 0, 0, 0, 0, 0, 0], 96)) {
129 const embeddedIpv4 = [ipv6[6]! >>> 8, ipv6[6]! & 0xff, ipv6[7]! >>> 8, ipv6[7]! & 0xff];
130 return !isSpecialPurposeIpv4Address(embeddedIpv4);
131 }
132 const first = ipv6[0]!;
133 if ((first & 0xe000) !== 0x2000) return false;
134 if (ipv6PrefixMatches(ipv6, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) {
135 const publicProtocolAssignment =
136 (ipv6[1] === 1 &&
137 ipv6.slice(2, 7).every((part) => part === 0) &&
138 [1, 2, 3].includes(ipv6[7]!)) ||
139 ipv6PrefixMatches(ipv6, [0x2001, 3, 0, 0, 0, 0, 0, 0], 32) ||
140 ipv6PrefixMatches(ipv6, [0x2001, 4, 0x0112, 0, 0, 0, 0, 0], 48) ||
141 ipv6PrefixMatches(ipv6, [0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28) ||
142 ipv6PrefixMatches(ipv6, [0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28);
143 return publicProtocolAssignment;
144 }
145 if (ipv6PrefixMatches(ipv6, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false;
146 if (ipv6PrefixMatches(ipv6, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false;
147 if (first === 0x3fff && (ipv6[1]! & 0xf000) === 0) return false;
148 return true;
149};