| 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,6 +86,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 86 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | 86 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 87 | import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; | 87 | import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; |
| 88 | import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; | 88 | import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; |
| 89 | import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; | ||
| 89 | import { | 90 | import { |
| 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}`); | ||
| 600 | 602 | ||
| 601 | return ( | 603 | return ( |
| 602 | <NativeText | 604 | <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 | <Image | 615 | <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"; |
| 6 | import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; | 6 | import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 7 | import { isLocalLoopbackHost, isPrivateNetworkHost } from "@t3tools/shared/hostClassification"; | ||
| 7 | 8 | ||
| 8 | import { readPreparedConnection } from "~/state/session"; | 9 | import { readPreparedConnection } from "~/state/session"; |
| 9 | 10 | ||
| 10 | export const normalizeHostname = (host: string): string => | 11 | export { |
| 11 | host | 12 | normalizeHostname, |
| 12 | .toLowerCase() | 13 | isLocalLoopbackHost, |
| 13 | .replace(/^\[|\]$/g, "") | 14 | isPrivateNetworkHost, |
| 14 | .replace(/\.+$/u, ""); | 15 | isPublicFaviconHost, |
| 15 | 16 | } from "@t3tools/shared/hostClassification"; | |
| 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 | }; | ||
| 159 | 17 | ||
| 160 | const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { | 18 | const 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 | } |
| 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 | describe("ChatMarkdown streaming", () => { | 106 | describe("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"; |
| 31 | import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; | ||
| 31 | import { | 32 | import { |
| 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>(); |
| 1133 | 1134 | ||
| 1134 | const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { | 1135 | const 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 | <span | 1139 | <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-hidden | 1141 | 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 | <img | 1146 | <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 @@ |
| 1 | import { isPublicFaviconHost } from "~/browser/browserTargetResolver"; | 1 | export { 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 | */ | ||
| 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 | } |
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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 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 | describe("faviconUrlForPage", () => { | 49 | describe("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 @@ |
| 1 | import { isPublicFaviconHost } from "./hostClassification.ts"; | ||
| 2 | |||
| 1 | /** | 3 | /** |
| 2 | * Mirrors Codex's generic Browser Use fallback: ask the page origin for its | 4 | * 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. */ | ||
| 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 | }; | ||