From d03f93a55af176cf4dd45a0b09fbda84d595cebf Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 08:15:41 +0000 Subject: [PATCH] fix(moshpit): decide the namespace by the ending, not by a DNS failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moshpit resolution inferred "clearnet has no answer for this name" from ERR_NAME_NOT_RESOLVED. That inference only holds on a resolver that reports failure honestly, and many do not: an NXDOMAIN-hijacking resolver answers every nonexistent name with a wildcard host. On such a connection blue.eggs "resolves", the error never fires, and in the default mode nothing ever ran — every Moshpit name landed on the hijacker's page. It was unfixable from inside the browser for as long as DNS was the signal. The ending is a better signal and we hold it locally: clearnet can only answer for an ending that exists on the real internet. So a hostname now falls into exactly one territory, decided from the hostname alone: - moshpit — an ending IANA does not delegate. Resolved in BOTH modes, told clearnetResolves=false, and never waiting on a DNS error that may never come. This is the case that broke. - clearnet — a real ending. The default mode returns before any storage read or registry call, so ordinary browsing costs one Set lookup. Only the opt-in 'moshpit' mode still consults the registry, which is what that mode is for. - reserved — .onion, .local, .test, .internal and friends: dropped in both modes, never sent to the registry. - none — not a Moshpit-shaped hostname. The reserved territory closes a leak rather than saving a request. A v3 onion address is 56 alphanumeric characters plus .onion — exactly two alphanumeric labels, so parseRegistryName accepted it and every Tor navigation sent the onion address to the registry. The pit's hosts deliberately bypass the SOCKS proxy, so that lookup left over clearnet carrying the address being visited. This makes the code match what the options page has always claimed: "Moshpit names always resolve here — clearnet has never heard of those endings. This setting only decides what happens when both namespaces answer." The shared resolution policy (decideResolution and friends) is untouched, so all three copies still agree and moshpit-drift.test.js stays green. The routing decision moved out of background.js into moshpit-routing.js so it can be tested without standing up a service worker. scripts/update-tlds.mjs regenerates tld-data.js from IANA (currently 1438 endings, version 2026080300). It is a manual chore, not a build step: a release should not depend on data.iana.org being up. Co-Authored-By: Claude Opus 5 (1M context) --- .../extensions/ai-sidebar/background.js | 66 +- .../extensions/ai-sidebar/moshpit-routing.js | 78 + .../ai-sidebar/moshpit-routing.test.js | 101 ++ .../desktop/extensions/ai-sidebar/tld-data.js | 1447 +++++++++++++++++ apps/desktop/extensions/ai-sidebar/tlds.js | 104 ++ .../extensions/ai-sidebar/tlds.test.js | 116 ++ scripts/update-tlds.mjs | 57 + 7 files changed, 1946 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/extensions/ai-sidebar/moshpit-routing.js create mode 100644 apps/desktop/extensions/ai-sidebar/moshpit-routing.test.js create mode 100644 apps/desktop/extensions/ai-sidebar/tld-data.js create mode 100644 apps/desktop/extensions/ai-sidebar/tlds.js create mode 100644 apps/desktop/extensions/ai-sidebar/tlds.test.js create mode 100644 scripts/update-tlds.mjs diff --git a/apps/desktop/extensions/ai-sidebar/background.js b/apps/desktop/extensions/ai-sidebar/background.js index e64cc82..ed3559f 100644 --- a/apps/desktop/extensions/ai-sidebar/background.js +++ b/apps/desktop/extensions/ai-sidebar/background.js @@ -1,4 +1,5 @@ -import { destinationFor, moshpitBypassHosts, moshpitConfig, parseRegistryName } from './moshpit.js'; +import { destinationFor, moshpitBypassHosts, moshpitConfig } from './moshpit.js'; +import { routeForDnsFailure, routeForNavigation, territoryOf } from './moshpit-routing.js'; // Open the AI side panel when the toolbar action is clicked. chrome.sidePanel @@ -305,18 +306,27 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { // This is what makes the Moshpit settings on the options page actually do // something: until now they were written to storage and never read. // -// Two hooks, because "does clearnet answer for this name?" is only knowable at -// two different moments: +// Which namespace a hostname belongs to is decided by its ENDING, not by +// whether DNS failed. See tlds.js for why: a resolver that hijacks NXDOMAIN +// answers for `blue.eggs` too, so "DNS failed" is a signal we do not reliably +// get, and on those connections the whole namespace silently stopped working. // -// onErrorOccurred — DNS came up empty (ERR_NAME_NOT_RESOLVED). This is the -// backfill path, and the ONLY one active in the default 'clearnet' mode, so -// someone who has never heard of Moshpit gets ordinary browsing plus a -// rescued error page. Nothing that already works is touched. +// So there are two territories, and a hostname is in exactly one: // -// onBeforeNavigate — consulted ONLY in 'moshpit' mode, where a registered -// name is meant to win even though clearnet has an answer. It costs a -// registry round-trip before navigation, which is why the default mode -// never goes near it. +// An ending only Moshpit could own (`.eggs` — not IANA's, not reserved). +// Clearnet cannot legitimately answer for it, so resolution runs in BOTH +// modes and does not wait for a DNS error that may never come. This is the +// path that a hijacking resolver used to swallow. +// +// A real or reserved ending (`.com`, `.onion`, `.local`). Ordinary browsing, +// and the default mode never touches the registry for it — no round-trip, +// no added latency, nothing on the wire. Only the opt-in 'moshpit' mode +// consults the registry here, because only it lets a registered name +// override a working clearnet domain, and that is what it costs. +// +// onErrorOccurred still backfills a real ending whose DNS genuinely failed — +// that is an honest signal when we get it, and it is how a `.com` that nobody +// registered can still fall through to Moshpit. // // No redirect loop: every destination we send a tab to (pit.moshcode.sh/n/…, // app.moshcode.sh/pit) has three labels, so parseRegistryName rejects it and @@ -327,11 +337,13 @@ const DNS_FAILED = new Set([ 'net::ERR_NAME_RESOLUTION_FAILED', ]); -function moshpitHostname(url) { +// The hostname of a top-level http(s) navigation. Whether it is ours to touch +// is routeForNavigation's call, not this one's. +function navigationHostname(url) { try { const u = new URL(url); if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''; - return parseRegistryName(u.hostname) ? u.hostname : ''; + return u.hostname; } catch { return ''; } @@ -348,20 +360,28 @@ async function sendTabTo(tabId, url) { chrome.webNavigation?.onErrorOccurred.addListener(async (details) => { if (details.frameId !== 0) return; // top-level navigations only if (!DNS_FAILED.has(details.error)) return; - const hostname = moshpitHostname(details.url); - if (!hostname) return; - const dest = await destinationFor(hostname, false); + const hostname = navigationHostname(details.url); + const route = routeForDnsFailure(hostname); + if (!route.resolve) return; + const dest = await destinationFor(hostname, route.clearnetResolves); if (dest) await sendTabTo(details.tabId, dest); }); chrome.webNavigation?.onBeforeNavigate.addListener(async (details) => { if (details.frameId !== 0) return; - const hostname = moshpitHostname(details.url); - if (!hostname) return; - // The default mode must never pre-empt a working clearnet domain — bail out - // before the registry is ever contacted. - const { mode } = await moshpitConfig(); - if (mode !== 'moshpit') return; - const dest = await destinationFor(hostname, true); + const hostname = navigationHostname(details.url); + + // The territory is decided from the hostname alone, so an ordinary navigation + // to a real ending costs one Set lookup — no storage read, no registry call. + // Only 'clearnet' has an answer that depends on the mode, so only it pays for + // reading the mode. + const territory = territoryOf(hostname); + if (territory === 'none' || territory === 'reserved') return; + const mode = territory === 'clearnet' ? (await moshpitConfig()).mode : 'clearnet'; + + const route = routeForNavigation(hostname, mode); + if (!route.resolve) return; + + const dest = await destinationFor(hostname, route.clearnetResolves); if (dest) await sendTabTo(details.tabId, dest); }); diff --git a/apps/desktop/extensions/ai-sidebar/moshpit-routing.js b/apps/desktop/extensions/ai-sidebar/moshpit-routing.js new file mode 100644 index 0000000..d9373f2 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/moshpit-routing.js @@ -0,0 +1,78 @@ +// Whether a navigation is Moshpit's business, and what to tell the policy. +// +// Split out of background.js so it can be tested without standing up a service +// worker: background.js has top-level Tor and proxy work that has nothing to do +// with name resolution. What is left there is the two listeners and this call. +// +// The decision this makes used to be made by DNS — see tlds.js for why that was +// unsound on a resolver that hijacks NXDOMAIN. +import { parseRegistryName } from './moshpit.js'; +import { isMoshpitOnlyNamespace, isReservedNamespace } from './tlds.js'; + +/** + * Which territory a hostname is in. Total: every hostname is in exactly one. + * + * 'none' — not a Moshpit-shaped hostname at all (wrong label count, an + * IP, a port, a dash). Never ours. + * 'reserved' — `.onion`, `.local` and friends: answered by something that is + * neither clearnet nor Moshpit, and must not reach the registry + * in either mode. + * 'moshpit' — an ending only Moshpit could own. Clearnet cannot answer for + * it, whatever the resolver said. + * 'clearnet' — a real ending. Ordinary browsing. + */ +export function territoryOf(hostname) { + if (!hostname || !parseRegistryName(hostname)) return 'none'; + if (isReservedNamespace(hostname)) return 'reserved'; + if (isMoshpitOnlyNamespace(hostname)) return 'moshpit'; + return 'clearnet'; +} + +/** + * What to do with a navigation we are about to let through. + * + * Returns `{ resolve: false, why }` to leave the tab alone, or + * `{ resolve: true, clearnetResolves, why }` to run the Moshpit policy — where + * `clearnetResolves` is what we know about clearnet, not what DNS claimed. + */ +export function routeForNavigation(hostname, mode) { + switch (territoryOf(hostname)) { + case 'none': + return { resolve: false, why: 'not a Moshpit-shaped hostname' }; + case 'reserved': + // .onion above all: asking the registry would carry the address out over + // clearnet, because the pit's hosts bypass the SOCKS proxy. + return { resolve: false, why: 'reserved ending — answered by neither clearnet nor Moshpit' }; + case 'moshpit': + // Clearnet cannot own this ending, so whatever DNS returned for it was + // not an answer. Both modes resolve it; neither waits for a DNS error. + return { resolve: true, clearnetResolves: false, why: 'ending only Moshpit can own' }; + default: + if (mode !== 'moshpit') { + // The default mode leaves a real ending to clearnet, and — the point of + // returning here — never spends a registry round-trip on it. + return { resolve: false, why: 'real ending, and Moshpit is set to backfill only' }; + } + return { resolve: true, clearnetResolves: true, why: 'moshpit mode may override a real ending' }; + } +} + +/** + * The same question for a navigation that already failed DNS. + * + * Only a real ending is actionable here: a Moshpit-only ending was handled + * before the request went out, and running again on the error would race that + * redirect. + */ +export function routeForDnsFailure(hostname) { + switch (territoryOf(hostname)) { + case 'clearnet': + return { resolve: true, clearnetResolves: false, why: 'real ending, and clearnet genuinely had no answer' }; + case 'moshpit': + return { resolve: false, why: 'already handled before the request went out' }; + case 'reserved': + return { resolve: false, why: 'reserved ending — answered by neither clearnet nor Moshpit' }; + default: + return { resolve: false, why: 'not a Moshpit-shaped hostname' }; + } +} diff --git a/apps/desktop/extensions/ai-sidebar/moshpit-routing.test.js b/apps/desktop/extensions/ai-sidebar/moshpit-routing.test.js new file mode 100644 index 0000000..eb3c0f2 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/moshpit-routing.test.js @@ -0,0 +1,101 @@ +// What each navigation is routed to, and — as much as it matters — what it +// costs. These are the regressions the DNS-based version could not express. +import { describe, expect, it } from 'vitest'; + +import { routeForDnsFailure, routeForNavigation, territoryOf } from './moshpit-routing.js'; + +const ONION = 'duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion'; + +describe('territoryOf', () => { + it('sorts every hostname into exactly one territory', () => { + expect(territoryOf('blue.eggs')).toBe('moshpit'); + expect(territoryOf('google.com')).toBe('clearnet'); + expect(territoryOf(ONION)).toBe('reserved'); + expect(territoryOf('printer.local')).toBe('reserved'); + expect(territoryOf('a.b.c')).toBe('none'); // three labels + expect(territoryOf('1.2.3.4')).toBe('none'); // an IP + expect(territoryOf('localhost')).toBe('none'); // no ending + expect(territoryOf('my-site.eggs')).toBe('none'); // a dash — the registry rejects it + expect(territoryOf('')).toBe('none'); + }); +}); + +describe('a Moshpit name resolves even when the resolver lies about it', () => { + // The bug: an NXDOMAIN-hijacking resolver answers for blue.eggs, so + // ERR_NAME_NOT_RESOLVED never fires and the old code never ran at all. + it('resolves in the DEFAULT mode, without waiting for a DNS error', () => { + const r = routeForNavigation('blue.eggs', 'clearnet'); + expect(r.resolve).toBe(true); + // The whole point: we assert clearnet has no answer regardless of DNS. + expect(r.clearnetResolves).toBe(false); + }); + + it('resolves in moshpit mode the same way', () => { + expect(routeForNavigation('blue.eggs', 'moshpit')).toMatchObject({ + resolve: true, clearnetResolves: false, + }); + }); + + it('does not run again on the DNS error, which would race the redirect', () => { + expect(routeForDnsFailure('blue.eggs').resolve).toBe(false); + }); +}); + +describe('ordinary browsing costs nothing', () => { + it('leaves a real ending alone in the default mode', () => { + const r = routeForNavigation('google.com', 'clearnet'); + expect(r.resolve).toBe(false); + expect(r.why).toMatch(/backfill only/); + }); + + it('still lets moshpit mode override a real ending — that is what it is for', () => { + expect(routeForNavigation('google.com', 'moshpit')).toMatchObject({ + resolve: true, clearnetResolves: true, + }); + }); + + it('backfills a real ending whose DNS honestly failed', () => { + expect(routeForDnsFailure('nothing.com')).toMatchObject({ + resolve: true, clearnetResolves: false, + }); + }); +}); + +describe('a .onion address never reaches the registry', () => { + // It is two alphanumeric labels, so the shape test alone accepts it. The + // registry hosts bypass the SOCKS proxy, so a lookup would carry the onion + // address out over clearnet. + it('is left alone in both modes', () => { + for (const mode of ['clearnet', 'moshpit']) { + const r = routeForNavigation(ONION, mode); + expect(r.resolve, mode).toBe(false); + expect(r.why, mode).toMatch(/reserved/); + } + }); + + it('is left alone on a DNS failure too', () => { + expect(routeForDnsFailure(ONION).resolve).toBe(false); + }); + + it('applies to the other reserved endings as well', () => { + for (const h of ['printer.local', 'box.lan', 'app.internal', 'foo.test']) { + expect(routeForNavigation(h, 'moshpit').resolve, h).toBe(false); + } + }); +}); + +describe('the decision is total', () => { + it('returns a usable shape for every combination', () => { + const hosts = ['blue.eggs', 'google.com', ONION, 'a.b.c', '', 'localhost', '1.2.3.4']; + for (const h of hosts) { + for (const mode of ['clearnet', 'moshpit', undefined]) { + for (const fn of [routeForNavigation, routeForDnsFailure]) { + const r = fn(h, mode); + expect(typeof r.resolve, `${fn.name} ${h} ${mode}`).toBe('boolean'); + expect(typeof r.why, `${fn.name} ${h} ${mode}`).toBe('string'); + if (r.resolve) expect(typeof r.clearnetResolves).toBe('boolean'); + } + } + } + }); +}); diff --git a/apps/desktop/extensions/ai-sidebar/tld-data.js b/apps/desktop/extensions/ai-sidebar/tld-data.js new file mode 100644 index 0000000..f596c97 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/tld-data.js @@ -0,0 +1,1447 @@ +// GENERATED by scripts/update-tlds.mjs — do not edit by hand. +// IANA tlds-alpha-by-domain.txt, version 2026080300. +// +// Every ending that exists on the real internet. Used to decide whether clearnet +// could ever legitimately answer for a hostname; see tlds.js for the policy. +export const IANA_TLD_VERSION = '2026080300'; + +export const IANA_TLDS = [ + 'aaa', + 'aarp', + 'abb', + 'abbott', + 'abbvie', + 'abc', + 'able', + 'abogado', + 'abudhabi', + 'ac', + 'academy', + 'accenture', + 'accountant', + 'accountants', + 'aco', + 'actor', + 'ad', + 'ads', + 'adult', + 'ae', + 'aeg', + 'aero', + 'aetna', + 'af', + 'afl', + 'africa', + 'ag', + 'agakhan', + 'agency', + 'ai', + 'aig', + 'airbus', + 'airforce', + 'airtel', + 'akdn', + 'al', + 'alibaba', + 'alipay', + 'allfinanz', + 'allstate', + 'ally', + 'alsace', + 'alstom', + 'am', + 'amazon', + 'americanexpress', + 'americanfamily', + 'amex', + 'amfam', + 'amica', + 'amsterdam', + 'analytics', + 'android', + 'anquan', + 'anz', + 'ao', + 'aol', + 'apartments', + 'app', + 'apple', + 'aq', + 'aquarelle', + 'ar', + 'arab', + 'aramco', + 'archi', + 'army', + 'arpa', + 'art', + 'arte', + 'as', + 'asda', + 'asia', + 'associates', + 'at', + 'athleta', + 'attorney', + 'au', + 'auction', + 'audi', + 'audible', + 'audio', + 'auspost', + 'author', + 'auto', + 'autos', + 'aw', + 'aws', + 'ax', + 'axa', + 'az', + 'azure', + 'ba', + 'baby', + 'baidu', + 'banamex', + 'band', + 'bank', + 'bar', + 'barcelona', + 'barclaycard', + 'barclays', + 'barefoot', + 'bargains', + 'baseball', + 'basketball', + 'bauhaus', + 'bayern', + 'bb', + 'bbc', + 'bbt', + 'bbva', + 'bcg', + 'bcn', + 'bd', + 'be', + 'beats', + 'beauty', + 'beer', + 'berlin', + 'best', + 'bestbuy', + 'bet', + 'bf', + 'bg', + 'bh', + 'bharti', + 'bi', + 'bible', + 'bid', + 'bike', + 'bing', + 'bingo', + 'bio', + 'biz', + 'bj', + 'black', + 'blackfriday', + 'blockbuster', + 'blog', + 'bloomberg', + 'blue', + 'bm', + 'bms', + 'bmw', + 'bn', + 'bnpparibas', + 'bo', + 'boats', + 'boehringer', + 'bofa', + 'bom', + 'bond', + 'boo', + 'book', + 'booking', + 'bosch', + 'bostik', + 'boston', + 'bot', + 'boutique', + 'box', + 'br', + 'bradesco', + 'bridgestone', + 'broadway', + 'broker', + 'brother', + 'brussels', + 'bs', + 'bt', + 'build', + 'builders', + 'business', + 'buy', + 'buzz', + 'bv', + 'bw', + 'by', + 'bz', + 'bzh', + 'ca', + 'cab', + 'cafe', + 'cal', + 'call', + 'calvinklein', + 'cam', + 'camera', + 'camp', + 'canon', + 'capetown', + 'capital', + 'capitalone', + 'car', + 'caravan', + 'cards', + 'care', + 'career', + 'careers', + 'cars', + 'casa', + 'case', + 'cash', + 'casino', + 'cat', + 'catering', + 'catholic', + 'cba', + 'cbn', + 'cbre', + 'cc', + 'cd', + 'center', + 'ceo', + 'cern', + 'cf', + 'cfa', + 'cfd', + 'cg', + 'ch', + 'chanel', + 'channel', + 'charity', + 'chase', + 'chat', + 'cheap', + 'chintai', + 'christmas', + 'chrome', + 'church', + 'ci', + 'cipriani', + 'circle', + 'cisco', + 'citadel', + 'citi', + 'citic', + 'city', + 'ck', + 'cl', + 'claims', + 'cleaning', + 'click', + 'clinic', + 'clinique', + 'clothing', + 'cloud', + 'club', + 'clubmed', + 'cm', + 'cn', + 'co', + 'coach', + 'codes', + 'coffee', + 'college', + 'cologne', + 'com', + 'commbank', + 'community', + 'company', + 'compare', + 'computer', + 'comsec', + 'condos', + 'construction', + 'consulting', + 'contact', + 'contractors', + 'cooking', + 'cool', + 'coop', + 'corsica', + 'country', + 'coupon', + 'coupons', + 'courses', + 'cpa', + 'cr', + 'credit', + 'creditcard', + 'creditunion', + 'cricket', + 'crown', + 'crs', + 'cruise', + 'cruises', + 'cu', + 'cuisinella', + 'cv', + 'cw', + 'cx', + 'cy', + 'cymru', + 'cyou', + 'cz', + 'dad', + 'dance', + 'data', + 'date', + 'dating', + 'datsun', + 'day', + 'dclk', + 'dds', + 'de', + 'deal', + 'dealer', + 'deals', + 'degree', + 'delivery', + 'dell', + 'deloitte', + 'delta', + 'democrat', + 'dental', + 'dentist', + 'desi', + 'design', + 'dev', + 'dhl', + 'diamonds', + 'diet', + 'digital', + 'direct', + 'directory', + 'discount', + 'discover', + 'dish', + 'diy', + 'dj', + 'dk', + 'dm', + 'dnp', + 'do', + 'docs', + 'doctor', + 'dog', + 'domains', + 'dot', + 'download', + 'drive', + 'dtv', + 'dubai', + 'dupont', + 'durban', + 'dvag', + 'dvr', + 'dz', + 'earth', + 'eat', + 'ec', + 'eco', + 'edeka', + 'edu', + 'education', + 'ee', + 'eg', + 'email', + 'emerck', + 'energy', + 'engineer', + 'engineering', + 'enterprises', + 'epson', + 'equipment', + 'er', + 'ericsson', + 'erni', + 'es', + 'esq', + 'estate', + 'et', + 'eu', + 'eurovision', + 'eus', + 'events', + 'exchange', + 'expert', + 'exposed', + 'express', + 'extraspace', + 'fage', + 'fail', + 'fairwinds', + 'faith', + 'family', + 'fan', + 'fans', + 'farm', + 'farmers', + 'fashion', + 'fast', + 'fedex', + 'feedback', + 'ferrari', + 'ferrero', + 'fi', + 'fidelity', + 'fido', + 'film', + 'final', + 'finance', + 'financial', + 'fire', + 'firestone', + 'firmdale', + 'fish', + 'fishing', + 'fit', + 'fitness', + 'fj', + 'fk', + 'flickr', + 'flights', + 'flir', + 'florist', + 'flowers', + 'fly', + 'fm', + 'fo', + 'foo', + 'food', + 'football', + 'ford', + 'forex', + 'forsale', + 'forum', + 'foundation', + 'fox', + 'fr', + 'free', + 'fresenius', + 'frl', + 'frogans', + 'frontier', + 'ftr', + 'fujitsu', + 'fun', + 'fund', + 'furniture', + 'futbol', + 'fyi', + 'ga', + 'gal', + 'gallery', + 'gallo', + 'gallup', + 'game', + 'games', + 'gap', + 'garden', + 'gay', + 'gb', + 'gbiz', + 'gd', + 'gdn', + 'ge', + 'gea', + 'gent', + 'genting', + 'george', + 'gf', + 'gg', + 'ggee', + 'gh', + 'gi', + 'gift', + 'gifts', + 'gives', + 'giving', + 'gl', + 'glass', + 'gle', + 'global', + 'globo', + 'gm', + 'gmail', + 'gmbh', + 'gmo', + 'gmx', + 'gn', + 'godaddy', + 'gold', + 'goldpoint', + 'golf', + 'goodyear', + 'goog', + 'google', + 'gop', + 'got', + 'gov', + 'gp', + 'gq', + 'gr', + 'grainger', + 'graphics', + 'gratis', + 'green', + 'gripe', + 'grocery', + 'group', + 'gs', + 'gt', + 'gu', + 'gucci', + 'guge', + 'guide', + 'guitars', + 'guru', + 'gw', + 'gy', + 'hair', + 'hamburg', + 'hangout', + 'haus', + 'hbo', + 'hdfc', + 'hdfcbank', + 'health', + 'healthcare', + 'help', + 'helsinki', + 'here', + 'hermes', + 'hiphop', + 'hisamitsu', + 'hitachi', + 'hiv', + 'hk', + 'hkt', + 'hm', + 'hn', + 'hockey', + 'holdings', + 'holiday', + 'homedepot', + 'homegoods', + 'homes', + 'homesense', + 'honda', + 'horse', + 'hospital', + 'host', + 'hosting', + 'hot', + 'hotels', + 'hotmail', + 'house', + 'how', + 'hr', + 'hsbc', + 'ht', + 'hu', + 'hughes', + 'hyatt', + 'hyundai', + 'ibm', + 'icbc', + 'ice', + 'icu', + 'id', + 'ie', + 'ieee', + 'ifm', + 'ikano', + 'il', + 'im', + 'imamat', + 'imdb', + 'immo', + 'immobilien', + 'in', + 'inc', + 'industries', + 'infiniti', + 'info', + 'ing', + 'ink', + 'institute', + 'insurance', + 'insure', + 'int', + 'international', + 'intuit', + 'investments', + 'io', + 'ipiranga', + 'iq', + 'ir', + 'irish', + 'is', + 'ismaili', + 'ist', + 'istanbul', + 'it', + 'itau', + 'itv', + 'jaguar', + 'java', + 'jcb', + 'je', + 'jeep', + 'jetzt', + 'jewelry', + 'jio', + 'jll', + 'jm', + 'jmp', + 'jnj', + 'jo', + 'jobs', + 'joburg', + 'jot', + 'joy', + 'jp', + 'jpmorgan', + 'jprs', + 'juegos', + 'juniper', + 'kaufen', + 'kddi', + 'ke', + 'kerryhotels', + 'kerryproperties', + 'kfh', + 'kg', + 'kh', + 'ki', + 'kia', + 'kids', + 'kim', + 'kindle', + 'kitchen', + 'kiwi', + 'km', + 'kn', + 'koeln', + 'komatsu', + 'kosher', + 'kp', + 'kpmg', + 'kpn', + 'kr', + 'krd', + 'kred', + 'kuokgroup', + 'kw', + 'ky', + 'kyoto', + 'kz', + 'la', + 'lacaixa', + 'lamborghini', + 'lamer', + 'land', + 'landrover', + 'lanxess', + 'lasalle', + 'lat', + 'latino', + 'latrobe', + 'law', + 'lawyer', + 'lb', + 'lc', + 'lds', + 'lease', + 'leclerc', + 'lefrak', + 'legal', + 'lego', + 'lexus', + 'lgbt', + 'li', + 'lidl', + 'life', + 'lifeinsurance', + 'lifestyle', + 'lighting', + 'like', + 'lilly', + 'limited', + 'limo', + 'lincoln', + 'link', + 'live', + 'living', + 'lk', + 'llc', + 'llp', + 'loan', + 'loans', + 'locker', + 'locus', + 'lol', + 'london', + 'lotte', + 'lotto', + 'love', + 'lpl', + 'lplfinancial', + 'lr', + 'ls', + 'lt', + 'ltd', + 'ltda', + 'lu', + 'lundbeck', + 'luxe', + 'luxury', + 'lv', + 'ly', + 'ma', + 'madrid', + 'maif', + 'maison', + 'makeup', + 'man', + 'management', + 'mango', + 'map', + 'market', + 'marketing', + 'markets', + 'marriott', + 'marshalls', + 'mattel', + 'mba', + 'mc', + 'mckinsey', + 'md', + 'me', + 'med', + 'media', + 'meet', + 'melbourne', + 'meme', + 'memorial', + 'men', + 'menu', + 'merck', + 'merckmsd', + 'mg', + 'mh', + 'miami', + 'microsoft', + 'mil', + 'mini', + 'mint', + 'mit', + 'mitsubishi', + 'mk', + 'ml', + 'mlb', + 'mls', + 'mm', + 'mma', + 'mn', + 'mo', + 'mobi', + 'mobile', + 'moda', + 'moe', + 'moi', + 'mom', + 'monash', + 'money', + 'monster', + 'mormon', + 'mortgage', + 'moscow', + 'moto', + 'motorcycles', + 'mov', + 'movie', + 'mp', + 'mq', + 'mr', + 'ms', + 'msd', + 'mt', + 'mtn', + 'mtr', + 'mu', + 'museum', + 'music', + 'mv', + 'mw', + 'mx', + 'my', + 'mz', + 'na', + 'nab', + 'nagoya', + 'name', + 'navy', + 'nba', + 'nc', + 'ne', + 'nec', + 'net', + 'netbank', + 'netflix', + 'network', + 'neustar', + 'new', + 'news', + 'next', + 'nextdirect', + 'nexus', + 'nf', + 'nfl', + 'ng', + 'ngo', + 'nhk', + 'ni', + 'nico', + 'nike', + 'nikon', + 'ninja', + 'nissan', + 'nissay', + 'nl', + 'no', + 'nokia', + 'norton', + 'now', + 'nowruz', + 'nowtv', + 'np', + 'nr', + 'nra', + 'nrw', + 'ntt', + 'nu', + 'nyc', + 'nz', + 'obi', + 'observer', + 'office', + 'okinawa', + 'olayan', + 'olayangroup', + 'ollo', + 'om', + 'omega', + 'one', + 'ong', + 'onl', + 'online', + 'ooo', + 'open', + 'oracle', + 'orange', + 'org', + 'organic', + 'origins', + 'osaka', + 'otsuka', + 'ott', + 'ovh', + 'pa', + 'page', + 'panasonic', + 'paris', + 'pars', + 'partners', + 'parts', + 'party', + 'pay', + 'pccw', + 'pe', + 'pet', + 'pf', + 'pfizer', + 'pg', + 'ph', + 'pharmacy', + 'phd', + 'philips', + 'phone', + 'photo', + 'photography', + 'photos', + 'physio', + 'pics', + 'pictet', + 'pictures', + 'pid', + 'pin', + 'ping', + 'pink', + 'pioneer', + 'pizza', + 'pk', + 'pl', + 'place', + 'play', + 'playstation', + 'plumbing', + 'plus', + 'pm', + 'pn', + 'pnc', + 'pohl', + 'poker', + 'politie', + 'porn', + 'post', + 'pr', + 'praxi', + 'press', + 'prime', + 'pro', + 'prod', + 'productions', + 'prof', + 'progressive', + 'promo', + 'properties', + 'property', + 'protection', + 'pru', + 'prudential', + 'ps', + 'pt', + 'pub', + 'pw', + 'pwc', + 'py', + 'qa', + 'qpon', + 'quebec', + 'quest', + 'racing', + 'radio', + 're', + 'read', + 'realestate', + 'realtor', + 'realty', + 'recipes', + 'red', + 'redumbrella', + 'rehab', + 'reise', + 'reisen', + 'reit', + 'reliance', + 'ren', + 'rent', + 'rentals', + 'repair', + 'report', + 'republican', + 'rest', + 'restaurant', + 'review', + 'reviews', + 'rexroth', + 'rich', + 'richardli', + 'ricoh', + 'ril', + 'rio', + 'rip', + 'ro', + 'rocks', + 'rodeo', + 'rogers', + 'room', + 'rs', + 'rsvp', + 'ru', + 'rugby', + 'ruhr', + 'run', + 'rw', + 'rwe', + 'ryukyu', + 'sa', + 'saarland', + 'safe', + 'safety', + 'sakura', + 'sale', + 'salon', + 'samsclub', + 'samsung', + 'sandvik', + 'sandvikcoromant', + 'sanofi', + 'sap', + 'sarl', + 'sas', + 'save', + 'saxo', + 'sb', + 'sbi', + 'sbs', + 'sc', + 'scb', + 'schaeffler', + 'schmidt', + 'scholarships', + 'school', + 'schule', + 'schwarz', + 'science', + 'scot', + 'sd', + 'se', + 'search', + 'seat', + 'secure', + 'security', + 'seek', + 'select', + 'sener', + 'services', + 'seven', + 'sew', + 'sex', + 'sexy', + 'sfr', + 'sg', + 'sh', + 'shangrila', + 'sharp', + 'shell', + 'shia', + 'shiksha', + 'shoes', + 'shop', + 'shopping', + 'shouji', + 'show', + 'si', + 'silk', + 'sina', + 'singles', + 'site', + 'sj', + 'sk', + 'ski', + 'skin', + 'sky', + 'skype', + 'sl', + 'sling', + 'sm', + 'smart', + 'smile', + 'sn', + 'sncf', + 'so', + 'soccer', + 'social', + 'softbank', + 'software', + 'sohu', + 'solar', + 'solutions', + 'song', + 'sony', + 'soy', + 'spa', + 'space', + 'sport', + 'spot', + 'sr', + 'srl', + 'ss', + 'st', + 'stada', + 'staples', + 'star', + 'statebank', + 'statefarm', + 'stc', + 'stcgroup', + 'stockholm', + 'storage', + 'store', + 'stream', + 'studio', + 'study', + 'style', + 'su', + 'sucks', + 'supplies', + 'supply', + 'support', + 'surf', + 'surgery', + 'suzuki', + 'sv', + 'swatch', + 'swiss', + 'sx', + 'sy', + 'sydney', + 'systems', + 'sz', + 'tab', + 'taipei', + 'talk', + 'taobao', + 'target', + 'tatamotors', + 'tatar', + 'tattoo', + 'tax', + 'taxi', + 'tc', + 'tci', + 'td', + 'tdk', + 'team', + 'tech', + 'technology', + 'tel', + 'temasek', + 'tennis', + 'teva', + 'tf', + 'tg', + 'th', + 'thd', + 'theater', + 'theatre', + 'tiaa', + 'tickets', + 'tienda', + 'tips', + 'tires', + 'tirol', + 'tj', + 'tjmaxx', + 'tjx', + 'tk', + 'tkmaxx', + 'tl', + 'tm', + 'tmall', + 'tn', + 'to', + 'today', + 'tokyo', + 'tools', + 'top', + 'toray', + 'toshiba', + 'total', + 'tours', + 'town', + 'toyota', + 'toys', + 'tr', + 'trade', + 'trading', + 'training', + 'travel', + 'travelers', + 'travelersinsurance', + 'trust', + 'trv', + 'tt', + 'tube', + 'tui', + 'tunes', + 'tushu', + 'tv', + 'tvs', + 'tw', + 'tz', + 'ua', + 'ubank', + 'ubs', + 'ug', + 'uk', + 'unicom', + 'university', + 'uno', + 'uol', + 'ups', + 'us', + 'uy', + 'uz', + 'va', + 'vacations', + 'vana', + 'vanguard', + 'vc', + 've', + 'vegas', + 'ventures', + 'verisign', + 'versicherung', + 'vet', + 'vg', + 'vi', + 'viajes', + 'video', + 'vig', + 'viking', + 'villas', + 'vin', + 'vip', + 'virgin', + 'visa', + 'vision', + 'viva', + 'vivo', + 'vlaanderen', + 'vn', + 'vodka', + 'volvo', + 'vote', + 'voting', + 'voto', + 'voyage', + 'vu', + 'wales', + 'walmart', + 'walter', + 'wang', + 'wanggou', + 'watch', + 'watches', + 'weather', + 'weatherchannel', + 'web', + 'webcam', + 'weber', + 'website', + 'wed', + 'wedding', + 'weibo', + 'weir', + 'wf', + 'whoswho', + 'wien', + 'wiki', + 'williamhill', + 'win', + 'windows', + 'wine', + 'winners', + 'wme', + 'woodside', + 'work', + 'works', + 'world', + 'wow', + 'ws', + 'wtc', + 'wtf', + 'xbox', + 'xerox', + 'xihuan', + 'xin', + 'xn--11b4c3d', + 'xn--1ck2e1b', + 'xn--1qqw23a', + 'xn--2scrj9c', + 'xn--30rr7y', + 'xn--3bst00m', + 'xn--3ds443g', + 'xn--3e0b707e', + 'xn--3hcrj9c', + 'xn--3pxu8k', + 'xn--42c2d9a', + 'xn--45br5cyl', + 'xn--45brj9c', + 'xn--45q11c', + 'xn--4dbrk0ce', + 'xn--4gbrim', + 'xn--54b7fta0cc', + 'xn--55qw42g', + 'xn--55qx5d', + 'xn--5su34j936bgsg', + 'xn--5tzm5g', + 'xn--6frz82g', + 'xn--6qq986b3xl', + 'xn--80adxhks', + 'xn--80ao21a', + 'xn--80aqecdr1a', + 'xn--80asehdb', + 'xn--80aswg', + 'xn--8y0a063a', + 'xn--90a3ac', + 'xn--90ae', + 'xn--90ais', + 'xn--9dbq2a', + 'xn--9et52u', + 'xn--9krt00a', + 'xn--b4w605ferd', + 'xn--bck1b9a5dre4c', + 'xn--c1avg', + 'xn--c2br7g', + 'xn--cck2b3b', + 'xn--cckwcxetd', + 'xn--cg4bki', + 'xn--clchc0ea0b2g2a9gcd', + 'xn--czr694b', + 'xn--czrs0t', + 'xn--czru2d', + 'xn--d1acj3b', + 'xn--d1alf', + 'xn--e1a4c', + 'xn--eckvdtc9d', + 'xn--efvy88h', + 'xn--fct429k', + 'xn--fhbei', + 'xn--fiq228c5hs', + 'xn--fiq64b', + 'xn--fiqs8s', + 'xn--fiqz9s', + 'xn--fjq720a', + 'xn--flw351e', + 'xn--fpcrj9c3d', + 'xn--fzc2c9e2c', + 'xn--fzys8d69uvgm', + 'xn--g2xx48c', + 'xn--gckr3f0f', + 'xn--gecrj9c', + 'xn--gk3at1e', + 'xn--h2breg3eve', + 'xn--h2brj9c', + 'xn--h2brj9c8c', + 'xn--hxt814e', + 'xn--i1b6b1a6a2e', + 'xn--imr513n', + 'xn--io0a7i', + 'xn--j1aef', + 'xn--j1amh', + 'xn--j6w193g', + 'xn--jlq480n2rg', + 'xn--jvr189m', + 'xn--kcrx77d1x4a', + 'xn--kprw13d', + 'xn--kpry57d', + 'xn--kput3i', + 'xn--l1acc', + 'xn--lgbbat1ad8j', + 'xn--mgb9awbf', + 'xn--mgba3a3ejt', + 'xn--mgba3a4f16a', + 'xn--mgba7c0bbn0a', + 'xn--mgbaam7a8h', + 'xn--mgbab2bd', + 'xn--mgbah1a3hjkrd', + 'xn--mgbai9azgqp6j', + 'xn--mgbayh7gpa', + 'xn--mgbbh1a', + 'xn--mgbbh1a71e', + 'xn--mgbc0a9azcg', + 'xn--mgbca7dzdo', + 'xn--mgbcpq6gpa1a', + 'xn--mgberp4a5d4ar', + 'xn--mgbgu82a', + 'xn--mgbi4ecexp', + 'xn--mgbpl2fh', + 'xn--mgbt3dhd', + 'xn--mgbtx2b', + 'xn--mgbx4cd0ab', + 'xn--mix891f', + 'xn--mk1bu44c', + 'xn--mxtq1m', + 'xn--ngbc5azd', + 'xn--ngbe9e0a', + 'xn--ngbrx', + 'xn--node', + 'xn--nqv7f', + 'xn--nqv7fs00ema', + 'xn--nyqy26a', + 'xn--o3cw4h', + 'xn--ogbpf8fl', + 'xn--otu796d', + 'xn--p1acf', + 'xn--p1ai', + 'xn--pgbs0dh', + 'xn--pssy2u', + 'xn--q7ce6a', + 'xn--q9jyb4c', + 'xn--qcka1pmc', + 'xn--qxa6a', + 'xn--qxam', + 'xn--rhqv96g', + 'xn--rovu88b', + 'xn--rvc1e0am3e', + 'xn--s9brj9c', + 'xn--ses554g', + 'xn--t60b56a', + 'xn--tckwe', + 'xn--tiq49xqyj', + 'xn--unup4y', + 'xn--vermgensberater-ctb', + 'xn--vermgensberatung-pwb', + 'xn--vhquv', + 'xn--vuq861b', + 'xn--w4r85el8fhu5dnra', + 'xn--w4rs40l', + 'xn--wgbh1c', + 'xn--wgbl6a', + 'xn--xhq521b', + 'xn--xkc2al3hye2a', + 'xn--xkc2dl3a5ee0h', + 'xn--y9a3aq', + 'xn--yfro4i67o', + 'xn--ygbi2ammx', + 'xn--zfr164b', + 'xxx', + 'xyz', + 'yachts', + 'yahoo', + 'yamaxun', + 'yandex', + 'ye', + 'yodobashi', + 'yoga', + 'yokohama', + 'you', + 'youtube', + 'yt', + 'yun', + 'za', + 'zappos', + 'zara', + 'zero', + 'zip', + 'zm', + 'zone', + 'zuerich', + 'zw', +]; diff --git a/apps/desktop/extensions/ai-sidebar/tlds.js b/apps/desktop/extensions/ai-sidebar/tlds.js new file mode 100644 index 0000000..c259153 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/tlds.js @@ -0,0 +1,104 @@ +// Which namespace an ending belongs to. +// +// Moshpit resolution used to infer "clearnet has no answer for this name" from +// a DNS error (ERR_NAME_NOT_RESOLVED). That inference is only sound on a +// resolver that reports failure honestly, and many do not: NXDOMAIN hijacking +// resolvers answer EVERY nonexistent name with a wildcard ad host. On such a +// connection `blue.eggs` resolves, the error never fires, and every Moshpit +// name lands on the hijacker's page — indistinguishable from the namespace not +// working, and unfixable from inside the browser as long as DNS is the signal. +// +// The ending is a better signal, and it is one we hold locally: clearnet can +// only ever answer for an ending that actually exists on the real internet. +// `.eggs` is not in IANA's list and never will be by accident, so a resolver +// that answers for `blue.eggs` is lying no matter what it returns. That makes +// the decision independent of the network the user happens to be on. +import { IANA_TLDS, IANA_TLD_VERSION } from './tld-data.js'; + +export { IANA_TLD_VERSION }; + +const ICANN = new Set(IANA_TLDS); + +/** + * Endings that are neither ICANN's nor Moshpit's. + * + * These resolve outside ordinary DNS, so they fail the "is it in IANA's list" + * test and would otherwise be treated as Moshpit names. Each one would break + * something real: + * + * onion — the big one. A v3 address is 56 alphanumeric characters plus + * `.onion`: exactly two labels, letters and digits only, so it satisfies + * parseRegistryName and would be sent to the registry before every Tor + * navigation. The pit's hosts deliberately bypass the SOCKS proxy, so that + * lookup would leave over clearnet carrying the onion address being + * visited — a deanonymization leak, not merely a wasted request. + * + * local / localhost / test / invalid / example — reserved by RFC 6761 for + * mDNS, loopback, testing and documentation. + * + * internal / home / lan / corp / intranet / alt — the private-use endings + * people actually put on home routers and office networks (`.internal` and + * `.alt` are the standardized ones; the rest are long-standing practice). + * + * A Moshpit ending will never be one of these, because the registry cannot + * hand out an ending that resolvers already treat as special. + */ +export const RESERVED_TLDS = new Set([ + 'onion', + 'local', 'localhost', 'test', 'invalid', 'example', + 'internal', 'alt', 'home', 'lan', 'corp', 'intranet', +]); + +/** The ending of a hostname, lowercased and de-rooted. '' when there isn't one. */ +export function tldOf(hostname) { + const host = String(hostname || '').trim().toLowerCase().replace(/\.$/, ''); + if (!host || host.includes(':')) return ''; + const i = host.lastIndexOf('.'); + return i === -1 ? '' : host.slice(i + 1); +} + +/** Does this ending exist on the real internet? */ +export function isIcannTld(tld) { + return ICANN.has(String(tld || '').trim().toLowerCase()); +} + +/** Is this ending reserved for something that is neither clearnet nor Moshpit? */ +export function isReservedTld(tld) { + return RESERVED_TLDS.has(String(tld || '').trim().toLowerCase()); +} + +/** + * Could clearnet legitimately answer for this hostname? + * + * True for a real ending, and — deliberately — true for a reserved one too: + * `.onion` and `.local` are answered by something other than the Moshpit + * registry, so as far as this policy is concerned they are already spoken for. + * The one case that returns false is an ending nobody but Moshpit could own. + */ +export function clearnetCanAnswer(hostname) { + const tld = tldOf(hostname); + if (!tld) return true; // no ending at all — not ours to redirect + return isIcannTld(tld) || isReservedTld(tld); +} + +/** + * Is this hostname in the part of the namespace only Moshpit can own? + * + * The caller still has to run parseRegistryName: this answers "is the ending + * Moshpit's", not "is the whole hostname a well-formed Moshpit name". + */ +export function isMoshpitOnlyNamespace(hostname) { + return !clearnetCanAnswer(hostname); +} + +/** + * Is this hostname answered by something that is neither clearnet nor Moshpit? + * + * Callers use this to drop a navigation before it reaches the registry at all, + * in EITHER mode. For `.onion` that is not an optimization: the registry hosts + * bypass the SOCKS proxy, so a lookup here would carry the onion address out + * over clearnet. + */ +export function isReservedNamespace(hostname) { + return isReservedTld(tldOf(hostname)); +} diff --git a/apps/desktop/extensions/ai-sidebar/tlds.test.js b/apps/desktop/extensions/ai-sidebar/tlds.test.js new file mode 100644 index 0000000..a2b09f2 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/tlds.test.js @@ -0,0 +1,116 @@ +// Which namespace an ending belongs to. +// +// The interesting cases are the ones that used to be decided by DNS: a +// hijacking resolver answers for every name, so anything that asked "did DNS +// fail?" got the wrong answer on those connections. These tests ask the ending +// instead, which is the same answer on every network. +import { describe, expect, it } from 'vitest'; + +import { + IANA_TLD_VERSION, + RESERVED_TLDS, + clearnetCanAnswer, + isIcannTld, + isMoshpitOnlyNamespace, + isReservedNamespace, + isReservedTld, + tldOf, +} from './tlds.js'; +import { IANA_TLDS } from './tld-data.js'; + +describe('the generated IANA list', () => { + it('is the real list, not a truncated one', () => { + expect(IANA_TLDS.length).toBeGreaterThan(1000); + expect(IANA_TLD_VERSION).toMatch(/^\d{10}$/); + }); + + it('is lowercase, sorted, and free of duplicates', () => { + expect(IANA_TLDS).toEqual([...IANA_TLDS].map((t) => t.toLowerCase())); + expect(IANA_TLDS).toEqual([...IANA_TLDS].sort()); + expect(new Set(IANA_TLDS).size).toBe(IANA_TLDS.length); + }); + + it('carries the endings the world actually uses', () => { + for (const t of ['com', 'org', 'net', 'io', 'dev', 'sh', 'uk', 'de', 'jp']) { + expect(isIcannTld(t), t).toBe(true); + } + }); + + it('carries internationalized endings too', () => { + expect(IANA_TLDS.some((t) => t.startsWith('xn--'))).toBe(true); + }); + + it('does not contain the Moshpit endings — the whole fix depends on it', () => { + for (const t of ['eggs', 'oranges', 'moshpit']) { + expect(isIcannTld(t), t).toBe(false); + } + }); +}); + +describe('tldOf', () => { + it('takes the last label, case- and root-insensitively', () => { + expect(tldOf('blue.eggs')).toBe('eggs'); + expect(tldOf('A.EGGS.')).toBe('eggs'); + expect(tldOf('deep.sub.example.com')).toBe('com'); + }); + + it('has nothing to return for a hostname with no ending', () => { + for (const h of ['', 'localhost', 'eggs', null, undefined]) { + expect(tldOf(h), String(h)).toBe(''); + } + }); + + it('refuses a host:port rather than reading the port as an ending', () => { + expect(tldOf('blue.eggs:8080')).toBe(''); + }); +}); + +describe('clearnet vs Moshpit territory', () => { + it('puts a real ending in clearnet, where the registry is never consulted', () => { + for (const h of ['google.com', 'a.org', 'x.dev']) { + expect(clearnetCanAnswer(h), h).toBe(true); + expect(isMoshpitOnlyNamespace(h), h).toBe(false); + } + }); + + it('puts an ending nobody else could own in Moshpit — regardless of DNS', () => { + // This is the hijack case: the resolver answers, and it is still not + // clearnet's name to answer for. + for (const h of ['blue.eggs', 'california.oranges', 'mosh.eggs']) { + expect(clearnetCanAnswer(h), h).toBe(false); + expect(isMoshpitOnlyNamespace(h), h).toBe(true); + } + }); + + it('never claims a hostname with no ending', () => { + for (const h of ['localhost', '', 'eggs']) { + expect(isMoshpitOnlyNamespace(h), h).toBe(false); + } + }); +}); + +describe('reserved endings stay out of the registry entirely', () => { + it('treats a real v3 onion address as Tor’s, not Moshpit’s', () => { + const onion = 'duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion'; + expect(isReservedNamespace(onion)).toBe(true); + // The bug this closes: it is two alphanumeric labels, so the shape test + // alone would have sent it to the registry over clearnet. + expect(isMoshpitOnlyNamespace(onion)).toBe(false); + }); + + it('leaves local and private-network endings alone', () => { + for (const t of ['local', 'localhost', 'test', 'invalid', 'example', + 'internal', 'alt', 'home', 'lan', 'corp', 'intranet']) { + expect(isReservedTld(t), t).toBe(true); + expect(isMoshpitOnlyNamespace(`host.${t}`), t).toBe(false); + } + }); + + it('does not reserve an ending IANA already delegates', () => { + // A reserved entry that IANA later delegates would quietly shadow a real + // TLD, so the two sets must stay disjoint. + for (const t of RESERVED_TLDS) { + expect(isIcannTld(t), t).toBe(false); + } + }); +}); diff --git a/scripts/update-tlds.mjs b/scripts/update-tlds.mjs new file mode 100644 index 0000000..b44192f --- /dev/null +++ b/scripts/update-tlds.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// Regenerates the ICANN TLD list the extension uses to tell "this ending exists +// on the real internet" from "this ending only exists in Moshpit". +// +// node scripts/update-tlds.mjs +// +// Source of truth is IANA's own list. It changes rarely (a handful of new +// endings a year, and the odd retirement), so this is a manual chore rather +// than a build step — a network fetch in the build would make a release depend +// on data.iana.org being up. +// +// tlds.test.js asserts the generated file still parses and still holds the +// endings we depend on; it deliberately does NOT re-fetch, so the suite stays +// offline and deterministic. +import { writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SOURCE = 'https://data.iana.org/TLD/tlds-alpha-by-domain.txt'; +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const out = join(root, 'apps/desktop/extensions/ai-sidebar/tld-data.js'); + +const res = await fetch(SOURCE); +if (!res.ok) { + console.error(`fetch ${SOURCE} failed: ${res.status}`); + process.exit(1); +} +const text = await res.text(); + +// The first line is a comment carrying IANA's own version stamp. Keep it: it is +// how you tell a stale list from a current one without diffing 1400 strings. +const [header] = text.split('\n'); +const version = /Version (\d+)/.exec(header)?.[1] ?? 'unknown'; + +const tlds = text + .split('\n') + .map((l) => l.trim().toLowerCase()) + .filter((l) => l && !l.startsWith('#')) + .sort(); + +if (tlds.length < 1000) { + console.error(`refusing to write ${tlds.length} TLDs — that is not the real list`); + process.exit(1); +} + +const body = `// GENERATED by scripts/update-tlds.mjs — do not edit by hand. +// IANA tlds-alpha-by-domain.txt, version ${version}. +// +// Every ending that exists on the real internet. Used to decide whether clearnet +// could ever legitimately answer for a hostname; see tlds.js for the policy. +export const IANA_TLD_VERSION = '${version}'; + +export const IANA_TLDS = ${JSON.stringify(tlds, null, 0).replace(/","/g, "','").replace(/^\["/, "[\n '").replace(/"\]$/, "',\n]").replace(/','/g, "',\n '")}; +`; + +writeFileSync(out, body); +console.log(`wrote ${tlds.length} TLDs (IANA version ${version}) to ${out.replace(root + '/', '')}`);