Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<NativeText
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -851,7 +852,9 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host:
const [failedHost, setFailedHost] = useState<string | null>(null);
return (
<span className="chat-markdown-link-favicon" aria-hidden>
{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) ? (
<GlobeIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />
) : (
<img
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/lib/favicon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vite-plus/test";

import { faviconUrlForOrigin } from "./favicon";

describe("faviconUrlForOrigin", () => {
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",
);
});
});
5 changes: 5 additions & 0 deletions apps/web/src/lib/favicon.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isPrivateHost } from "@t3tools/shared/privateHost";

/**
* Favicon helpers for the preview tab strip.
*
Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@
"./usageFormat": {
"types": "./src/usageFormat.ts",
"import": "./src/usageFormat.ts"
},
"./privateHost": {
"types": "./src/privateHost.ts",
"import": "./src/privateHost.ts"
}
},
"scripts": {
Expand Down
110 changes: 110 additions & 0 deletions packages/shared/src/privateHost.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
109 changes: 109 additions & 0 deletions packages/shared/src/privateHost.ts
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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;
Comment thread
cursor[bot] marked this conversation as resolved.
}
// 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);
}
Loading