Skip to content
Merged
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
12 changes: 8 additions & 4 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
} from "../review/nativeReviewDiffAdapter";
import { buildReviewParsedDiff } from "../review/reviewModel";
import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
import { cn } from "../../lib/cn";
import {
deriveCenteredContentHorizontalPadding,
Expand Down Expand Up @@ -447,7 +448,8 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
readonly host: string;
readonly href: string;
}) {
const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host));
const [failedHost, setFailedHost] = useState<string | null>(null);
const faviconUrl = faviconUrlForOrigin(`https://${props.host}`);

return (
<NativeText
Expand All @@ -460,15 +462,17 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
textDecorationLine: "none",
}}
>
{!failed ? (
{faviconUrl !== null &&
failedHost !== props.host &&
!failedMarkdownFaviconHosts.has(props.host) ? (
<Image
source={{
uri: `https://www.google.com/s2/favicons?domain=${encodeURIComponent(props.host)}&sz=32`,
uri: faviconUrl,
}}
style={[markdownLinkStyles.inlineIcon, markdownLinkStyles.favicon]}
onError={() => {
failedMarkdownFaviconHosts.add(props.host);
setFailed(true);
setFailedHost(props.host);
}}
/>
) : (
Expand Down
156 changes: 7 additions & 149 deletions apps/web/src/browser/browserTargetResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,158 +4,16 @@ import type {
PreviewUrlResolution,
} from "@t3tools/contracts";
import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";
import {
isLocalLoopbackHost,
isPrivateNetworkHost,
isPublicFaviconHost,
normalizeHostname,
} from "@t3tools/shared/hostClassification";

import { readPreparedConnection } from "~/state/session";

export const normalizeHostname = (host: string): string =>
host
.toLowerCase()
.replace(/^\[|\]$/g, "")
.replace(/\.+$/u, "");

const parseIpv4Address = (host: string): readonly number[] | null => {
const parts = normalizeHostname(host).split(".").map(Number);
return parts.length === 4 &&
parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)
? parts
: null;
};

const parseIpv4MappedIpv6Address = (host: string): readonly number[] | null => {
const normalized = normalizeHostname(host);
if (!normalized.startsWith("::ffff:")) return null;
const suffix = normalized.slice("::ffff:".length);
const dotted = parseIpv4Address(suffix);
if (dotted) return dotted;
const hextets = suffix.split(":");
if (hextets.length !== 2 || hextets.some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null;
const high = Number.parseInt(hextets[0]!, 16);
const low = Number.parseInt(hextets[1]!, 16);
return [high >>> 8, high & 0xff, low >>> 8, low & 0xff];
};

const parseIpv6Address = (host: string): readonly number[] | null => {
const normalized = normalizeHostname(host);
if (!normalized.includes(":")) return null;
const halves = normalized.split("::");
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(":") : [];
const tail = halves[1] ? halves[1].split(":") : [];
if ([...head, ...tail].some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null;
const missing = 8 - head.length - tail.length;
if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) return null;
return [...head, ...Array.from({ length: missing }, () => "0"), ...tail].map((part) =>
Number.parseInt(part, 16),
);
};

const ipv6PrefixMatches = (
address: readonly number[],
prefix: readonly number[],
prefixLength: number,
): boolean => {
const fullHextets = Math.floor(prefixLength / 16);
if (address.slice(0, fullHextets).some((part, index) => part !== prefix[index])) return false;
const remainingBits = prefixLength % 16;
if (remainingBits === 0) return true;
const mask = (0xffff << (16 - remainingBits)) & 0xffff;
return (address[fullHextets]! & mask) === (prefix[fullHextets]! & mask);
};

const isPrivateIpv4Address = (parts: readonly number[]): boolean =>
parts[0] === 0 ||
parts[0] === 10 ||
parts[0] === 127 ||
(parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) ||
(parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
(parts[0] === 169 && parts[1] === 254) ||
(parts[0] === 198 && parts[1]! >= 18 && parts[1]! <= 19);

const isSpecialPurposeIpv4Address = (parts: readonly number[]): boolean =>
isPrivateIpv4Address(parts) ||
parts[0]! >= 224 ||
// Deliberately suppress the whole protocol-assignment block. IANA marks
// .9 and .10 globally reachable, but privacy-safe false negatives are
// preferable to disclosing another special-purpose address by mistake.
(parts[0] === 192 && parts[1] === 0 && parts[2] === 0) ||
(parts[0] === 192 && parts[1] === 0 && parts[2] === 2) ||
(parts[0] === 192 && parts[1] === 88 && parts[2] === 99) ||
(parts[0] === 198 && parts[1] === 51 && parts[2] === 100) ||
(parts[0] === 203 && parts[1] === 0 && parts[2] === 113);

export const isLocalLoopbackHost = (host: string): boolean => {
const normalized = normalizeHostname(host);
if (normalized === "localhost" || normalized === "::1") return true;
return parseIpv4Address(normalized)?.[0] === 127;
};

export const isPrivateNetworkHost = (host: string): boolean => {
const normalized = normalizeHostname(host);
if (
normalized === "::" ||
isLocalLoopbackHost(normalized) ||
normalized.endsWith(".localhost") ||
normalized.endsWith(".local") ||
normalized === "home.arpa" ||
normalized.endsWith(".home.arpa") ||
(!normalized.includes(".") && !normalized.includes(":"))
) {
return true;
}
if (normalized.endsWith(".ts.net")) return true;
const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
if (parts) return isPrivateIpv4Address(parts);
const firstIpv6Token = normalized.split(":", 1)[0] ?? "";
if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false;
const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16);
return (
Number.isInteger(firstIpv6Hextet) &&
((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80)
);
};

/** Whether a hostname is eligible to be disclosed to a public favicon provider. */
export const isPublicFaviconHost = (host: string): boolean => {
// A single trailing dot is a valid absolute DNS name. Repeated trailing
// dots are malformed and can conceal legacy numeric forms such as 127.1.
if (host.endsWith("..")) return false;
const normalized = normalizeHostname(host);
if (isPrivateNetworkHost(normalized)) return false;
if (
[".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some(
(suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix),
)
) {
return false;
}
const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
if (ipv4) return !isSpecialPurposeIpv4Address(ipv4);
if (!normalized.includes(":")) return true;
const ipv6 = parseIpv6Address(normalized);
if (!ipv6) return false;
if (ipv6PrefixMatches(ipv6, [0x0064, 0xff9b, 0, 0, 0, 0, 0, 0], 96)) {
const embeddedIpv4 = [ipv6[6]! >>> 8, ipv6[6]! & 0xff, ipv6[7]! >>> 8, ipv6[7]! & 0xff];
return !isSpecialPurposeIpv4Address(embeddedIpv4);
}
const first = ipv6[0]!;
if ((first & 0xe000) !== 0x2000) return false;
if (ipv6PrefixMatches(ipv6, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) {
const publicProtocolAssignment =
(ipv6[1] === 1 &&
ipv6.slice(2, 7).every((part) => part === 0) &&
[1, 2, 3].includes(ipv6[7]!)) ||
ipv6PrefixMatches(ipv6, [0x2001, 3, 0, 0, 0, 0, 0, 0], 32) ||
ipv6PrefixMatches(ipv6, [0x2001, 4, 0x0112, 0, 0, 0, 0, 0], 48) ||
ipv6PrefixMatches(ipv6, [0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28) ||
ipv6PrefixMatches(ipv6, [0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28);
return publicProtocolAssignment;
}
if (ipv6PrefixMatches(ipv6, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false;
if (ipv6PrefixMatches(ipv6, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false;
if (first === 0x3fff && (ipv6[1]! & 0xf000) === 0) return false;
return true;
};
export { isLocalLoopbackHost, isPrivateNetworkHost, isPublicFaviconHost, normalizeHostname };

const readEnvironmentUrl = (environmentId: EnvironmentId): URL => {
const connection = readPreparedConnection(environmentId);
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";
import React, {
Expand Down Expand Up @@ -125,9 +126,9 @@
WORKSPACE_BASENAME_LOOKUP_LIMIT,
} from "../workspaceBasenameLookup";
import {
findProjectForChangeRequest,

Check warning on line 129 in apps/web/src/components/ChatMarkdown.tsx

View workflow job for this annotation

GitHub Actions / Repository checks

eslint(no-unused-vars)

Identifier 'findProjectForChangeRequest' is imported but never used.
matchesLinkedPullRequestUrl,
parseChangeRequestUrl,

Check warning on line 131 in apps/web/src/components/ChatMarkdown.tsx

View workflow job for this annotation

GitHub Actions / Repository checks

eslint(no-unused-vars)

Identifier 'parseChangeRequestUrl' is imported but never used.
useOpenChangeRequestLink,
} from "~/lib/openPullRequestLink";
import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
Expand Down Expand Up @@ -1133,16 +1134,17 @@

const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) {
const [failedHost, setFailedHost] = useState<string | null>(null);
const faviconUrl = faviconUrlForOrigin(`https://${host}`);
return (
<span
className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]"
aria-hidden
>
{failedHost === host || failedFaviconHosts.has(host) ? (
{faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? (
<GlobeIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />
) : (
<img
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=32`}
src={faviconUrl}
alt=""
loading="lazy"
draggable={false}
Expand Down Expand Up @@ -1778,10 +1780,10 @@
);
const preparedConnection = usePreparedConnection(environmentId);
const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
const threadServerConfig = useAtomValue(

Check warning on line 1783 in apps/web/src/components/ChatMarkdown.tsx

View workflow job for this annotation

GitHub Actions / Repository checks

eslint(no-unused-vars)

Variable 'threadServerConfig' is declared but never used. Unused variables should start with a '_'.
serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId),
);
const projects = useProjects();

Check warning on line 1786 in apps/web/src/components/ChatMarkdown.tsx

View workflow job for this annotation

GitHub Actions / Repository checks

eslint(no-unused-vars)

Variable 'projects' is declared but never used. Unused variables should start with a '_'.
const availableEditors = serverConfig?.availableEditors ?? [];
const [preferredEditor] = usePreferredEditor(availableEditors);
const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor);
Expand Down
24 changes: 1 addition & 23 deletions apps/web/src/lib/favicon.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1 @@
import { isPublicFaviconHost } from "~/browser/browserTargetResolver";

/**
* Favicon helpers for the preview tab strip.
*
* Uses Google's s2 favicon endpoint (same approach as ami's tab strip).
* Callers should always render a `<Globe />` fallback when the returned URL
* fails to load via an `onError` handler.
*/
const FAVICON_PROVIDER = "https://www.google.com/s2/favicons";

export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null {
if (!rawUrl) return null;
try {
const url = new URL(rawUrl);
if (!url.host) return null;
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (!isPublicFaviconHost(url.hostname)) return null;
return `${FAVICON_PROVIDER}?domain=${encodeURIComponent(url.host)}&sz=${size}`;
} catch {
return null;
}
}
export { faviconUrlForOrigin } from "@t3tools/shared/favicon";
8 changes: 8 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,14 @@
"./claudeCompaction": {
"types": "./src/claudeCompaction.ts",
"import": "./src/claudeCompaction.ts"
},
"./hostClassification": {
"types": "./src/hostClassification.ts",
"import": "./src/hostClassification.ts"
},
"./favicon": {
"types": "./src/favicon.ts",
"import": "./src/favicon.ts"
}
},
"scripts": {
Expand Down
42 changes: 42 additions & 0 deletions packages/shared/src/favicon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vite-plus/test";

import { faviconUrlForOrigin } from "./favicon.ts";

describe("faviconUrlForOrigin", () => {
it.each([
"http://192.168.1.10:8080",
"http://localhost:3000",
"http://home.arpa",
"https://printer.local.",
"https://api.internal",
"https://box.tailnet.ts.net",
"http://127.1",
"http://0x7f000001",
"http://[::]",
"http://[::1]",
"http://[::ffff:192.168.1.10]",
"http://[fd00::1]",
"http://[fe80::1]",
"http://100.64.0.1",
"http://198.51.100.1",
"http://[2001:db8::1]",
"http://service.test",
"http://private.onion",
"http://127.1..",
])("does not disclose %s to the favicon provider", (origin) => {
expect(faviconUrlForOrigin(origin)).toBeNull();
});

it("keeps the public origin, port and requested size", () => {
expect(faviconUrlForOrigin("https://github.com:8443/pingdotgg/t3code?private=query", 64)).toBe(
"https://www.google.com/s2/favicons?domain=github.com%3A8443&sz=64",
);
});

it.each([null, undefined, "", "invalid URL", "file:///tmp/private", "data:text/plain,private"])(
"rejects an invalid or unsupported origin %s",
(origin) => {
expect(faviconUrlForOrigin(origin)).toBeNull();
},
);
});
15 changes: 15 additions & 0 deletions packages/shared/src/favicon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { isPublicFaviconHost } from "./hostClassification.ts";

/** Return a public favicon URL without disclosing private or reserved hosts. */
export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null {
if (!rawUrl) return null;
try {
const url = new URL(rawUrl);
if (!url.host) return null;
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (!isPublicFaviconHost(url.hostname)) return null;
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.host)}&sz=${size}`;
} catch {
return null;
}
}
Loading
Loading