connect.test.ts

15.0 KB · raw

1// Covers connect setup, validation, one-time redemption, rate limits, admin history, and secrecy.23import { Database } from "bun:sqlite";4import { afterAll, describe, expect, setSystemTime, test } from "bun:test";5import { mkdtemp } from "node:fs/promises";6import { tmpdir } from "node:os";7import { join } from "node:path";8import { createApp } from "../src/server/app.ts";9import { makeBackup } from "../src/server/backup.ts";10import type { Config } from "../src/server/config.ts";11import { openKeprDb } from "../src/server/db.ts";12import { Browser, PASSWORD } from "./browser.ts";1314const ORIGIN = "https://kepr.example";15// One argon2 hash for the whole file: every instance shares it, so the suite stays quick.16const HASH = await Bun.password.hash(PASSWORD, "argon2id");17let clock = Date.parse("2026-09-27T12:00:00Z");18setSystemTime(clock);19afterAll(() => setSystemTime());2021async function build(folder = false) {22	const dataDir = await mkdtemp(join(tmpdir(), "kepr-connect-"));23	const kepr = openKeprDb(dataDir);24	const bucket: Config["bucket"] = folder25		? { kind: "folder", path: dataDir }26		: {27				kind: "s3",28				endpoint: "https://s3.fr-par.scw.cloud",29				name: "my-bucket",30				region: "fr-par",31				accessKeyId: "KEY-ID",32				secretAccessKey: "KEY-SECRET",33			};34	const config: Config = {35		domain: "kepr.example",36		dataDir,37		adminPasswordHash: HASH,38		bucket,39	};40	return { app: createApp(config, kepr), kepr, config };41}4243async function connected() {44	const built = await build();45	const browser = new Browser(built.app, `198.51.100.${Math.floor(Math.random() * 200) + 1}`);46	await browser.signIn();47	return { ...built, browser };48}4950async function issue(browser: Browser, machine = "laptop") {51	await browser.get(`/connect?machine=${encodeURIComponent(machine)}`);52	const page = await browser.post("/connect", { machine });53	const body = await page.text();54	const match = /class="mono code">([^<]+)/.exec(body);55	const code = match?.[1]?.trim() ?? "";56	if (code === "") {57		// A page without a code is the interesting failure: say what it said instead.58		throw new Error(`no code on the connect page (${page.status}): ${body.slice(0, 300)}`);59	}60	return { page, body, code };61}6263async function redeem(app: ReturnType<typeof createApp>, code: string, address = "203.0.113.77") {64	const browser = new Browser(app, address);65	return browser.post("/api/connect", { code });66}6768describe.serial("connect configuration and page", () => {69	test("a folder bucket hides all connect routes", async () => {70		const browser = new Browser((await build(true)).app);71		expect((await browser.get("/connect?machine=x")).status).toBe(404);72		expect((await browser.post("/connect", { machine: "x" })).status).toBe(404);73		expect((await browser.post("/api/connect", { code: "ABCDEFGH" })).status).toBe(404);74	});7576	test("a disabled connect route is an unknown path, byte for byte", async () => {77		const { app } = await build(true);78		const browser = new Browser(app);79		for (const [method, path] of [80			["GET", "/connect?machine=x"],81			["POST", "/connect"],82			["POST", "/api/connect"],83		] as const) {84			const asked = method === "GET" ? browser.get(path) : browser.post(path, {});85			const unknown =86				method === "GET" ? browser.get("/nothing/here") : browser.post("/nothing/here", {});87			const [found, absent] = await Promise.all([asked, unknown]);88			expect(`${found.status} ${await found.text()}`).toBe(89				`${absent.status} ${await absent.text()}`,90			);91			expect(found.headers.get("cache-control")).toBe(absent.headers.get("cache-control"));92		}93		// Even an oversized body is answered here the way the unknown path answers it.94		const big = { code: "x".repeat(70_000) };95		const oversized = await browser.post("/api/connect", big);96		const unrouted = await browser.post("/nothing/here", big);97		expect(`${oversized.status} ${await oversized.text()}`).toBe(98			`${unrouted.status} ${await unrouted.text()}`,99		);100		expect(oversized.headers.get("cache-control")).toBe(unrouted.headers.get("cache-control"));101	});102103	test("a signed-out request redirects before the machine name is judged", async () => {104		const { app } = await build();105		const browser = new Browser(app);106		const response = await browser.get(`/connect?machine=${encodeURIComponent("x".repeat(65))}`);107		expect(response.status).toBe(302);108		expect(response.headers.get("location")).toBe("/sign-in?next=%2Fconnect");109	});110111	test("connect redirects signed out, preserves next, and rejects unsafe next values", async () => {112		const built = await build();113		const browser = new Browser(built.app);114		const response = await browser.get("/connect?machine=desk%20%26%20rack");115		expect(response.status).toBe(302);116		for (const next of ["https://evil.example", "//evil.example", "/bad\n"]) {117			const sign = await browser.get(`/sign-in?next=${encodeURIComponent(next)}`);118			const html = await sign.text();119			expect(html).toContain('name="next" value="/admin"');120		}121		const sign = await browser.get("/sign-in?next=%2Fconnect%3Fmachine%3Ddesk");122		const form = /name="form" value="([^"]+)"/.exec(await sign.text())?.[1] ?? "";123		const wrong = await browser.request("/sign-in", {124			method: "POST",125			headers: { origin: ORIGIN },126			body: new URLSearchParams({ form, next: "/connect?machine=desk", password: "wrong" }),127		});128		expect(wrong.status).toBe(401);129		expect(await wrong.text()).toContain('name="next" value="/connect?machine=desk"');130		const good = await browser.request("/sign-in", {131			method: "POST",132			headers: { origin: ORIGIN },133			body: new URLSearchParams({ form, next: "/connect?machine=desk", password: PASSWORD }),134		});135		expect(good.status).toBe(303);136		expect(good.headers.get("location")).toBe("/connect?machine=desk");137	});138139	test("connect validates and escapes machine names, and never caches or exposes secrets", async () => {140		const { browser } = await connected();141		for (const machine of ["", "x".repeat(65), "bad\nname", "bad\u0085name", "bad\u202ename"])142			expect((await browser.get(`/connect?machine=${encodeURIComponent(machine)}`)).status).toBe(143				400,144			);145		const result = await issue(browser, "<img src=x onerror=alert(1)>");146		expect(result.page.status).toBe(200);147		expect(result.body).toContain("&lt;img src=x onerror=alert(1)&gt;");148		expect(result.body).not.toContain("KEY-SECRET");149		expect(result.page.headers.get("cache-control")).toBe("no-store");150	});151});152153describe.serial("connect redemption", () => {154	test("stores only a hash, redeems once with exact credentials, normalizes code, and gives identical failures", async () => {155		const { app, browser, kepr, config } = await connected();156		const { code, body } = await issue(browser, "office");157		expect(code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/);158		expect(body).not.toContain("KEY-SECRET");159		const row = kepr.query("SELECT code_hash, machine FROM connect_codes").get() as {160			code_hash: string;161			machine: string;162		};163		expect(row.code_hash).toMatch(/^[0-9a-f]{64}$/);164		expect(row.code_hash).not.toContain(code.replace("-", ""));165		expect(row.machine).toBe("office");166		const success = await redeem(app, code.toLowerCase(), "203.0.113.88");167		expect(success.status).toBe(200);168		expect(await success.json()).toEqual({169			endpoint: config.bucket.kind === "s3" ? config.bucket.endpoint : "",170			bucket: "my-bucket",171			region: "fr-par",172			access_key_id: "KEY-ID",173			secret_access_key: "KEY-SECRET",174		});175		expect(success.headers.get("cache-control")).toBe("no-store");176		const used = await redeem(app, code, "203.0.113.89");177		const unknown = await redeem(app, "ZZZZ-ZZZZ", "203.0.113.90");178		expect(used.status).toBe(404);179		expect(await used.text()).toBe(await unknown.text());180		expect(used.headers.get("cache-control")).toBe("no-store");181	});182183	test("connect POST requires session, origin and fresh form token", async () => {184		const built = await build();185		const browser = new Browser(built.app);186		expect((await browser.post("/connect", { machine: "x" })).status).toBe(401);187		await browser.signIn();188		expect((await browser.post("/connect", { machine: "x" }, "https://evil.example")).status).toBe(189			403,190		);191		expect((await browser.post("/connect", { machine: "x", form: "stale" })).status).toBe(403);192	});193194	test("expired, malformed and missing codes all return the same no-code response", async () => {195		const { app, browser } = await connected();196		const { code } = await issue(browser);197		clock += 601_000;198		setSystemTime(clock);199		const expired = await redeem(app, code, "198.51.100.201");200		const malformed = await redeem(app, "bad", "198.51.100.202");201		const missing = await new Browser(app, "198.51.100.203").request("/api/connect", {202			method: "POST",203			body: "not-a-form",204		});205		expect(expired.status).toBe(404);206		const expected = await expired.text();207		expect(expected).toBe('{"error":"no_code","message":"no code"}');208		expect(await malformed.text()).toBe(expected);209		expect(await missing.text()).toBe(expected);210	});211212	test("rate limits ten attempts per peer while another peer succeeds", async () => {213		const { app, browser } = await connected();214		const { code } = await issue(browser);215		for (let i = 0; i < 10; i++) await redeem(app, "bad", "192.0.2.40");216		const limited = await redeem(app, code, "192.0.2.40");217		expect(limited.status).toBe(404);218		const other = await redeem(app, code, "192.0.2.41");219		expect(other.status).toBe(200);220		// The window is an hour: once it has passed the same peer is served again.221		clock += 3_601_000;222		setSystemTime(clock);223		const { code: fresh } = await issue(browser, "later");224		expect((await redeem(app, fresh, "192.0.2.40")).status).toBe(200);225	});226227	test("the attempt window is counted by time, not by the list's order", async () => {228		const { app, browser } = await connected();229		const address = "192.0.2.70";230		// Nine attempts at one moment...231		for (let i = 0; i < 9; i++) await redeem(app, "bad", address);232		// ...then the clock steps back — an NTP correction, or a test's fake timer — and a tenth233		// attempt lands *older* than the nine before it. A prune that walks the list by position234		// stops at the first entry still inside the window and never reaches that one.235		clock -= 120_000;236		setSystemTime(clock);237		await redeem(app, "bad", address);238		// Now: an hour and two minutes after the nine, so the older tenth has left the window while239		// they are still inside it — the peer has one attempt left, not none.240		clock += 3_620_000;241		setSystemTime(clock);242		const { code } = await issue(browser, "after the step");243		expect((await redeem(app, code, address)).status).toBe(200);244	});245246	test("refused attempts are not recorded, so hammering neither grows the list nor the block", async () => {247		const { app, browser } = await connected();248		const address = "192.0.2.90";249		for (let i = 0; i < 10; i++) await redeem(app, "bad", address);250		// Fifty more inside the hour are all refused, and none of them is remembered: kept, they would251		// cost memory per request and move the block out an hour past the last one.252		clock += 3_540_000;253		setSystemTime(clock);254		for (let i = 0; i < 50; i++) expect((await redeem(app, "bad", address)).status).toBe(404);255		// An hour after the tenth counted attempt the peer is served again.256		clock += 61_000;257		setSystemTime(clock);258		const { code } = await issue(browser, "after the block");259		expect((await redeem(app, code, address)).status).toBe(200);260	});261262	test("a forged X-Forwarded-For does not buy another bucketful", async () => {263		const { app, browser } = await connected();264		const { code } = await issue(browser);265		for (let i = 0; i < 10; i++) await redeem(app, "bad", "192.0.2.50");266		const forged = await new Browser(app, "192.0.2.50").request("/api/connect", {267			method: "POST",268			headers: {269				"x-forwarded-for": "198.51.100.99",270				"content-type": "application/x-www-form-urlencoded",271			},272			body: new URLSearchParams({ code }),273		});274		expect(forged.status).toBe(404);275	});276277	test("two redemptions at once: exactly one wins", async () => {278		const { app, browser } = await connected();279		const { code } = await issue(browser, "race");280		const [first, second] = await Promise.all([281			redeem(app, code, "203.0.113.101"),282			redeem(app, code, "203.0.113.102"),283		]);284		expect([first.status, second.status].sort()).toEqual([200, 404]);285	});286287	test("an oversized redemption is refused with the same no-code body", async () => {288		const { app } = await connected();289		const response = await new Browser(app, "192.0.2.60").post("/api/connect", {290			code: "x".repeat(70_000),291		});292		expect(response.status).toBe(404);293		expect(await response.text()).toBe('{"error":"no_code","message":"no code"}');294		expect(response.headers.get("cache-control")).toBe("no-store");295	});296297	test("a backup carries the hashed code and never the bucket key", async () => {298		const { browser, kepr, config } = await connected();299		const { code } = await issue(browser, "laptop");300		const name = makeBackup(kepr, config.dataDir);301		const file = join(config.dataDir, "backups", name, "kepr.db");302		const copy = new Database(file, { readonly: true });303		const rows = copy.query("SELECT machine, code_hash FROM connect_codes").all();304		copy.close();305		expect(rows.length).toBe(1);306		expect(JSON.stringify(rows)).not.toContain(code.replace("-", ""));307		// The key is read from kepr.toml alone, so no copy of kepr.db can hold it.308		const bytes = await Bun.file(file).bytes();309		expect(new TextDecoder().decode(bytes)).not.toContain("KEY-SECRET");310	});311});312313describe.serial("connected machines in admin", () => {314	test("lists redeemed machines newest first, at most twenty, with revocation guidance", async () => {315		const { app, browser, kepr } = await connected();316		for (let i = 0; i < 22; i++) {317			const { code } = await issue(browser, `machine-${i}`);318			const response = await redeem(app, code, `10.0.0.${i + 1}`);319			expect(response.status).toBe(200);320			clock += 1000;321			setSystemTime(clock);322		}323		const admin = await browser.get("/admin");324		const html = await admin.text();325		expect(html).toContain("Machines connected");326		expect(html).toContain("rotate the bucket key in Scaleway");327		expect(html).not.toContain("machine-0 ");328		expect(html).toContain("machine-21");329		expect(html.indexOf("machine-21")).toBeLessThan(html.indexOf("machine-20"));330		// Only what the list can show is kept: the two oldest redemptions are gone from kepr.db.331		const kept = kepr332			.query<{ machine: string }, []>(333				"SELECT machine FROM connect_codes WHERE used_at IS NOT NULL ORDER BY used_at",334			)335			.all();336		expect(kept.length).toBe(20);337		expect(kept[0]?.machine).toBe("machine-2");338	});339340	test("redemptions in one second keep and list the latest twenty", async () => {341		const { app, browser, kepr } = await connected();342		for (let i = 0; i < 22; i++) {343			const { code } = await issue(browser, `machine-${i}`);344			expect((await redeem(app, code, `10.0.1.${i + 1}`)).status).toBe(200);345		}346		const html = await (await browser.get("/admin")).text();347		expect(html).not.toContain("machine-1 ");348		expect(html.indexOf("machine-21")).toBeLessThan(html.indexOf("machine-2 "));349		const kept = kepr350			.query<{ machine: string }, []>("SELECT machine FROM connect_codes WHERE used_at IS NOT NULL")351			.all()352			.map((row) => row.machine);353		expect(kept).not.toContain("machine-0");354		expect(kept).not.toContain("machine-1");355		expect(kept.length).toBe(20);356	});357});