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)}

+ ` + : `

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

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

Also claimed on .${esc(tld)}

+ ` : ""} + + ${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"); +});