From 8dc6556303baeac8ce633a5cd1c3aba089c7cc05 Mon Sep 17 00:00:00 2001 From: Anthony Date: Sat, 1 Aug 2026 04:43:20 +0000 Subject: [PATCH] feat(parking): host-parked Moshpit names go to the Pit too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #87 moved /parking?name= to the Pit, but that is not how a parked name actually arrives. The DNS resolver points an unpointed name at this app (DEFAULT_PARKING_HOST), so `curl scrambled.eggs` lands here as a Host header and still rendered the "IS COMING" tenant card. A name resolved from the Host now redirects to /n/, while an ordinary parked clearnet domain renders exactly as before. Which is which is asked of the Pit per name, not by pulling its ending list: that list is capped at 200 and `.eggs` already falls outside it, so matching against it would quietly answer "no" for real names. resolve's `registered` flag has no such ceiling. 307, not 308. The owner can point the name at a real target at any moment; a permanent redirect cached in a browser would keep sending them here long after this app stopped being the answer. /parking keeps its 308 — that URL will always mean "go to the pit". Fails closed: an unreachable Pit answers "not ours", so a registry outage leaves every parked domain rendering as it does today rather than bouncing the whole internet at /n/. Answers are memoised per name behind a 5-minute TTL, with a bounded map because Host is attacker-controlled. Co-Authored-By: Claude Opus 5 (1M context) --- app/page.tsx | 10 ++++ lib/moshpit-tlds.ts | 63 +++++++++++++++++++++ lib/parking.ts | 5 ++ tests/moshpit-tlds.test.mjs | 110 ++++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 lib/moshpit-tlds.ts create mode 100644 tests/moshpit-tlds.test.mjs diff --git a/app/page.tsx b/app/page.tsx index bd96d14..0759c5f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,9 @@ import type { Metadata } from "next"; import { headers } from "next/headers"; +import { redirect } from "next/navigation"; import { configFor, safeDomain } from "@/lib/config"; +import { isMoshpitName } from "@/lib/moshpit-tlds"; +import { pitNameUrl } from "@/lib/parking"; import { getTenantConfig } from "@/lib/db"; import Landing from "@/components/Landing"; import Tenant from "@/components/Tenant"; @@ -69,6 +72,13 @@ export default async function Page({ const dn = safeDomain(sp.dn) || (await hostTenantDn()); if (!dn) return ; + // A Moshpit name that DNS parked here belongs to the Pit, not to this app's + // parked-domain card. Temporary, not permanent: the owner can point the name + // at a real target at any moment, and a 308 cached in a browser would keep + // sending them here long after this app stopped being the answer. Ordinary + // parked clearnet domains do not match and render exactly as before. + if (await isMoshpitName(dn)) redirect(pitNameUrl(dn)); + // A paid/provisioned domain has a tenants row that overrides the defaults. const tenantOverride = await getTenantConfig(dn).catch(() => null); diff --git a/lib/moshpit-tlds.ts b/lib/moshpit-tlds.ts new file mode 100644 index 0000000..8b4479f --- /dev/null +++ b/lib/moshpit-tlds.ts @@ -0,0 +1,63 @@ +import { PIT_BASE_URL } from "./parking"; + +/** + * Is this host a name on the Moshpit network, or an ordinary parked domain? + * + * Asked of the Pit per name rather than by pulling its ending list: that list + * is capped (200 at the time of writing) and `.eggs` already falls outside it, + * so matching against it would quietly answer "no" for real names. `resolve` + * has no such ceiling — its `registered` flag is exactly "an ending the pit + * holds", which is the question here. + * + * This app keeps its own `moshpit_tlds` rows, but they are a cache of a + * registry that lives elsewhere; a name claimed minutes ago has to work. + */ + +/** Long enough not to hit the Pit per request, short enough for a new ending to show up. */ +const TTL_MS = 5 * 60_000; +const TIMEOUT_MS = 2_000; +/** Host is attacker-controlled, so the memo cannot be allowed to grow without end. */ +const MAX_ENTRIES = 2_000; + +const cache = new Map(); + +function remember(name: string, ours: boolean): void { + // Whole-map eviction rather than LRU bookkeeping: this is a small memo in + // front of a 5-minute TTL, and the cost of a cold start is one request. + if (cache.size >= MAX_ENTRIES) cache.clear(); + cache.set(name, { at: Date.now(), ours }); +} + +/** + * Fails closed: an unreachable Pit answers "no", so a registry outage leaves + * every parked domain rendering exactly as it does today rather than bouncing + * the whole internet at `/n/`. A previously cached answer beats that fallback. + */ +export async function isMoshpitName(dn: string): Promise { + const name = String(dn || "").toLowerCase(); + const parts = name.split("."); + // One label and one ending — the same shape the registry can hold. + if (parts.length !== 2 || !parts[0] || !parts[1]) return false; + + const hit = cache.get(name); + if (hit && Date.now() - hit.at < TTL_MS) return hit.ours; + + try { + const res = await fetch( + `${PIT_BASE_URL}/api/moshpit/resolve?name=${encodeURIComponent(name)}`, + { signal: AbortSignal.timeout(TIMEOUT_MS), cache: "no-store" }, + ); + if (!res.ok) return hit?.ours ?? false; + const json = (await res.json()) as { registered?: boolean }; + const ours = json?.registered === true; + remember(name, ours); + return ours; + } catch { + return hit?.ours ?? false; + } +} + +/** Test seam: drop the memoised answers. */ +export function resetMoshpitTldCache(): void { + cache.clear(); +} diff --git a/lib/parking.ts b/lib/parking.ts index 5d86f25..dbb8d0c 100644 --- a/lib/parking.ts +++ b/lib/parking.ts @@ -27,5 +27,10 @@ export function parkingTarget(sp: ParkingParams): string { // Nothing usable to look up — the Pit's front door beats a 404 for someone // who just typed a name at us. if (!name) return `${PIT_BASE_URL}/pit`; + return pitNameUrl(name); +} + +/** The Pit's page for a name. Callers pass something safeDomain() has cleared. */ +export function pitNameUrl(name: string): string { return `${PIT_BASE_URL}/n/${encodeURIComponent(name)}`; } diff --git a/tests/moshpit-tlds.test.mjs b/tests/moshpit-tlds.test.mjs new file mode 100644 index 0000000..69e6ee1 --- /dev/null +++ b/tests/moshpit-tlds.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isMoshpitName, resetMoshpitTldCache } from "../lib/moshpit-tlds.ts"; + +const realFetch = globalThis.fetch; + +/** Stand in for the Pit's resolve endpoint. */ +function pitSays(registeredEndings, { ok = true } = {}) { + const seen = []; + globalThis.fetch = async (url) => { + seen.push(String(url)); + const name = new URL(String(url)).searchParams.get("name") || ""; + const ending = name.split(".").pop(); + return { ok, json: async () => ({ name, registered: registeredEndings.includes(ending) }) }; + }; + return seen; +} + +function pitIsDown() { + globalThis.fetch = async () => { + throw new Error("registry unreachable"); + }; +} + +test.afterEach(() => { + globalThis.fetch = realFetch; + resetMoshpitTldCache(); +}); + +test("a name under a Moshpit ending is ours", async () => { + resetMoshpitTldCache(); + pitSays(["eggs", "chicken"]); + + assert.equal(await isMoshpitName("scrambled.eggs"), true); + assert.equal(await isMoshpitName("hawaiian.chicken"), true); +}); + +test("an ordinary parked domain is not ours, and keeps its tenant page", async () => { + resetMoshpitTldCache(); + pitSays(["eggs"]); + + // `.sh` is a real TLD nobody claimed in the pit — it must not be hijacked. + assert.equal(await isMoshpitName("moshcode.sh"), false); + assert.equal(await isMoshpitName("example.com"), false); +}); + +test("only one label and one ending counts, and it never calls out for the rest", async () => { + resetMoshpitTldCache(); + const seen = pitSays(["eggs"]); + + assert.equal(await isMoshpitName("a.b.eggs"), false); + assert.equal(await isMoshpitName("eggs"), false); + assert.equal(await isMoshpitName(""), false); + assert.equal(seen.length, 0, "a shape the registry cannot hold needs no request"); +}); + +test("the answer is memoised per name rather than fetched per request", async () => { + resetMoshpitTldCache(); + const seen = pitSays(["eggs"]); + + await isMoshpitName("scrambled.eggs"); + await isMoshpitName("scrambled.eggs"); + await isMoshpitName("scrambled.eggs"); + + assert.equal(seen.length, 1, "the Pit should be hit once per name"); +}); + +test("the name is normalized, so case is not a cache miss", async () => { + resetMoshpitTldCache(); + const seen = pitSays(["eggs"]); + + assert.equal(await isMoshpitName("Scrambled.EGGS"), true); + assert.equal(await isMoshpitName("scrambled.eggs"), true); + assert.equal(seen.length, 1); +}); + +test("an unreachable Pit fails closed rather than bouncing everything", async () => { + resetMoshpitTldCache(); + pitIsDown(); + + // The whole internet parked here must not start redirecting to /n/ because + // the registry blipped — today's behaviour is the safe answer. + assert.equal(await isMoshpitName("scrambled.eggs"), false); +}); + +test("a non-200 from the Pit is treated as unreachable", async () => { + resetMoshpitTldCache(); + pitSays(["eggs"], { ok: false }); + + assert.equal(await isMoshpitName("scrambled.eggs"), false); +}); + +test("a cached answer beats the fail-closed fallback when the Pit goes down", async () => { + resetMoshpitTldCache(); + pitSays(["eggs"]); + assert.equal(await isMoshpitName("scrambled.eggs"), true); + + pitIsDown(); + assert.equal(await isMoshpitName("scrambled.eggs"), true, "warm cache should hold"); +}); + +test("the lookup asks the Pit for the exact name", async () => { + resetMoshpitTldCache(); + const seen = pitSays(["eggs"]); + + await isMoshpitName("scrambled.eggs"); + + assert.match(seen[0], /\/api\/moshpit\/resolve\?name=scrambled\.eggs$/); +});