| author | |
| committer | |
| log | b6f72681da394369274121c4d1216f5d64a7a4bb |
| tree | 6e886798bb445f863e2a157ae0f2270a6560b8d8 |
| parent | fce8508456e7f9218eebb02ecd764f9b3a47df6b |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 86 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 87 | 87 | import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; |
| 88 | 88 | import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; |
| 89 | import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; | |
| 89 | 90 | import { |
| 90 | 91 | hasNativeSelectableMarkdownText, |
| 91 | 92 | SelectableMarkdownText, |
| ... | ... | @@ -596,7 +597,8 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { |
| 596 | 597 | readonly href: string; |
| 597 | 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}`); | |
| 600 | 602 | |
| 601 | 603 | return ( |
| 602 | 604 | <NativeText |
| ... | ... | @@ -607,15 +609,17 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { |
| 607 | 609 | textDecorationLine: "none", |
| 608 | 610 | }} |
| 609 | 611 | > |
| 610 | {!failed ? ( | |
| 612 | {faviconUrl !== null && | |
| 613 | failedHost !== props.host && | |
| 614 | !failedMarkdownFaviconHosts.has(props.host) ? ( | |
| 611 | 615 | <Image |
| 612 | 616 | source={{ |
| 613 | uri: `https://www.google.com/s2/favicons?domain=${encodeURIComponent(props.host)}&sz=32`, | |
| 617 | uri: faviconUrl, | |
| 614 | 618 | }} |
| 615 | 619 | style={[markdownLinkStyles.inlineIcon, markdownLinkStyles.favicon]} |
| 616 | 620 | onError={() => { |
| 617 | 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 | 4 | PreviewUrlResolution, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 7 | import { isLocalLoopbackHost, isPrivateNetworkHost } from "@t3tools/shared/hostClassification"; | |
| 7 | 8 | |
| 8 | 9 | import { readPreparedConnection } from "~/state/session"; |
| 9 | 10 | |
| 10 | export const normalizeHostname = (host: string): string => | |
| 11 | host | |
| 12 | .toLowerCase() | |
| 13 | .replace(/^\[|\]$/g, "") | |
| 14 | .replace(/\.+$/u, ""); | |
| 15 | ||
| 16 | const 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 | ||
| 24 | const 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 | ||
| 37 | const 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 | ||
| 52 | const 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 | ||
| 65 | const 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 | ||
| 75 | const 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 | ||
| 87 | export 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 | ||
| 93 | export 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. */ | |
| 119 | export 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 | }; | |
| 11 | export { | |
| 12 | normalizeHostname, | |
| 13 | isLocalLoopbackHost, | |
| 14 | isPrivateNetworkHost, | |
| 15 | isPublicFaviconHost, | |
| 16 | } from "@t3tools/shared/hostClassification"; | |
| 159 | 17 | |
| 160 | 18 | const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { |
| 161 | 19 | const connection = readPreparedConnection(environmentId); |
apps/web/src/components/ChatMarkdown.test.tsx+31| ... | ... | @@ -72,6 +72,37 @@ function codeButton(renderer: ReactTestRenderer, label: string) { |
| 72 | 72 | return button.props as ComponentProps<typeof Button>; |
| 73 | 73 | } |
| 74 | 74 | |
| 75 | describe("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 | ||
| 75 | 106 | describe("ChatMarkdown streaming", () => { |
| 76 | 107 | it("preserves code controls and details without highlighting an unchanged fence again", async () => { |
| 77 | 108 | const highlighter = await getSyntaxHighlighterPromise("text"); |
apps/web/src/components/ChatMarkdown.tsx+4-2| ... | ... | @@ -28,6 +28,7 @@ import type { |
| 28 | 28 | ServerProviderSkill, |
| 29 | 29 | ThreadLinkedPullRequest, |
| 30 | 30 | } from "@t3tools/contracts"; |
| 31 | import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; | |
| 31 | 32 | import { |
| 32 | 33 | isAtomCommandInterrupted, |
| 33 | 34 | squashAtomCommandFailure, |
| ... | ... | @@ -1133,16 +1134,17 @@ const failedFaviconHosts = new Set<string>(); |
| 1133 | 1134 | |
| 1134 | 1135 | const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { |
| 1135 | 1136 | const [failedHost, setFailedHost] = useState<string | null>(null); |
| 1137 | const faviconUrl = faviconUrlForOrigin(`https://${host}`); | |
| 1136 | 1138 | return ( |
| 1137 | 1139 | <span |
| 1138 | 1140 | className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]" |
| 1139 | 1141 | aria-hidden |
| 1140 | 1142 | > |
| 1141 | {failedHost === host || failedFaviconHosts.has(host) ? ( | |
| 1143 | {faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( | |
| 1142 | 1144 | <GlobeIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} /> |
| 1143 | 1145 | ) : ( |
| 1144 | 1146 | <img |
| 1145 | src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=32`} | |
| 1147 | src={faviconUrl} | |
| 1146 | 1148 | alt="" |
| 1147 | 1149 | loading="lazy" |
| 1148 | 1150 | draggable={false} |
apps/web/src/lib/favicon.ts+1-23| ... | ... | @@ -1,23 +1 @@ |
| 1 | import { isPublicFaviconHost } from "~/browser/browserTargetResolver"; | |
| 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 | */ | |
| 10 | const FAVICON_PROVIDER = "https://www.google.com/s2/favicons"; | |
| 11 | ||
| 12 | export 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 | } | |
| 1 | export { faviconUrlForOrigin } from "@t3tools/shared/favicon"; |
packages/shared/package.json+4| ... | ... | @@ -266,6 +266,10 @@ |
| 266 | 266 | "./nodeSqliteClient": { |
| 267 | 267 | "types": "./src/nodeSqliteClient.ts", |
| 268 | 268 | "import": "./src/nodeSqliteClient.ts" |
| 269 | }, | |
| 270 | "./hostClassification": { | |
| 271 | "types": "./src/hostClassification.ts", | |
| 272 | "import": "./src/hostClassification.ts" | |
| 269 | 273 | } |
| 270 | 274 | }, |
| 271 | 275 | "scripts": { |
packages/shared/src/favicon.test.ts+45-1| ... | ... | @@ -1,6 +1,50 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | import { explicitFaviconUrl, faviconUrlForPage, toolActivityFaviconUrl } from "./favicon.ts"; | |
| 3 | import { | |
| 4 | explicitFaviconUrl, | |
| 5 | faviconUrlForOrigin, | |
| 6 | faviconUrlForPage, | |
| 7 | toolActivityFaviconUrl, | |
| 8 | } from "./favicon.ts"; | |
| 9 | ||
| 10 | describe("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 | }); | |
| 4 | 48 | |
| 5 | 49 | describe("faviconUrlForPage", () => { |
| 6 | 50 | it("uses the page origin instead of a third-party favicon service", () => { |
packages/shared/src/favicon.ts+16| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { isPublicFaviconHost } from "./hostClassification.ts"; | |
| 2 | ||
| 1 | 3 | /** |
| 2 | 4 | * Mirrors Codex's generic Browser Use fallback: ask the page origin for its |
| 3 | 5 | * conventional favicon and let the image element fall back to a browser glyph. |
| ... | ... | @@ -79,3 +81,17 @@ export function toolActivityFaviconUrl( |
| 79 | 81 | faviconUrlForPage(icon.pageUrl, size) |
| 80 | 82 | ); |
| 81 | 83 | } |
| 84 | ||
| 85 | /** Return a public favicon URL without disclosing private or reserved hosts. */ | |
| 86 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { isPublicFaviconHost } from "./hostClassification.ts"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 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 | export 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. */ | |
| 110 | export 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 | }; |