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
26 changes: 26 additions & 0 deletions apps/pwa/src/routes/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { Router } from "express";
import { page, footer, appBar, esc } from "../lib/html.mjs";
import { requireAuth, csrfInput } from "../lib/session.mjs";
import { bearer, userForApiKey } from "../lib/apikey.mjs";
import { balance } from "../lib/credits.mjs";
import { resolverConfig } from "../lib/moshpit-resolvers.mjs";
import { landingFor } from "../lib/moshpit-landing.mjs";
Expand Down Expand Up @@ -74,6 +75,31 @@ export const moshpitRouter = Router();
const bad = (res, error, status = 400) => res.status(status).json({ error });
const unauthorized = (res) => res.status(401).json({ error: "sign in first" });

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

/* ---------- API ---------- */

/**
Expand Down
165 changes: 165 additions & 0 deletions apps/pwa/test/moshpit-api-key.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// The namespace API, driven by a machine.
//
// /api/moshpit/* reads as an API and behaved like one only for a browser: the
// same API key that `moshcode whoami` uses against /api/me got 401 from every
// endpoint here, so the namespace was the one part of the product no script
// could touch.
//
// These tests pin both halves of that: a key works, and the absence of one
// still does not.
import assert from "node:assert/strict";
import fs from "node:fs";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import test from "node:test";

const require = createRequire(import.meta.url);
let deps = null;
try {
deps = { express: require("express"), cookieParser: require("cookie-parser") };
} catch {
deps = null;
}

const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-pit-apikey-test-"));
process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
process.env.SESSION_SECRET = "test-secret";

async function boot() {
const { migrate } = await import("../src/migrate.mjs");
await migrate();
const { run, db } = await import("../src/db.mjs");
const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs");
const { moshpitRouter } = await import("../src/routes/moshpit.mjs");
const { createApiKey } = await import("../src/lib/apikey.mjs");

await run(`INSERT OR REPLACE INTO users (id, email, display_name, created_at) VALUES ('u1','a@b.c','one',1)`);
await run(`INSERT OR REPLACE INTO users (id, email, display_name, created_at) VALUES ('u2','x@y.z','two',1)`);
await run(`INSERT INTO moshpit_tlds (tld,user_id,owner_email,created_at) VALUES ('held','u1','a@b.c',1)`);
await run(`INSERT INTO moshpit_tlds (tld,user_id,owner_email,created_at) VALUES ('theirs','u2','x@y.z',1)`);

const keyOne = (await createApiKey("u1", "cli one")).plaintext;
const keyTwo = (await createApiKey("u2", "cli two")).plaintext;

// The real middleware stack, so the CSRF guard gets a vote on these requests
// exactly as it does in production.
const app = deps.express();
app.use(deps.express.json());
app.use(deps.express.urlencoded({ extended: false }));
app.use(deps.cookieParser());
app.use(sessionMiddleware);
app.use(csrfGuard);
app.use(moshpitRouter);
const server = await new Promise((resolve) => {
const s = app.listen(0, "127.0.0.1", () => resolve(s));
});
const base = `http://127.0.0.1:${server.address().port}`;

const call = (token) => async (method, p, body) => {
const res = await fetch(`${base}${p}`, {
method,
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch { /* HTML error page */ }
return { status: res.status, json, text };
};

return { server, db, one: call(keyOne), two: call(keyTwo), anon: call(null) };
}

let booted = null;
const app = () => (booted ||= boot());

test.after(() => {
if (!booted) return;
booted.then(({ server, db }) => { server.close(); db.close?.(); })
.finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } });
});

const skip = { skip: !deps && "apps/pwa deps not installed" };

test("api key: reading your own endings no longer needs a browser", skip, async () => {
const { one, anon } = await app();

const mine = await one("GET", "/api/moshpit/tlds?mine=1");
assert.equal(mine.status, 200);
assert.deepEqual(mine.json.tlds.map((t) => t.tld), ["held"]);

// The absence of a key is still 401 — this widened who can authenticate, not
// whether anyone has to.
assert.equal((await anon("GET", "/api/moshpit/tlds?mine=1")).status, 401);
});

test("api key: claiming an ending works from a script", skip, async () => {
const { one } = await app();
const res = await one("POST", "/api/moshpit/tlds", { tld: "claimed" });
assert.equal(res.status, 201, res.text); // 201: it created something

const mine = await one("GET", "/api/moshpit/tlds?mine=1");
assert.ok(mine.json.tlds.map((t) => t.tld).includes("claimed"));
});

test("api key: a write with no key is refused, not silently accepted", skip, async () => {
const { anon } = await app();
const res = await anon("POST", "/api/moshpit/tlds", { tld: "nokey" });
assert.equal(res.status, 401);

const { anon: check } = await app();
const still = await check("GET", "/api/moshpit/tlds/nokey");
assert.equal(still.json.available, true, "nothing was written");
});

test("api key: one key cannot act on another account's ending", skip, async () => {
const { two } = await app();
// u2 holds .theirs, not .held. Authenticating is not authorising.
const res = await two("PUT", "/api/moshpit/tlds/held/alias", { to: "theirs" });
assert.notEqual(res.status, 200);

const { one } = await app();
const mine = await one("GET", "/api/moshpit/tlds?mine=1");
const held = mine.json.tlds.find((t) => t.tld === "held");
assert.equal(held.alias_of, null, "somebody else's key did not repoint it");
});

test("api key: a made-up key is nobody", skip, async () => {
const { server } = await app();
const base = `http://127.0.0.1:${server.address().port}`;
const res = await fetch(`${base}/api/moshpit/tlds?mine=1`, {
headers: { authorization: "Bearer not-a-real-key" },
});
assert.equal(res.status, 401);
});

test("api key: names can be minted and pointed from a script", skip, async () => {
const { one } = await app();
assert.equal((await one("POST", "/api/moshpit/tlds/held/names", { label: "blue" })).status, 201);

const pointed = await one("PUT", "/api/moshpit/tlds/held/names", { label: "blue", target: "203.0.113.7" });
assert.equal(pointed.status, 200, pointed.text);

const resolved = await one("GET", "/api/moshpit/resolve?name=blue.held");
assert.equal(resolved.json.target, "203.0.113.7");
});

test("api key: the browser routes are still browser routes", skip, async () => {
const { one } = await app();
// /pit/claim is a CSRF-guarded form post. A bearer token must not stand in
// for a session there — the guard runs before the router and should reject it.
const res = await fetch(`http://127.0.0.1:${(await app()).server.address().port}/pit/claim`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded", authorization: "Bearer whatever" },
body: "tld=sneaky",
});
assert.equal(res.status, 403, "csrf guard still owns the form routes");

const check = await one("GET", "/api/moshpit/tlds/sneaky");
assert.equal(check.json.available, true, "nothing was claimed");
});
Loading