Skip to content

Commit 681ca9e

Browse files
ralyodioclaude
andauthored
feat(moshpit): let an API key drive the namespace (#169)
/api/moshpit/* is documented at the top of the router as an API and was one only if you had a browser cookie. The key `moshcode` already holds -- the one /api/me and /api/sessions both accept -- got 401 from every endpoint here, so the namespace was the single part of the product no script could touch. That is why a batch of registrations could not be submitted without sitting in the browser doing it by hand. Same helper, same keys, same 401 when there is no key. A cookie session still wins when both are present, because that caller is already identified. Scoped to /api/moshpit. The /pit pages stay browser routes: they are CSRF-guarded form posts, and a bearer token has no business standing in for a session there -- there is a test that posts one at /pit/claim and expects the 403 it gets today. Worth stating plainly: this widens what a leaked API key can do. It could already read your account and drive your CLI sessions; it can now claim endings, mint names and start a name checkout. That is the same blast radius the key has everywhere else, which is the argument for consistency, not against it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 73f945c commit 681ca9e

2 files changed

Lines changed: 191 additions & 0 deletions

File tree

apps/pwa/src/routes/moshpit.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import { Router } from "express";
1616
import { page, footer, appBar, esc } from "../lib/html.mjs";
1717
import { requireAuth, csrfInput } from "../lib/session.mjs";
18+
import { bearer, userForApiKey } from "../lib/apikey.mjs";
1819
import { balance } from "../lib/credits.mjs";
1920
import { resolverConfig } from "../lib/moshpit-resolvers.mjs";
2021
import { landingFor } from "../lib/moshpit-landing.mjs";
@@ -74,6 +75,31 @@ export const moshpitRouter = Router();
7475
const bad = (res, error, status = 400) => res.status(status).json({ error });
7576
const unauthorized = (res) => res.status(401).json({ error: "sign in first" });
7677

78+
/**
79+
* The machine half of the namespace.
80+
*
81+
* Everything under /api/moshpit is described at the top of this file as an API,
82+
* and it was one only if you happened to have a browser cookie. `moshcode`
83+
* holds an API key that /api/me and /api/sessions both accept; every endpoint
84+
* here answered that same key with 401, so the namespace was the one part of
85+
* the product no script could touch.
86+
*
87+
* Same helper, same keys, same 401 when there is no key -- this is not a new
88+
* way in, it stops one router being the exception. A cookie session still wins
89+
* when both are present, because that is the caller who is already identified.
90+
*
91+
* Only /api/moshpit. The /pit pages are browser routes: they are CSRF-guarded
92+
* form posts, and a bearer token has no business standing in for a session
93+
* there.
94+
*/
95+
moshpitRouter.use("/api/moshpit", async (req, _res, next) => {
96+
if (!req.user) {
97+
const user = await userForApiKey(bearer(req));
98+
if (user) req.user = user;
99+
}
100+
next();
101+
});
102+
77103
/* ---------- API ---------- */
78104

79105
/**
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// The namespace API, driven by a machine.
2+
//
3+
// /api/moshpit/* reads as an API and behaved like one only for a browser: the
4+
// same API key that `moshcode whoami` uses against /api/me got 401 from every
5+
// endpoint here, so the namespace was the one part of the product no script
6+
// could touch.
7+
//
8+
// These tests pin both halves of that: a key works, and the absence of one
9+
// still does not.
10+
import assert from "node:assert/strict";
11+
import fs from "node:fs";
12+
import { mkdtempSync } from "node:fs";
13+
import { tmpdir } from "node:os";
14+
import path from "node:path";
15+
import { createRequire } from "node:module";
16+
import test from "node:test";
17+
18+
const require = createRequire(import.meta.url);
19+
let deps = null;
20+
try {
21+
deps = { express: require("express"), cookieParser: require("cookie-parser") };
22+
} catch {
23+
deps = null;
24+
}
25+
26+
const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-pit-apikey-test-"));
27+
process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
28+
process.env.SESSION_SECRET = "test-secret";
29+
30+
async function boot() {
31+
const { migrate } = await import("../src/migrate.mjs");
32+
await migrate();
33+
const { run, db } = await import("../src/db.mjs");
34+
const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs");
35+
const { moshpitRouter } = await import("../src/routes/moshpit.mjs");
36+
const { createApiKey } = await import("../src/lib/apikey.mjs");
37+
38+
await run(`INSERT OR REPLACE INTO users (id, email, display_name, created_at) VALUES ('u1','a@b.c','one',1)`);
39+
await run(`INSERT OR REPLACE INTO users (id, email, display_name, created_at) VALUES ('u2','x@y.z','two',1)`);
40+
await run(`INSERT INTO moshpit_tlds (tld,user_id,owner_email,created_at) VALUES ('held','u1','a@b.c',1)`);
41+
await run(`INSERT INTO moshpit_tlds (tld,user_id,owner_email,created_at) VALUES ('theirs','u2','x@y.z',1)`);
42+
43+
const keyOne = (await createApiKey("u1", "cli one")).plaintext;
44+
const keyTwo = (await createApiKey("u2", "cli two")).plaintext;
45+
46+
// The real middleware stack, so the CSRF guard gets a vote on these requests
47+
// exactly as it does in production.
48+
const app = deps.express();
49+
app.use(deps.express.json());
50+
app.use(deps.express.urlencoded({ extended: false }));
51+
app.use(deps.cookieParser());
52+
app.use(sessionMiddleware);
53+
app.use(csrfGuard);
54+
app.use(moshpitRouter);
55+
const server = await new Promise((resolve) => {
56+
const s = app.listen(0, "127.0.0.1", () => resolve(s));
57+
});
58+
const base = `http://127.0.0.1:${server.address().port}`;
59+
60+
const call = (token) => async (method, p, body) => {
61+
const res = await fetch(`${base}${p}`, {
62+
method,
63+
headers: {
64+
"content-type": "application/json",
65+
...(token ? { authorization: `Bearer ${token}` } : {}),
66+
},
67+
body: body === undefined ? undefined : JSON.stringify(body),
68+
});
69+
const text = await res.text();
70+
let json = null;
71+
try { json = JSON.parse(text); } catch { /* HTML error page */ }
72+
return { status: res.status, json, text };
73+
};
74+
75+
return { server, db, one: call(keyOne), two: call(keyTwo), anon: call(null) };
76+
}
77+
78+
let booted = null;
79+
const app = () => (booted ||= boot());
80+
81+
test.after(() => {
82+
if (!booted) return;
83+
booted.then(({ server, db }) => { server.close(); db.close?.(); })
84+
.finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } });
85+
});
86+
87+
const skip = { skip: !deps && "apps/pwa deps not installed" };
88+
89+
test("api key: reading your own endings no longer needs a browser", skip, async () => {
90+
const { one, anon } = await app();
91+
92+
const mine = await one("GET", "/api/moshpit/tlds?mine=1");
93+
assert.equal(mine.status, 200);
94+
assert.deepEqual(mine.json.tlds.map((t) => t.tld), ["held"]);
95+
96+
// The absence of a key is still 401 — this widened who can authenticate, not
97+
// whether anyone has to.
98+
assert.equal((await anon("GET", "/api/moshpit/tlds?mine=1")).status, 401);
99+
});
100+
101+
test("api key: claiming an ending works from a script", skip, async () => {
102+
const { one } = await app();
103+
const res = await one("POST", "/api/moshpit/tlds", { tld: "claimed" });
104+
assert.equal(res.status, 201, res.text); // 201: it created something
105+
106+
const mine = await one("GET", "/api/moshpit/tlds?mine=1");
107+
assert.ok(mine.json.tlds.map((t) => t.tld).includes("claimed"));
108+
});
109+
110+
test("api key: a write with no key is refused, not silently accepted", skip, async () => {
111+
const { anon } = await app();
112+
const res = await anon("POST", "/api/moshpit/tlds", { tld: "nokey" });
113+
assert.equal(res.status, 401);
114+
115+
const { anon: check } = await app();
116+
const still = await check("GET", "/api/moshpit/tlds/nokey");
117+
assert.equal(still.json.available, true, "nothing was written");
118+
});
119+
120+
test("api key: one key cannot act on another account's ending", skip, async () => {
121+
const { two } = await app();
122+
// u2 holds .theirs, not .held. Authenticating is not authorising.
123+
const res = await two("PUT", "/api/moshpit/tlds/held/alias", { to: "theirs" });
124+
assert.notEqual(res.status, 200);
125+
126+
const { one } = await app();
127+
const mine = await one("GET", "/api/moshpit/tlds?mine=1");
128+
const held = mine.json.tlds.find((t) => t.tld === "held");
129+
assert.equal(held.alias_of, null, "somebody else's key did not repoint it");
130+
});
131+
132+
test("api key: a made-up key is nobody", skip, async () => {
133+
const { server } = await app();
134+
const base = `http://127.0.0.1:${server.address().port}`;
135+
const res = await fetch(`${base}/api/moshpit/tlds?mine=1`, {
136+
headers: { authorization: "Bearer not-a-real-key" },
137+
});
138+
assert.equal(res.status, 401);
139+
});
140+
141+
test("api key: names can be minted and pointed from a script", skip, async () => {
142+
const { one } = await app();
143+
assert.equal((await one("POST", "/api/moshpit/tlds/held/names", { label: "blue" })).status, 201);
144+
145+
const pointed = await one("PUT", "/api/moshpit/tlds/held/names", { label: "blue", target: "203.0.113.7" });
146+
assert.equal(pointed.status, 200, pointed.text);
147+
148+
const resolved = await one("GET", "/api/moshpit/resolve?name=blue.held");
149+
assert.equal(resolved.json.target, "203.0.113.7");
150+
});
151+
152+
test("api key: the browser routes are still browser routes", skip, async () => {
153+
const { one } = await app();
154+
// /pit/claim is a CSRF-guarded form post. A bearer token must not stand in
155+
// for a session there — the guard runs before the router and should reject it.
156+
const res = await fetch(`http://127.0.0.1:${(await app()).server.address().port}/pit/claim`, {
157+
method: "POST",
158+
headers: { "content-type": "application/x-www-form-urlencoded", authorization: "Bearer whatever" },
159+
body: "tld=sneaky",
160+
});
161+
assert.equal(res.status, 403, "csrf guard still owns the form routes");
162+
163+
const check = await one("GET", "/api/moshpit/tlds/sneaky");
164+
assert.equal(check.json.available, true, "nothing was claimed");
165+
});

0 commit comments

Comments
 (0)