From b15914e267f6275292965de397bb1c4199bfc22f Mon Sep 17 00:00:00 2001 From: anthony Date: Fri, 31 Jul 2026 11:11:24 +0000 Subject: [PATCH] pit: serve a Moshpit name at /n/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every resolver and the TronBrowser extension already pointed here. Nothing answered — gatewayUrlFor sent tabs to pit.moshcode.sh/n/ and got the app's 404 page, so a resolved name still went nowhere. A name with a target is fetched and returned. A name without one gets a directory rather than a dead end: the live sites under that ending, what else is claimed there, and which other endings are worth a look. A parked name is the commonest thing anyone lands on, so it is the page that has to earn its keep. "Related" without inventing a taxonomy: an alias is an operator explicitly saying two endings belong together, and shared ownership is the next best signal. Everything after that is just the rest of the namespace. The security problem here is the target, which whoever holds the name typed in and which this server then fetches from inside its own network. Pointed at 169.254.169.254 that is cloud credentials; pointed at 10.x it is every internal service the box can reach, returned to the person who asked. So: - reserved ranges are refused — loopback, private, link-local, CGNAT, multicast, benchmarking, documentation, and IPv4-mapped IPv6, which would otherwise skip every v4 rule - a hostname is judged on what it RESOLVES to, not how it looks, and one public A record does not make the others safe - cookies and authorization are never forwarded; the visitor's session on app.moshcode.sh has nothing to do with the origin, and passing it would hand a name's owner their visitors' credentials - Set-Cookie does not come back, or a name's owner could set cookies on the domain where accounts live - responses are sandboxed by CSP, bounded at 5MB and 10s 11 tests, every one of them an SSRF case including the /12 boundary that is the classic off-by-one in a hand-written deny list. Exercised live: a parked name renders the directory, a bad target returns "link-local — cloud metadata lives here" rather than the metadata. 274 across the pwa suite. Co-Authored-By: Claude Opus 5 (1M context) --- apps/pwa/src/lib/moshpit-gateway.mjs | 155 +++++++++++++++++++++++++ apps/pwa/src/routes/moshpit.mjs | 153 ++++++++++++++++++++++++ apps/pwa/test/moshpit-gateway.test.mjs | 124 ++++++++++++++++++++ 3 files changed, 432 insertions(+) create mode 100644 apps/pwa/src/lib/moshpit-gateway.mjs create mode 100644 apps/pwa/test/moshpit-gateway.test.mjs diff --git a/apps/pwa/src/lib/moshpit-gateway.mjs b/apps/pwa/src/lib/moshpit-gateway.mjs new file mode 100644 index 0000000..882d2cb --- /dev/null +++ b/apps/pwa/src/lib/moshpit-gateway.mjs @@ -0,0 +1,155 @@ +// Serving a Moshpit name over the clearnet. +// +// A name resolves to a `target` its owner typed in, and this fetches that and +// hands the result back — so `pit.moshcode.sh/n/blue.eggs` shows whatever +// blue.eggs points at, from a browser that has never heard of Moshpit. +// +// The target is attacker-controlled. That is the whole security problem here: +// anyone who can claim a name can point it at an address of their choosing and +// make this server fetch it, from inside whatever network this server is in. +// Pointed at 169.254.169.254 that is cloud credentials; pointed at 127.0.0.1 or +// a 10.x address it is every internal service the box can reach, returned to +// the person who asked. So the target is checked against the ranges that are +// not the public internet, and a hostname is checked *after* resolution rather +// than before, because "internal.example.com" is a public-looking name that can +// resolve anywhere. +// +// The check is a deny-list of the reserved ranges rather than an allow-list of +// public ones, which is the weaker shape — but the alternative is enumerating +// the entire public internet. The ranges below are the ones IANA reserves, and +// anything unparseable is refused rather than assumed routable. + +import { promises as dns } from "node:dns"; +import { isIP } from "node:net"; + +/** How long the origin has to answer before the gateway gives up on it. */ +export const ORIGIN_TIMEOUT_MS = 10_000; + +/** Enough for a page; a gateway is not a file host. */ +export const MAX_BODY_BYTES = 5 * 1024 * 1024; + +function ipv4ToInt(ip) { + const parts = ip.split(".").map(Number); + if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null; + return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; +} + +const V4_BLOCKED = [ + ["0.0.0.0", 8, "this host"], + ["10.0.0.0", 8, "private"], + ["100.64.0.0", 10, "carrier-grade NAT"], + ["127.0.0.0", 8, "loopback"], + ["169.254.0.0", 16, "link-local — cloud metadata lives here"], + ["172.16.0.0", 12, "private"], + ["192.0.0.0", 24, "IETF protocol assignments"], + ["192.0.2.0", 24, "documentation"], + ["192.168.0.0", 16, "private"], + ["198.18.0.0", 15, "benchmarking"], + ["198.51.100.0", 24, "documentation"], + ["203.0.113.0", 24, "documentation"], + ["224.0.0.0", 4, "multicast"], + ["240.0.0.0", 4, "reserved"], +]; + +/** Why this address may not be fetched, or null when it may. */ +export function blockedReason(ip) { + const version = isIP(ip); + if (version === 4) { + const value = ipv4ToInt(ip); + if (value === null) return "unparseable address"; + for (const [base, bits, why] of V4_BLOCKED) { + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + if ((value & mask) === (ipv4ToInt(base) & mask)) return why; + } + return null; + } + if (version === 6) { + const v6 = ip.toLowerCase().replace(/^\[|\]$/g, ""); + if (v6 === "::" || v6 === "::1") return "loopback"; + if (v6.startsWith("fe80")) return "link-local"; + // fc00::/7 — unique local addresses. + if (/^f[cd]/.test(v6)) return "unique local"; + if (v6.startsWith("ff")) return "multicast"; + // An IPv4-mapped address would otherwise skip every rule above. + const mapped = v6.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return blockedReason(mapped[1]); + return null; + } + return "not an IP address"; +} + +/** + * Split a target into host and port. + * + * Targets are typed by hand into a text field, so they arrive as bare IPs, + * host:port, and occasionally with a scheme already on the front. + */ +export function parseTarget(target) { + const raw = String(target || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, ""); + if (!raw) return null; + + const bracketed = raw.match(/^\[([0-9a-f:]+)\](?::(\d+))?$/i); + if (bracketed) return { host: bracketed[1], port: bracketed[2] ? Number(bracketed[2]) : 80 }; + + // A bare IPv6 literal has colons but no port; only split on the last colon + // when what follows is digits and what precedes is not itself IPv6. + if (isIP(raw) === 6) return { host: raw, port: 80 }; + + const index = raw.lastIndexOf(":"); + if (index > 0 && /^\d+$/.test(raw.slice(index + 1))) { + const port = Number(raw.slice(index + 1)); + if (port < 1 || port > 65535) return null; + return { host: raw.slice(0, index), port }; + } + return { host: raw, port: 80 }; +} + +/** + * Is this target safe to fetch, and at what address? + * + * A hostname is resolved here and every address it returns is checked, because + * one A record pointing somewhere public does not make the others safe. + */ +export async function checkTarget(target, { resolve = dns.lookup } = {}) { + const parsed = parseTarget(target); + if (!parsed) return { ok: false, error: "not a usable target" }; + + if (isIP(parsed.host)) { + const why = blockedReason(parsed.host); + return why + ? { ok: false, error: `target is ${why}` } + : { ok: true, host: parsed.host, port: parsed.port, addresses: [parsed.host] }; + } + + let addresses; + try { + addresses = await resolve(parsed.host, { all: true }); + } catch { + return { ok: false, error: "target does not resolve" }; + } + if (!addresses?.length) return { ok: false, error: "target does not resolve" }; + + for (const { address } of addresses) { + const why = blockedReason(address); + if (why) return { ok: false, error: `target resolves to ${why}` }; + } + return { ok: true, host: parsed.host, port: parsed.port, addresses: addresses.map((a) => a.address) }; +} + +/** Headers worth passing to the origin. Everything else is dropped. */ +export function forwardableHeaders(headers = {}, name) { + const out = { + // The origin is virtual-hosting on the Moshpit name, so it needs to be told + // which one this is — the TCP connection only knows an IP. + host: name, + "x-forwarded-host": name, + "x-moshpit-name": name, + }; + for (const key of ["accept", "accept-language", "user-agent"]) { + if (headers[key]) out[key] = headers[key]; + } + // Deliberately absent: cookie, authorization, and every x-forwarded-for. The + // visitor's session on app.moshcode.sh has nothing to do with the origin, and + // forwarding it would hand a name's owner their visitors' credentials. + return out; +} diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs index fbf64c3..527728d 100644 --- a/apps/pwa/src/routes/moshpit.mjs +++ b/apps/pwa/src/routes/moshpit.mjs @@ -18,6 +18,9 @@ import { requireAuth, csrfInput } from "../lib/session.mjs"; import { balance } from "../lib/credits.mjs"; import { resolverConfig } from "../lib/moshpit-resolvers.mjs"; import { landingFor } from "../lib/moshpit-landing.mjs"; +import { + MAX_BODY_BYTES, ORIGIN_TIMEOUT_MS, checkTarget, forwardableHeaders, +} from "../lib/moshpit-gateway.mjs"; import { addPin, clearAlias, @@ -182,6 +185,151 @@ moshpitRouter.delete("/api/moshpit/tlds/:tld/names", async (req, res) => { res.json({ tld: normalizeTld(req.params.tld), label: normalizeLabel(req.body?.label), released: true }); }); +/* ---- serving a name over the clearnet ---- */ + +/** + * GET /n/:name — what a Moshpit name actually shows. + * + * The destination every resolver and the TronBrowser extension already points + * at. Two outcomes: a name with a target is fetched and returned, and a name + * without one gets a directory instead of a dead end — what else lives under + * this ending, and which other endings are worth a look. A parked name is the + * commonest thing anyone will land on, so it is the page that has to earn its + * keep. + */ +moshpitRouter.get("/n/:name", async (req, res) => { + const resolution = await resolveMoshpitName(req.params.name); + if (!resolution) return res.status(400).send(page({ title: "moshpit", body: notAName(req.params.name) })); + + const parsed = parseMoshpitName(resolution.resolved); + const tld = parsed?.tld; + + if (resolution.target) { + const check = await checkTarget(resolution.target); + if (!check.ok) { + // Named plainly rather than shown as a generic failure: the owner is the + // only one who can fix it, and "target is link-local" tells them how. + return res.status(502).send(page({ + title: resolution.name, + body: unreachable(resolution, check.error), + })); + } + return proxyToOrigin(req, res, resolution, check); + } + + // No target: the directory. + const [names, tlds] = await Promise.all([ + tld ? listNames(tld) : Promise.resolve([]), + listTlds(200), + ]); + const owner = tld ? await getTld(tld) : null; + res.status(resolution.name_registered ? 200 : 404).send(page({ + title: resolution.name, + body: directory({ resolution, tld, owner, names, tlds }), + })); +}); + +/** Fetch the origin and hand the result back, bounded in time and size. */ +async function proxyToOrigin(req, res, resolution, check) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ORIGIN_TIMEOUT_MS); + try { + const upstream = await fetch(`http://${check.host}:${check.port}${req.originalUrl.replace(/^\/n\/[^/?]+/, "") || "/"}`, { + headers: forwardableHeaders(req.headers, resolution.resolved), + redirect: "manual", + signal: controller.signal, + }); + + // Only what a page needs. Passing the origin's Set-Cookie through would let + // a name's owner set cookies on app.moshcode.sh, which is where accounts + // live — that is a session-fixation hole, not a feature. + const type = upstream.headers.get("content-type"); + if (type) res.set("content-type", type); + res.set("x-moshpit-name", resolution.resolved); + res.set("content-security-policy", "sandbox allow-scripts allow-forms allow-popups"); + + const buffer = Buffer.from(await upstream.arrayBuffer()); + if (buffer.length > MAX_BODY_BYTES) { + return res.status(502).send(page({ title: resolution.name, body: unreachable(resolution, "response too large") })); + } + return res.status(upstream.status).send(buffer); + } catch (error) { + const why = error.name === "AbortError" ? "the origin did not answer in time" : "the origin could not be reached"; + return res.status(504).send(page({ title: resolution.name, body: unreachable(resolution, why) })); + } finally { + clearTimeout(timer); + } +} + +const notAName = (typed) => ` +
+

not a Moshpit name

+

${esc(typed)} is not one label and one ending.

+

the pit →

+
`; + +const unreachable = (resolution, why) => ` +
+

${esc(resolution.name)}

+

This name points somewhere that could not be served: ${esc(why)}.

+

Its owner can repoint it from the pit.

+

the pit →

+
`; + +/** + * The page a parked name shows. + * + * Everything here is a link to something else in the namespace, because the + * person reading it typed a name that has nothing behind it and the useful + * answer is what does. Live sites first — they are the only entries that go + * anywhere real — then the rest of the ending, then other endings. + */ +function directory({ resolution, tld, owner, names, tlds }) { + const live = names.filter((n) => n.target); + const claimed = names.filter((n) => !n.target); + + // "Related" without a taxonomy: an alias is an explicit statement by an + // operator that two endings belong together, and shared ownership is the + // next best signal. Everything else is just the rest of the namespace. + const related = tlds.filter((t) => + t.tld !== tld && (t.alias_of === tld || (owner && t.user_id === owner.user_id))); + const others = tlds.filter((t) => t.tld !== tld && !related.includes(t)).slice(0, 24); + + const nameLink = (n) => + `${esc(n.label)}.${esc(tld)}`; + const tldLink = (t) => + `.${esc(t.tld)}`; + + return ` +
+

${esc(resolution.name)}

+

+ ${resolution.name_registered + ? "This name is claimed but does not point anywhere yet." + : `Nobody holds this name. Claim it →`} +

+ + ${live.length ? ` +

Sites on .${esc(tld)}

+
    ${live.map((n) => `
  • ${nameLink(n)} → ${esc(n.target)}
  • `).join("")}
` + : `

No site under .${esc(tld)} points anywhere yet.

`} + + ${claimed.length ? ` +

Also claimed on .${esc(tld)}

+
    ${claimed.slice(0, 40).map((n) => `
  • ${nameLink(n)}
  • `).join("")}
` : ""} + + ${related.length ? ` +

Related endings

+

${related.map(tldLink).join(" · ")}

` : ""} + + ${others.length ? ` +

More endings

+

${others.map(tldLink).join(" · ")}

` : ""} + +

the pit →

+
`; +} + /* ---- the keys a name may present ---- */ /** @@ -553,6 +701,11 @@ const PIT_CSS = ` .pit-defaults label{display:flex;align-items:center;gap:6px;font-family:var(--mono); font-size:.72rem;letter-spacing:.06em;color:var(--dim);white-space:nowrap} .pit-defaults input{width:11ch;padding:7px 9px;font-size:.78rem} +.pit-dir{list-style:none;padding:0;margin:10px 0;display:grid;gap:4px} +.pit-dir li{font-size:.82rem} +.pit-dir-row{line-height:2;max-width:70ch} +.pit-dir-row a{color:var(--dim);text-decoration:none} +.pit-dir-row a:hover{color:var(--acid)} .pit-bulk{margin:0 0 18px;max-width:62ch} .pit-bulk summary{font-family:var(--mono);font-size:.74rem;letter-spacing:.08em;color:var(--dim);cursor:pointer;padding:6px 0} .pit-bulk summary:hover{color:var(--acid)} diff --git a/apps/pwa/test/moshpit-gateway.test.mjs b/apps/pwa/test/moshpit-gateway.test.mjs new file mode 100644 index 0000000..cb6a1e0 --- /dev/null +++ b/apps/pwa/test/moshpit-gateway.test.mjs @@ -0,0 +1,124 @@ +// What a Moshpit name is allowed to point at. +// +// The target is typed in by whoever holds the name, and this server fetches it +// from inside whatever network it runs in. That makes every one of these an +// SSRF test: the failure is not "the page looks wrong", it is "the gateway +// returned our cloud credentials to the person who asked for them". +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + blockedReason, checkTarget, forwardableHeaders, parseTarget, +} from "../src/lib/moshpit-gateway.mjs"; + +test("addresses that must never be fetched", () => { + const blocked = { + "127.0.0.1": /loopback/, + "127.1.2.3": /loopback/, + "0.0.0.0": /this host/, + "10.1.2.3": /private/, + "172.16.5.5": /private/, + "172.31.255.255": /private/, + "192.168.1.1": /private/, + "169.254.169.254": /link-local/, // the one that hands out cloud credentials + "100.64.0.1": /carrier-grade NAT/, + "224.0.0.1": /multicast/, + "240.0.0.1": /reserved/, + "::1": /loopback/, + "fe80::1": /link-local/, + "fc00::1": /unique local/, + "ff02::1": /multicast/, + "::ffff:127.0.0.1": /loopback/, // IPv4-mapped, or every v4 rule is skippable + "::ffff:169.254.169.254": /link-local/, + }; + for (const [ip, why] of Object.entries(blocked)) { + assert.match(blockedReason(ip) || "", why, ip); + } +}); + +test("ordinary public addresses are allowed", () => { + for (const ip of ["8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:4700::1111"]) { + assert.equal(blockedReason(ip), null, ip); + } +}); + +test("172.32 is public even though 172.16/12 is not", () => { + // The /12 boundary is the classic off-by-one in a hand-written deny list. + assert.equal(blockedReason("172.15.255.255"), null); + assert.match(blockedReason("172.16.0.0"), /private/); + assert.match(blockedReason("172.31.255.255"), /private/); + assert.equal(blockedReason("172.32.0.0"), null); +}); + +test("anything that is not an address is refused, not assumed routable", () => { + for (const junk of ["", "not-an-ip", "999.1.1.1", "1.2.3", null, undefined]) { + assert.ok(blockedReason(junk), JSON.stringify(junk)); + } +}); + +test("targets parse in the shapes people type", () => { + assert.deepEqual(parseTarget("203.0.113.7"), { host: "203.0.113.7", port: 80 }); + assert.deepEqual(parseTarget("203.0.113.7:8080"), { host: "203.0.113.7", port: 8080 }); + assert.deepEqual(parseTarget("http://example.com"), { host: "example.com", port: 80 }); + assert.deepEqual(parseTarget("https://example.com:3000/"), { host: "example.com", port: 3000 }); + assert.deepEqual(parseTarget("[2606:4700::1111]:8080"), { host: "2606:4700::1111", port: 8080 }); + assert.deepEqual(parseTarget("2606:4700::1111"), { host: "2606:4700::1111", port: 80 }); + assert.equal(parseTarget(" "), null); + assert.equal(parseTarget("example.com:99999"), null, "not a port"); +}); + +test("a literal private address is refused before any lookup", async () => { + const never = () => { throw new Error("should not resolve a literal"); }; + const result = await checkTarget("169.254.169.254", { resolve: never }); + assert.equal(result.ok, false); + assert.match(result.error, /link-local/); +}); + +test("a hostname is judged on what it resolves to, not how it looks", async () => { + // `internal.example.com` is a perfectly public-looking name. Checking the + // string instead of the answer is how this class of bug survives review. + const resolve = async () => [{ address: "10.0.0.5" }]; + const result = await checkTarget("innocent.example.com", { resolve }); + assert.equal(result.ok, false); + assert.match(result.error, /resolves to private/); +}); + +test("one bad address among good ones fails the whole target", async () => { + const resolve = async () => [{ address: "93.184.216.34" }, { address: "127.0.0.1" }]; + const result = await checkTarget("split.example.com", { resolve }); + assert.equal(result.ok, false, "a public A record does not make the others safe"); +}); + +test("a public hostname passes and reports where it went", async () => { + const resolve = async () => [{ address: "93.184.216.34" }]; + const result = await checkTarget("example.com:8080", { resolve }); + assert.deepEqual(result, { ok: true, host: "example.com", port: 8080, addresses: ["93.184.216.34"] }); +}); + +test("a name that does not resolve is refused", async () => { + const result = await checkTarget("nx.example.com", { resolve: async () => { throw new Error("NXDOMAIN"); } }); + assert.equal(result.ok, false); + assert.match(result.error, /does not resolve/); +}); + +test("credentials are never forwarded to an origin", () => { + const headers = forwardableHeaders({ + cookie: "mc_sess=secret", + authorization: "Bearer secret", + "x-forwarded-for": "203.0.113.9", + accept: "text/html", + "user-agent": "curl/8", + }, "blue.eggs"); + + // The visitor's session on app.moshcode.sh has nothing to do with the origin, + // and forwarding it hands a name's owner their visitors' credentials. + assert.equal(headers.cookie, undefined); + assert.equal(headers.authorization, undefined); + assert.equal(headers["x-forwarded-for"], undefined); + + assert.equal(headers.accept, "text/html"); + assert.equal(headers["user-agent"], "curl/8"); + // The origin virtual-hosts on the name; the TCP connection only knows an IP. + assert.equal(headers.host, "blue.eggs"); + assert.equal(headers["x-moshpit-name"], "blue.eggs"); +});