Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 43 additions & 23 deletions apps/desktop/extensions/ai-sidebar/background.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 '';
}
Expand All @@ -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);
});
78 changes: 78 additions & 0 deletions apps/desktop/extensions/ai-sidebar/moshpit-routing.js
Original file line number Diff line number Diff line change
@@ -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' };
}
}
101 changes: 101 additions & 0 deletions apps/desktop/extensions/ai-sidebar/moshpit-routing.test.js
Original file line number Diff line number Diff line change
@@ -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');
}
}
}
});
});
Loading
Loading