diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index cd6f2d06b79c..5a4849784d4c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -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, @@ -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(null); + const faviconUrl = faviconUrlForOrigin(`https://${props.host}`); return ( - {!failed ? ( + {faviconUrl !== null && + failedHost !== props.host && + !failedMarkdownFaviconHosts.has(props.host) ? ( { failedMarkdownFaviconHosts.add(props.host); - setFailed(true); + setFailedHost(props.host); }} /> ) : ( diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 684247e28022..dc031c3ec6ab 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -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); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3440bfbee5d9..8ce71d19af0d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -25,6 +25,7 @@ import { 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, { @@ -1133,16 +1134,17 @@ const failedFaviconHosts = new Set(); const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); + const faviconUrl = faviconUrlForOrigin(`https://${host}`); return ( - {failedHost === host || failedFaviconHosts.has(host) ? ( + {faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( ` 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"; diff --git a/packages/shared/package.json b/packages/shared/package.json index fe83885d0d73..48b837b53fae 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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": { diff --git a/packages/shared/src/favicon.test.ts b/packages/shared/src/favicon.test.ts new file mode 100644 index 000000000000..1cd56665f102 --- /dev/null +++ b/packages/shared/src/favicon.test.ts @@ -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(); + }, + ); +}); diff --git a/packages/shared/src/favicon.ts b/packages/shared/src/favicon.ts new file mode 100644 index 000000000000..24158099a9d0 --- /dev/null +++ b/packages/shared/src/favicon.ts @@ -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; + } +} diff --git a/packages/shared/src/hostClassification.test.ts b/packages/shared/src/hostClassification.test.ts new file mode 100644 index 000000000000..4110c9f207a5 --- /dev/null +++ b/packages/shared/src/hostClassification.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isPublicFaviconHost } from "./hostClassification.ts"; + +describe("isPublicFaviconHost", () => { + 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(isPublicFaviconHost(host), host).toBe(true); + } + }); + + it("detects private IPv4 ranges", () => { + for (const host of [ + "0.0.0.0", + "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(isPublicFaviconHost(host), host).toBe(false); + } + }); + + 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(isPublicFaviconHost(host), host).toBe(false); + } + expect(isPublicFaviconHost("100.63.255.255")).toBe(true); + expect(isPublicFaviconHost("100.128.0.1")).toBe(true); + }); + + it("detects private host names and suffixes", () => { + for (const host of [ + "localhost", + "air", + "printer.local", + "api.internal", + "router.home.arpa", + "home.arpa", + "box.tailnet.ts.net", + "AIR.TAILE8BEA7.TS.NET", + ]) { + expect(isPublicFaviconHost(host), host).toBe(false); + } + }); + + it("detects private IPv6 addresses", () => { + for (const host of ["::1", "[::1]", "fd00::1", "fc00::1", "fe80::1", "FD12:3456::1"]) { + expect(isPublicFaviconHost(host), host).toBe(false); + } + expect(isPublicFaviconHost("2606:4700:4700::1111")).toBe(true); + }); + + 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]", + "::ffff:c0a8:010a", + "::ffff:a00:1", + ]) { + expect(isPublicFaviconHost(host), host).toBe(false); + } + expect(isPublicFaviconHost("::ffff:8.8.8.8")).toBe(true); + expect(isPublicFaviconHost("::ffff:808:808")).toBe(true); + }); + + it("ignores a trailing DNS root label", () => { + for (const host of [ + "localhost.", + "printer.local.", + "api.internal.", + "box.tailnet.ts.net.", + "air.", + ]) { + expect(isPublicFaviconHost(host), host).toBe(false); + } + expect(isPublicFaviconHost("github.com.")).toBe(true); + }); + + it("detects names under .localhost", () => { + for (const host of ["app.localhost", "api.app.localhost", "APP.LOCALHOST"]) { + expect(isPublicFaviconHost(host), host).toBe(false); + } + }); + + it("treats an empty host as private", () => { + expect(isPublicFaviconHost("")).toBe(false); + expect(isPublicFaviconHost(" ")).toBe(false); + }); + + it("rejects malformed IPv4 text as a public host", () => { + expect(isPublicFaviconHost("10.0.0.999")).toBe(true); + expect(isPublicFaviconHost("10.0.0")).toBe(true); + }); +}); diff --git a/packages/shared/src/hostClassification.ts b/packages/shared/src/hostClassification.ts new file mode 100644 index 000000000000..7191fa78221f --- /dev/null +++ b/packages/shared/src/hostClassification.ts @@ -0,0 +1,149 @@ +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; +};