-
Notifications
You must be signed in to change notification settings - Fork 4k
fix: stop favicon requests for private link hosts on web and mobile #5838
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
fe-franco
wants to merge
3
commits into
pingdotgg:main
Choose a base branch
from
fe-franco:fix/skip-favicon-for-private-hosts
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
| 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; | ||
|
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); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.