Skip to content

Commit d03f93a

Browse files
ralyodioclaude
andcommitted
fix(moshpit): decide the namespace by the ending, not by a DNS failure
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) <noreply@anthropic.com>
1 parent 4092358 commit d03f93a

7 files changed

Lines changed: 1946 additions & 23 deletions

File tree

apps/desktop/extensions/ai-sidebar/background.js

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { destinationFor, moshpitBypassHosts, moshpitConfig, parseRegistryName } from './moshpit.js';
1+
import { destinationFor, moshpitBypassHosts, moshpitConfig } from './moshpit.js';
2+
import { routeForDnsFailure, routeForNavigation, territoryOf } from './moshpit-routing.js';
23

34
// Open the AI side panel when the toolbar action is clicked.
45
chrome.sidePanel
@@ -305,18 +306,27 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
305306
// This is what makes the Moshpit settings on the options page actually do
306307
// something: until now they were written to storage and never read.
307308
//
308-
// Two hooks, because "does clearnet answer for this name?" is only knowable at
309-
// two different moments:
309+
// Which namespace a hostname belongs to is decided by its ENDING, not by
310+
// whether DNS failed. See tlds.js for why: a resolver that hijacks NXDOMAIN
311+
// answers for `blue.eggs` too, so "DNS failed" is a signal we do not reliably
312+
// get, and on those connections the whole namespace silently stopped working.
310313
//
311-
// onErrorOccurred — DNS came up empty (ERR_NAME_NOT_RESOLVED). This is the
312-
// backfill path, and the ONLY one active in the default 'clearnet' mode, so
313-
// someone who has never heard of Moshpit gets ordinary browsing plus a
314-
// rescued error page. Nothing that already works is touched.
314+
// So there are two territories, and a hostname is in exactly one:
315315
//
316-
// onBeforeNavigate — consulted ONLY in 'moshpit' mode, where a registered
317-
// name is meant to win even though clearnet has an answer. It costs a
318-
// registry round-trip before navigation, which is why the default mode
319-
// never goes near it.
316+
// An ending only Moshpit could own (`.eggs` — not IANA's, not reserved).
317+
// Clearnet cannot legitimately answer for it, so resolution runs in BOTH
318+
// modes and does not wait for a DNS error that may never come. This is the
319+
// path that a hijacking resolver used to swallow.
320+
//
321+
// A real or reserved ending (`.com`, `.onion`, `.local`). Ordinary browsing,
322+
// and the default mode never touches the registry for it — no round-trip,
323+
// no added latency, nothing on the wire. Only the opt-in 'moshpit' mode
324+
// consults the registry here, because only it lets a registered name
325+
// override a working clearnet domain, and that is what it costs.
326+
//
327+
// onErrorOccurred still backfills a real ending whose DNS genuinely failed —
328+
// that is an honest signal when we get it, and it is how a `.com` that nobody
329+
// registered can still fall through to Moshpit.
320330
//
321331
// No redirect loop: every destination we send a tab to (pit.moshcode.sh/n/…,
322332
// app.moshcode.sh/pit) has three labels, so parseRegistryName rejects it and
@@ -327,11 +337,13 @@ const DNS_FAILED = new Set([
327337
'net::ERR_NAME_RESOLUTION_FAILED',
328338
]);
329339

330-
function moshpitHostname(url) {
340+
// The hostname of a top-level http(s) navigation. Whether it is ours to touch
341+
// is routeForNavigation's call, not this one's.
342+
function navigationHostname(url) {
331343
try {
332344
const u = new URL(url);
333345
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
334-
return parseRegistryName(u.hostname) ? u.hostname : '';
346+
return u.hostname;
335347
} catch {
336348
return '';
337349
}
@@ -348,20 +360,28 @@ async function sendTabTo(tabId, url) {
348360
chrome.webNavigation?.onErrorOccurred.addListener(async (details) => {
349361
if (details.frameId !== 0) return; // top-level navigations only
350362
if (!DNS_FAILED.has(details.error)) return;
351-
const hostname = moshpitHostname(details.url);
352-
if (!hostname) return;
353-
const dest = await destinationFor(hostname, false);
363+
const hostname = navigationHostname(details.url);
364+
const route = routeForDnsFailure(hostname);
365+
if (!route.resolve) return;
366+
const dest = await destinationFor(hostname, route.clearnetResolves);
354367
if (dest) await sendTabTo(details.tabId, dest);
355368
});
356369

357370
chrome.webNavigation?.onBeforeNavigate.addListener(async (details) => {
358371
if (details.frameId !== 0) return;
359-
const hostname = moshpitHostname(details.url);
360-
if (!hostname) return;
361-
// The default mode must never pre-empt a working clearnet domain — bail out
362-
// before the registry is ever contacted.
363-
const { mode } = await moshpitConfig();
364-
if (mode !== 'moshpit') return;
365-
const dest = await destinationFor(hostname, true);
372+
const hostname = navigationHostname(details.url);
373+
374+
// The territory is decided from the hostname alone, so an ordinary navigation
375+
// to a real ending costs one Set lookup — no storage read, no registry call.
376+
// Only 'clearnet' has an answer that depends on the mode, so only it pays for
377+
// reading the mode.
378+
const territory = territoryOf(hostname);
379+
if (territory === 'none' || territory === 'reserved') return;
380+
const mode = territory === 'clearnet' ? (await moshpitConfig()).mode : 'clearnet';
381+
382+
const route = routeForNavigation(hostname, mode);
383+
if (!route.resolve) return;
384+
385+
const dest = await destinationFor(hostname, route.clearnetResolves);
366386
if (dest) await sendTabTo(details.tabId, dest);
367387
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Whether a navigation is Moshpit's business, and what to tell the policy.
2+
//
3+
// Split out of background.js so it can be tested without standing up a service
4+
// worker: background.js has top-level Tor and proxy work that has nothing to do
5+
// with name resolution. What is left there is the two listeners and this call.
6+
//
7+
// The decision this makes used to be made by DNS — see tlds.js for why that was
8+
// unsound on a resolver that hijacks NXDOMAIN.
9+
import { parseRegistryName } from './moshpit.js';
10+
import { isMoshpitOnlyNamespace, isReservedNamespace } from './tlds.js';
11+
12+
/**
13+
* Which territory a hostname is in. Total: every hostname is in exactly one.
14+
*
15+
* 'none' — not a Moshpit-shaped hostname at all (wrong label count, an
16+
* IP, a port, a dash). Never ours.
17+
* 'reserved' — `.onion`, `.local` and friends: answered by something that is
18+
* neither clearnet nor Moshpit, and must not reach the registry
19+
* in either mode.
20+
* 'moshpit' — an ending only Moshpit could own. Clearnet cannot answer for
21+
* it, whatever the resolver said.
22+
* 'clearnet' — a real ending. Ordinary browsing.
23+
*/
24+
export function territoryOf(hostname) {
25+
if (!hostname || !parseRegistryName(hostname)) return 'none';
26+
if (isReservedNamespace(hostname)) return 'reserved';
27+
if (isMoshpitOnlyNamespace(hostname)) return 'moshpit';
28+
return 'clearnet';
29+
}
30+
31+
/**
32+
* What to do with a navigation we are about to let through.
33+
*
34+
* Returns `{ resolve: false, why }` to leave the tab alone, or
35+
* `{ resolve: true, clearnetResolves, why }` to run the Moshpit policy — where
36+
* `clearnetResolves` is what we know about clearnet, not what DNS claimed.
37+
*/
38+
export function routeForNavigation(hostname, mode) {
39+
switch (territoryOf(hostname)) {
40+
case 'none':
41+
return { resolve: false, why: 'not a Moshpit-shaped hostname' };
42+
case 'reserved':
43+
// .onion above all: asking the registry would carry the address out over
44+
// clearnet, because the pit's hosts bypass the SOCKS proxy.
45+
return { resolve: false, why: 'reserved ending — answered by neither clearnet nor Moshpit' };
46+
case 'moshpit':
47+
// Clearnet cannot own this ending, so whatever DNS returned for it was
48+
// not an answer. Both modes resolve it; neither waits for a DNS error.
49+
return { resolve: true, clearnetResolves: false, why: 'ending only Moshpit can own' };
50+
default:
51+
if (mode !== 'moshpit') {
52+
// The default mode leaves a real ending to clearnet, and — the point of
53+
// returning here — never spends a registry round-trip on it.
54+
return { resolve: false, why: 'real ending, and Moshpit is set to backfill only' };
55+
}
56+
return { resolve: true, clearnetResolves: true, why: 'moshpit mode may override a real ending' };
57+
}
58+
}
59+
60+
/**
61+
* The same question for a navigation that already failed DNS.
62+
*
63+
* Only a real ending is actionable here: a Moshpit-only ending was handled
64+
* before the request went out, and running again on the error would race that
65+
* redirect.
66+
*/
67+
export function routeForDnsFailure(hostname) {
68+
switch (territoryOf(hostname)) {
69+
case 'clearnet':
70+
return { resolve: true, clearnetResolves: false, why: 'real ending, and clearnet genuinely had no answer' };
71+
case 'moshpit':
72+
return { resolve: false, why: 'already handled before the request went out' };
73+
case 'reserved':
74+
return { resolve: false, why: 'reserved ending — answered by neither clearnet nor Moshpit' };
75+
default:
76+
return { resolve: false, why: 'not a Moshpit-shaped hostname' };
77+
}
78+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// What each navigation is routed to, and — as much as it matters — what it
2+
// costs. These are the regressions the DNS-based version could not express.
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { routeForDnsFailure, routeForNavigation, territoryOf } from './moshpit-routing.js';
6+
7+
const ONION = 'duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion';
8+
9+
describe('territoryOf', () => {
10+
it('sorts every hostname into exactly one territory', () => {
11+
expect(territoryOf('blue.eggs')).toBe('moshpit');
12+
expect(territoryOf('google.com')).toBe('clearnet');
13+
expect(territoryOf(ONION)).toBe('reserved');
14+
expect(territoryOf('printer.local')).toBe('reserved');
15+
expect(territoryOf('a.b.c')).toBe('none'); // three labels
16+
expect(territoryOf('1.2.3.4')).toBe('none'); // an IP
17+
expect(territoryOf('localhost')).toBe('none'); // no ending
18+
expect(territoryOf('my-site.eggs')).toBe('none'); // a dash — the registry rejects it
19+
expect(territoryOf('')).toBe('none');
20+
});
21+
});
22+
23+
describe('a Moshpit name resolves even when the resolver lies about it', () => {
24+
// The bug: an NXDOMAIN-hijacking resolver answers for blue.eggs, so
25+
// ERR_NAME_NOT_RESOLVED never fires and the old code never ran at all.
26+
it('resolves in the DEFAULT mode, without waiting for a DNS error', () => {
27+
const r = routeForNavigation('blue.eggs', 'clearnet');
28+
expect(r.resolve).toBe(true);
29+
// The whole point: we assert clearnet has no answer regardless of DNS.
30+
expect(r.clearnetResolves).toBe(false);
31+
});
32+
33+
it('resolves in moshpit mode the same way', () => {
34+
expect(routeForNavigation('blue.eggs', 'moshpit')).toMatchObject({
35+
resolve: true, clearnetResolves: false,
36+
});
37+
});
38+
39+
it('does not run again on the DNS error, which would race the redirect', () => {
40+
expect(routeForDnsFailure('blue.eggs').resolve).toBe(false);
41+
});
42+
});
43+
44+
describe('ordinary browsing costs nothing', () => {
45+
it('leaves a real ending alone in the default mode', () => {
46+
const r = routeForNavigation('google.com', 'clearnet');
47+
expect(r.resolve).toBe(false);
48+
expect(r.why).toMatch(/backfill only/);
49+
});
50+
51+
it('still lets moshpit mode override a real ending — that is what it is for', () => {
52+
expect(routeForNavigation('google.com', 'moshpit')).toMatchObject({
53+
resolve: true, clearnetResolves: true,
54+
});
55+
});
56+
57+
it('backfills a real ending whose DNS honestly failed', () => {
58+
expect(routeForDnsFailure('nothing.com')).toMatchObject({
59+
resolve: true, clearnetResolves: false,
60+
});
61+
});
62+
});
63+
64+
describe('a .onion address never reaches the registry', () => {
65+
// It is two alphanumeric labels, so the shape test alone accepts it. The
66+
// registry hosts bypass the SOCKS proxy, so a lookup would carry the onion
67+
// address out over clearnet.
68+
it('is left alone in both modes', () => {
69+
for (const mode of ['clearnet', 'moshpit']) {
70+
const r = routeForNavigation(ONION, mode);
71+
expect(r.resolve, mode).toBe(false);
72+
expect(r.why, mode).toMatch(/reserved/);
73+
}
74+
});
75+
76+
it('is left alone on a DNS failure too', () => {
77+
expect(routeForDnsFailure(ONION).resolve).toBe(false);
78+
});
79+
80+
it('applies to the other reserved endings as well', () => {
81+
for (const h of ['printer.local', 'box.lan', 'app.internal', 'foo.test']) {
82+
expect(routeForNavigation(h, 'moshpit').resolve, h).toBe(false);
83+
}
84+
});
85+
});
86+
87+
describe('the decision is total', () => {
88+
it('returns a usable shape for every combination', () => {
89+
const hosts = ['blue.eggs', 'google.com', ONION, 'a.b.c', '', 'localhost', '1.2.3.4'];
90+
for (const h of hosts) {
91+
for (const mode of ['clearnet', 'moshpit', undefined]) {
92+
for (const fn of [routeForNavigation, routeForDnsFailure]) {
93+
const r = fn(h, mode);
94+
expect(typeof r.resolve, `${fn.name} ${h} ${mode}`).toBe('boolean');
95+
expect(typeof r.why, `${fn.name} ${h} ${mode}`).toBe('string');
96+
if (r.resolve) expect(typeof r.clearnetResolves).toBe('boolean');
97+
}
98+
}
99+
}
100+
});
101+
});

0 commit comments

Comments
 (0)