From cfe25ac22bee5ebccc4a2f562b97fa46c95239cd Mon Sep 17 00:00:00 2001 From: Felipe Franco Date: Sun, 9 Aug 2026 12:00:39 -0300 Subject: [PATCH 1/3] fix(mobile): stop favicon requests for private link hosts MarkdownExternalLink built a Google favicon URL from any external link host. resolveMarkdownLinkPresentation marks every http and https link as external, so a link to a dev server or a Tailscale host sent that private host name to Google. The request also always failed, because Google cannot resolve a private host. Add isPrivateLinkHost and start the component in the failed state for such a host, so it draws the existing fallback glyph and sends no request. Resolves #5835 Co-Authored-By: Claude Opus 5 (1M context) --- .../src/features/threads/ThreadFeed.tsx | 7 +- apps/mobile/src/lib/privateLinkHost.test.ts | 75 +++++++++++++++++++ apps/mobile/src/lib/privateLinkHost.ts | 60 +++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/lib/privateLinkHost.test.ts create mode 100644 apps/mobile/src/lib/privateLinkHost.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index db7fecf64ff..fa0705d17bf 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -49,6 +49,7 @@ import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reani import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { isPrivateLinkHost } from "../../lib/privateLinkHost"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -265,7 +266,11 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly host: string; readonly href: string; }) { - const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); + // A favicon service cannot resolve a private host. Skip the request so the + // host name stays inside the network. + const [failed, setFailed] = useState( + () => isPrivateLinkHost(props.host) || failedMarkdownFaviconHosts.has(props.host), + ); return ( { + it("treats public hosts as public", () => { + for (const host of [ + "github.com", + "www.google.com", + "t3.chat", + "sub.domain.example.co.uk", + "8.8.8.8", + "1.1.1.1", + "100.200.1.1", + "172.32.0.1", + "192.167.1.1", + "11.0.0.1", + ]) { + expect(isPrivateLinkHost(host), host).toBe(false); + } + }); + + it("detects private IPv4 ranges", () => { + for (const host of [ + "10.0.0.1", + "10.255.255.255", + "127.0.0.1", + "192.168.1.10", + "172.16.0.1", + "172.31.255.255", + "169.254.1.1", + ]) { + expect(isPrivateLinkHost(host), host).toBe(true); + } + }); + + it("detects the Tailscale 100.64.0.0/10 range", () => { + for (const host of ["100.64.0.1", "100.100.100.100", "100.126.17.15", "100.127.255.255"]) { + expect(isPrivateLinkHost(host), host).toBe(true); + } + expect(isPrivateLinkHost("100.63.255.255")).toBe(false); + expect(isPrivateLinkHost("100.128.0.1")).toBe(false); + }); + + it("detects private host names and suffixes", () => { + for (const host of [ + "localhost", + "air", + "printer.local", + "api.internal", + "router.home.arpa", + "box.tailnet.ts.net", + "AIR.TAILE8BEA7.TS.NET", + ]) { + expect(isPrivateLinkHost(host), host).toBe(true); + } + }); + + it("detects private IPv6 addresses", () => { + for (const host of ["::1", "[::1]", "fd00::1", "fc00::1", "fe80::1", "FD12:3456::1"]) { + expect(isPrivateLinkHost(host), host).toBe(true); + } + expect(isPrivateLinkHost("2606:4700:4700::1111")).toBe(false); + }); + + it("treats an empty host as private", () => { + expect(isPrivateLinkHost("")).toBe(true); + expect(isPrivateLinkHost(" ")).toBe(true); + }); + + it("rejects malformed IPv4 text as a public host", () => { + expect(isPrivateLinkHost("10.0.0.999")).toBe(false); + expect(isPrivateLinkHost("10.0.0")).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/privateLinkHost.ts b/apps/mobile/src/lib/privateLinkHost.ts new file mode 100644 index 00000000000..e4e3ebf3c46 --- /dev/null +++ b/apps/mobile/src/lib/privateLinkHost.ts @@ -0,0 +1,60 @@ +const PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".home.arpa", ".ts.net"]; + +function isPrivateIpv4(host: string): boolean { + const parts = host.split("."); + if (parts.length !== 4) { + return false; + } + const octets = parts.map((part) => (/^\d{1,3}$/.test(part) ? Number(part) : Number.NaN)); + if (octets.some((octet) => Number.isNaN(octet) || octet > 255)) { + return false; + } + const [first = Number.NaN, second = Number.NaN] = octets; + if (first === 10 || first === 127) { + return true; + } + if (first === 192 && second === 168) { + return true; + } + if (first === 172 && second >= 16 && second <= 31) { + return true; + } + if (first === 169 && second === 254) { + return true; + } + // Tailscale hands out 100.64.0.0/10. + return first === 100 && second >= 64 && second <= 127; +} + +function isPrivateIpv6(host: string): boolean { + const address = host.replace(/^\[/, "").replace(/\]$/, "").toLowerCase(); + if (address === "::1") { + return true; + } + // fc00::/7 covers unique local addresses. fe80::/10 covers link local. + return /^f[cd][0-9a-f]{0,2}:/.test(address) || /^fe[89ab][0-9a-f]?:/.test(address); +} + +/** + * True when a link host belongs to a private network. + * + * A favicon service cannot resolve such a host, so a request for it always + * fails. The request also tells that service the private host name. + */ +export function isPrivateLinkHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + if (normalized.length === 0) { + return true; + } + if (normalized === "localhost") { + return true; + } + if (PRIVATE_HOST_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) { + return true; + } + // A host with no dot and no colon cannot be a public domain. + if (!normalized.includes(".") && !normalized.includes(":")) { + return true; + } + return isPrivateIpv4(normalized) || isPrivateIpv6(normalized); +} From f763b8af0d3ad5d05c845b1d68b38d9f3303d96c Mon Sep 17 00:00:00 2001 From: Felipe Franco Date: Sun, 9 Aug 2026 12:21:03 -0300 Subject: [PATCH 2/3] fix(web): stop favicon requests for private link hosts The same defect exists in two more places than the mobile thread feed: - apps/web ChatMarkdown.tsx MarkdownLinkFavicon, the web chat renderer - apps/web favicon.ts faviconUrlForOrigin, the preview tab strip The preview tab strip matters most. A preview URL usually points at a dev server or a tailnet host, so it leaked a private host on the normal path. Move isPrivateHost into packages/shared, which every app already depends on, and guard all three call sites. The shared module keeps no runtime dependency, so web and React Native can both import it. Net.ts was not an option because it imports node:net. faviconUrlForOrigin checks url.hostname, not url.host, because host keeps the port and localhost:5173 would slip past the check. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/features/threads/ThreadFeed.tsx | 4 +- apps/web/src/components/ChatMarkdown.tsx | 5 +- apps/web/src/lib/favicon.test.ts | 47 +++++++++++++++++++ apps/web/src/lib/favicon.ts | 5 ++ packages/shared/package.json | 4 ++ .../shared/src/privateHost.test.ts | 28 +++++------ .../shared/src/privateHost.ts | 18 +++++-- 7 files changed, 90 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/lib/favicon.test.ts rename apps/mobile/src/lib/privateLinkHost.test.ts => packages/shared/src/privateHost.test.ts (63%) rename apps/mobile/src/lib/privateLinkHost.ts => packages/shared/src/privateHost.ts (74%) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index fa0705d17bf..d6c16e00270 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -49,7 +49,7 @@ import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reani import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; -import { isPrivateLinkHost } from "../../lib/privateLinkHost"; +import { isPrivateHost } from "@t3tools/shared/privateHost"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -269,7 +269,7 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { // A favicon service cannot resolve a private host. Skip the request so the // host name stays inside the network. const [failed, setFailed] = useState( - () => isPrivateLinkHost(props.host) || failedMarkdownFaviconHosts.has(props.host), + () => isPrivateHost(props.host) || failedMarkdownFaviconHosts.has(props.host), ); return ( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b5d33facc96..d3e9531e0a6 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -9,6 +9,7 @@ import { WrapTextIcon, } from "lucide-react"; import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; +import { isPrivateHost } from "@t3tools/shared/privateHost"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -851,7 +852,9 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: const [failedHost, setFailedHost] = useState(null); return ( - {failedHost === host || failedFaviconHosts.has(host) ? ( + {/* A private host never reaches the favicon provider. It cannot resolve + one, and the request would disclose the host name. */} + {isPrivateHost(host) || failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( { + it("builds a provider URL for a public origin", () => { + expect(faviconUrlForOrigin("https://github.com/pingdotgg/t3code")).toBe( + "https://www.google.com/s2/favicons?domain=github.com&sz=32", + ); + }); + + it("keeps the port in the domain parameter", () => { + expect(faviconUrlForOrigin("https://example.com:8443/app")).toBe( + "https://www.google.com/s2/favicons?domain=example.com%3A8443&sz=32", + ); + }); + + it("returns null for a private host, so the host name stays inside the network", () => { + for (const url of [ + "http://localhost:5173", + "http://127.0.0.1:3000", + "http://192.168.1.10:8080", + "http://10.0.0.5", + "http://172.16.4.4", + "http://100.126.17.15:8177", + "https://box.tailnet.ts.net", + "http://printer.local", + "http://air", + ]) { + expect(faviconUrlForOrigin(url), url).toBeNull(); + } + }); + + it("returns null for a non-http protocol, an empty input, or invalid text", () => { + expect(faviconUrlForOrigin("file:///tmp/x.html")).toBeNull(); + expect(faviconUrlForOrigin(null)).toBeNull(); + expect(faviconUrlForOrigin(undefined)).toBeNull(); + expect(faviconUrlForOrigin("")).toBeNull(); + expect(faviconUrlForOrigin("not a url")).toBeNull(); + }); + + it("honors a custom size", () => { + expect(faviconUrlForOrigin("https://t3.chat", 64)).toBe( + "https://www.google.com/s2/favicons?domain=t3.chat&sz=64", + ); + }); +}); diff --git a/apps/web/src/lib/favicon.ts b/apps/web/src/lib/favicon.ts index e5e94b2666f..bac6f58420c 100644 --- a/apps/web/src/lib/favicon.ts +++ b/apps/web/src/lib/favicon.ts @@ -1,3 +1,5 @@ +import { isPrivateHost } from "@t3tools/shared/privateHost"; + /** * Favicon helpers for the preview tab strip. * @@ -13,6 +15,9 @@ export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32 const url = new URL(rawUrl); if (!url.host) return null; if (url.protocol !== "http:" && url.protocol !== "https:") return null; + // A preview URL often points at a dev server or a tailnet host. Send no + // such host name to the favicon provider, which cannot resolve it anyway. + if (isPrivateHost(url.hostname)) return null; return `${FAVICON_PROVIDER}?domain=${encodeURIComponent(url.host)}&sz=${size}`; } catch { return null; diff --git a/packages/shared/package.json b/packages/shared/package.json index f669bd0a452..fc844837757 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -218,6 +218,10 @@ "./usageFormat": { "types": "./src/usageFormat.ts", "import": "./src/usageFormat.ts" + }, + "./privateHost": { + "types": "./src/privateHost.ts", + "import": "./src/privateHost.ts" } }, "scripts": { diff --git a/apps/mobile/src/lib/privateLinkHost.test.ts b/packages/shared/src/privateHost.test.ts similarity index 63% rename from apps/mobile/src/lib/privateLinkHost.test.ts rename to packages/shared/src/privateHost.test.ts index 0830c669af7..afaed886953 100644 --- a/apps/mobile/src/lib/privateLinkHost.test.ts +++ b/packages/shared/src/privateHost.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { isPrivateLinkHost } from "./privateLinkHost"; +import { isPrivateHost } from "./privateHost.ts"; -describe("isPrivateLinkHost", () => { +describe("isPrivateHost", () => { it("treats public hosts as public", () => { for (const host of [ "github.com", @@ -16,7 +16,7 @@ describe("isPrivateLinkHost", () => { "192.167.1.1", "11.0.0.1", ]) { - expect(isPrivateLinkHost(host), host).toBe(false); + expect(isPrivateHost(host), host).toBe(false); } }); @@ -30,16 +30,16 @@ describe("isPrivateLinkHost", () => { "172.31.255.255", "169.254.1.1", ]) { - expect(isPrivateLinkHost(host), host).toBe(true); + expect(isPrivateHost(host), host).toBe(true); } }); it("detects the Tailscale 100.64.0.0/10 range", () => { for (const host of ["100.64.0.1", "100.100.100.100", "100.126.17.15", "100.127.255.255"]) { - expect(isPrivateLinkHost(host), host).toBe(true); + expect(isPrivateHost(host), host).toBe(true); } - expect(isPrivateLinkHost("100.63.255.255")).toBe(false); - expect(isPrivateLinkHost("100.128.0.1")).toBe(false); + expect(isPrivateHost("100.63.255.255")).toBe(false); + expect(isPrivateHost("100.128.0.1")).toBe(false); }); it("detects private host names and suffixes", () => { @@ -52,24 +52,24 @@ describe("isPrivateLinkHost", () => { "box.tailnet.ts.net", "AIR.TAILE8BEA7.TS.NET", ]) { - expect(isPrivateLinkHost(host), host).toBe(true); + expect(isPrivateHost(host), host).toBe(true); } }); it("detects private IPv6 addresses", () => { for (const host of ["::1", "[::1]", "fd00::1", "fc00::1", "fe80::1", "FD12:3456::1"]) { - expect(isPrivateLinkHost(host), host).toBe(true); + expect(isPrivateHost(host), host).toBe(true); } - expect(isPrivateLinkHost("2606:4700:4700::1111")).toBe(false); + expect(isPrivateHost("2606:4700:4700::1111")).toBe(false); }); it("treats an empty host as private", () => { - expect(isPrivateLinkHost("")).toBe(true); - expect(isPrivateLinkHost(" ")).toBe(true); + expect(isPrivateHost("")).toBe(true); + expect(isPrivateHost(" ")).toBe(true); }); it("rejects malformed IPv4 text as a public host", () => { - expect(isPrivateLinkHost("10.0.0.999")).toBe(false); - expect(isPrivateLinkHost("10.0.0")).toBe(false); + expect(isPrivateHost("10.0.0.999")).toBe(false); + expect(isPrivateHost("10.0.0")).toBe(false); }); }); diff --git a/apps/mobile/src/lib/privateLinkHost.ts b/packages/shared/src/privateHost.ts similarity index 74% rename from apps/mobile/src/lib/privateLinkHost.ts rename to packages/shared/src/privateHost.ts index e4e3ebf3c46..9cee05d7fb2 100644 --- a/apps/mobile/src/lib/privateLinkHost.ts +++ b/packages/shared/src/privateHost.ts @@ -1,3 +1,13 @@ +/** + * Private host detection, for code that sends a host name to a third party. + * + * A public favicon service cannot resolve a private host, so a request for one + * always fails. The request also tells that service the private host name. + * + * This module holds no runtime dependency, so web, mobile and desktop can all + * import it. + */ + const PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".home.arpa", ".ts.net"]; function isPrivateIpv4(host: string): boolean { @@ -36,12 +46,12 @@ function isPrivateIpv6(host: string): boolean { } /** - * True when a link host belongs to a private network. + * True when a host belongs to a private network. * - * A favicon service cannot resolve such a host, so a request for it always - * fails. The request also tells that service the private host name. + * An empty host counts as private, so a caller that cannot read a host never + * sends it to a third party. */ -export function isPrivateLinkHost(host: string): boolean { +export function isPrivateHost(host: string): boolean { const normalized = host.trim().toLowerCase(); if (normalized.length === 0) { return true; From 575e128929ec5def97bd72e043bdfe746b372f54 Mon Sep 17 00:00:00 2001 From: Felipe Franco Date: Sun, 9 Aug 2026 12:30:45 -0300 Subject: [PATCH 3/3] fix(shared): close three private host gaps found in review isPrivateHost let three private forms through: - IPv4-mapped IPv6, both ::ffff:192.168.1.10 and ::ffff:c0a8:010a - a trailing DNS root label, such as printer.local. - names under .localhost, which RFC 6761 reserves for loopback Add ipv4FromMappedIpv6 and delegate the embedded address to isPrivateIpv4. Strip one trailing dot before the suffix checks. Add .localhost to the suffix list. Each gap gets a test, including the public counterexamples ::ffff:8.8.8.8 and github.com. Co-Authored-By: Claude Opus 5 (1M context) --- packages/shared/src/privateHost.test.ts | 35 ++++++++++++++++++++ packages/shared/src/privateHost.ts | 43 +++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/privateHost.test.ts b/packages/shared/src/privateHost.test.ts index afaed886953..d46380e8039 100644 --- a/packages/shared/src/privateHost.test.ts +++ b/packages/shared/src/privateHost.test.ts @@ -63,6 +63,41 @@ describe("isPrivateHost", () => { expect(isPrivateHost("2606:4700:4700::1111")).toBe(false); }); + it("detects IPv4-mapped IPv6 addresses in both spellings", () => { + for (const host of [ + "::ffff:192.168.1.10", + "::ffff:10.0.0.1", + "::ffff:100.126.17.15", + "[::ffff:192.168.1.10]", + // c0a8:010a is 192.168.1.10, 0a00:0001 is 10.0.0.1. + "::ffff:c0a8:010a", + "::ffff:a00:1", + ]) { + expect(isPrivateHost(host), host).toBe(true); + } + expect(isPrivateHost("::ffff:8.8.8.8")).toBe(false); + expect(isPrivateHost("::ffff:808:808")).toBe(false); + }); + + it("ignores a trailing DNS root label", () => { + for (const host of [ + "localhost.", + "printer.local.", + "api.internal.", + "box.tailnet.ts.net.", + "air.", + ]) { + expect(isPrivateHost(host), host).toBe(true); + } + expect(isPrivateHost("github.com.")).toBe(false); + }); + + it("detects names under .localhost", () => { + for (const host of ["app.localhost", "api.app.localhost", "APP.LOCALHOST"]) { + expect(isPrivateHost(host), host).toBe(true); + } + }); + it("treats an empty host as private", () => { expect(isPrivateHost("")).toBe(true); expect(isPrivateHost(" ")).toBe(true); diff --git a/packages/shared/src/privateHost.ts b/packages/shared/src/privateHost.ts index 9cee05d7fb2..bd18a715ab3 100644 --- a/packages/shared/src/privateHost.ts +++ b/packages/shared/src/privateHost.ts @@ -8,7 +8,40 @@ * import it. */ -const PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".home.arpa", ".ts.net"]; +// RFC 6761 reserves every name under .localhost for the loopback interface. +const PRIVATE_HOST_SUFFIXES = [".localhost", ".local", ".internal", ".home.arpa", ".ts.net"]; + +const IPV4_MAPPED_PREFIX = /^::ffff:/; + +/** + * The IPv4 address inside an IPv4-mapped IPv6 address, or null. + * + * Accepts the dotted form `::ffff:192.168.1.10` and the hex form + * `::ffff:c0a8:010a`. + */ +function ipv4FromMappedIpv6(address: string): string | null { + if (!IPV4_MAPPED_PREFIX.test(address)) { + return null; + } + const tail = address.replace(IPV4_MAPPED_PREFIX, ""); + if (tail.includes(".")) { + return tail; + } + const groups = tail.split(":"); + if (groups.length !== 2) { + return null; + } + const [high, low] = groups; + if (high === undefined || low === undefined) { + return null; + } + if (!/^[0-9a-f]{1,4}$/.test(high) || !/^[0-9a-f]{1,4}$/.test(low)) { + return null; + } + const highValue = Number.parseInt(high, 16); + const lowValue = Number.parseInt(low, 16); + return [highValue >> 8, highValue & 0xff, lowValue >> 8, lowValue & 0xff].join("."); +} function isPrivateIpv4(host: string): boolean { const parts = host.split("."); @@ -52,7 +85,8 @@ function isPrivateIpv6(host: string): boolean { * sends it to a third party. */ export function isPrivateHost(host: string): boolean { - const normalized = host.trim().toLowerCase(); + // Drop a trailing DNS root label, so "printer.local." matches ".local". + const normalized = host.trim().toLowerCase().replace(/\.$/, ""); if (normalized.length === 0) { return true; } @@ -66,5 +100,10 @@ export function isPrivateHost(host: string): boolean { if (!normalized.includes(".") && !normalized.includes(":")) { return true; } + const bare = normalized.replace(/^\[/, "").replace(/\]$/, ""); + const mapped = ipv4FromMappedIpv6(bare); + if (mapped !== null) { + return isPrivateIpv4(mapped); + } return isPrivateIpv4(normalized) || isPrivateIpv6(normalized); }