diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index db7fecf64ff..d6c16e00270 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 { isPrivateHost } from "@t3tools/shared/privateHost"; 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( + () => isPrivateHost(props.host) || failedMarkdownFaviconHosts.has(props.host), + ); return ( (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/packages/shared/src/privateHost.test.ts b/packages/shared/src/privateHost.test.ts new file mode 100644 index 00000000000..d46380e8039 --- /dev/null +++ b/packages/shared/src/privateHost.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isPrivateHost } from "./privateHost.ts"; + +describe("isPrivateHost", () => { + 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(isPrivateHost(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(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(isPrivateHost(host), host).toBe(true); + } + expect(isPrivateHost("100.63.255.255")).toBe(false); + expect(isPrivateHost("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(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(isPrivateHost(host), host).toBe(true); + } + 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); + }); + + it("rejects malformed IPv4 text as a public host", () => { + expect(isPrivateHost("10.0.0.999")).toBe(false); + expect(isPrivateHost("10.0.0")).toBe(false); + }); +}); diff --git a/packages/shared/src/privateHost.ts b/packages/shared/src/privateHost.ts new file mode 100644 index 00000000000..bd18a715ab3 --- /dev/null +++ b/packages/shared/src/privateHost.ts @@ -0,0 +1,109 @@ +/** + * 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. + */ + +// 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("."); + 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 host belongs to a private network. + * + * An empty host counts as private, so a caller that cannot read a host never + * sends it to a third party. + */ +export function isPrivateHost(host: string): boolean { + // Drop a trailing DNS root label, so "printer.local." matches ".local". + const normalized = host.trim().toLowerCase().replace(/\.$/, ""); + 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; + } + const bare = normalized.replace(/^\[/, "").replace(/\]$/, ""); + const mapped = ipv4FromMappedIpv6(bare); + if (mapped !== null) { + return isPrivateIpv4(mapped); + } + return isPrivateIpv4(normalized) || isPrivateIpv6(normalized); +}