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
155 changes: 155 additions & 0 deletions apps/pwa/src/lib/moshpit-gateway.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
153 changes: 153 additions & 0 deletions apps/pwa/src/routes/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => `
<section class="pit-panel">
<h1 class="acid">not a Moshpit name</h1>
<p class="dim"><span class="mono">${esc(typed)}</span> is not one label and one ending.</p>
<p><a class="btn acid" href="/pit">the pit →</a></p>
</section>`;

const unreachable = (resolution, why) => `
<section class="pit-panel">
<h1 class="acid">${esc(resolution.name)}</h1>
<p class="dim">This name points somewhere that could not be served: ${esc(why)}.</p>
<p class="mono faint" style="font-size:.72rem">Its owner can repoint it from the pit.</p>
<p><a class="btn" href="/pit">the pit →</a></p>
</section>`;

/**
* 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) =>
`<a class="mono acid" href="/n/${esc(n.label)}.${esc(tld)}">${esc(n.label)}.${esc(tld)}</a>`;
const tldLink = (t) =>
`<a class="mono" href="/pit?tab=theirs&q=${esc(t.tld)}">.${esc(t.tld)}</a>`;

return `
<section class="pit-panel">
<h1 class="acid">${esc(resolution.name)}</h1>
<p class="dim">
${resolution.name_registered
? "This name is claimed but does not point anywhere yet."
: `Nobody holds this name. <a class="acid" href="/pit">Claim it →</a>`}
</p>

${live.length ? `
<h2 class="acid" style="font-size:.9rem;margin-top:26px">Sites on .${esc(tld)}</h2>
<ul class="pit-dir">${live.map((n) => `<li>${nameLink(n)} <span class="faint mono">→ ${esc(n.target)}</span></li>`).join("")}</ul>`
: `<p class="mono faint" style="font-size:.72rem;margin-top:26px">No site under .${esc(tld)} points anywhere yet.</p>`}

${claimed.length ? `
<h2 class="acid" style="font-size:.9rem;margin-top:22px">Also claimed on .${esc(tld)}</h2>
<ul class="pit-dir">${claimed.slice(0, 40).map((n) => `<li>${nameLink(n)}</li>`).join("")}</ul>` : ""}

${related.length ? `
<h2 class="acid" style="font-size:.9rem;margin-top:22px">Related endings</h2>
<p class="pit-dir-row">${related.map(tldLink).join(" · ")}</p>` : ""}

${others.length ? `
<h2 class="acid" style="font-size:.9rem;margin-top:22px">More endings</h2>
<p class="pit-dir-row">${others.map(tldLink).join(" · ")}</p>` : ""}

<p style="margin-top:26px"><a class="btn acid" href="/pit">the pit →</a></p>
</section>`;
}

/* ---- the keys a name may present ---- */

/**
Expand Down Expand Up @@ -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)}
Expand Down
Loading
Loading