connect.test.ts

13.1 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, parseConfig } from "../src/server/config.ts";11import { openKeprDb } from "../src/server/db.ts";12import { Browser, PASSWORD } from "./browser.ts";1314const ORIGIN = "https://kepr.example";15const WRITE = { accessKeyId: "WRITE-ID", secretAccessKey: "WRITE-SECRET" };16// One argon2 hash for the whole file: every instance shares it, so the suite stays quick.17const HASH = await Bun.password.hash(PASSWORD, "argon2id");18let clock = Date.parse("2026-09-27T12:00:00Z");19setSystemTime(clock);20afterAll(() => setSystemTime());2122async function build(write = true, folder = false) {23	const dataDir = await mkdtemp(join(tmpdir(), "kepr-connect-"));24	const kepr = openKeprDb(dataDir);25	const bucket: Config["bucket"] = folder26		? { kind: "folder", path: dataDir }27		: {28				kind: "s3",29				endpoint: "https://s3.fr-par.scw.cloud",30				name: "my-bucket",31				region: "fr-par",32				accessKeyId: "read-id",33				secretAccessKey: "read-secret",34				...(write ? { write: WRITE } : {}),35			};36	const config: Config = {37		domain: "kepr.example",38		dataDir,39		adminPasswordHash: HASH,40		bucket,41	};42	return { app: createApp(config, kepr), kepr, config };43}4445async function connected(write = true) {46	const built = await build(write);47	const browser = new Browser(built.app, `198.51.100.${Math.floor(Math.random() * 200) + 1}`);48	await browser.signIn();49	return { ...built, browser };50}5152async function issue(browser: Browser, machine = "laptop") {53	await browser.get(`/connect?machine=${encodeURIComponent(machine)}`);54	const page = await browser.post("/connect", { machine });55	const body = await page.text();56	const match = /class="mono code">([^<]+)/.exec(body);57	const code = match?.[1]?.trim() ?? "";58	if (code === "") {59		// A page without a code is the interesting failure: say what it said instead.60		throw new Error(`no code on the connect page (${page.status}): ${body.slice(0, 300)}`);61	}62	return { page, body, code };63}6465async function redeem(app: ReturnType<typeof createApp>, code: string, address = "203.0.113.77") {66	const browser = new Browser(app, address);67	return browser.post("/api/connect", { code });68}6970describe.serial("connect configuration and page", () => {71	test("parseConfig rejects write keys beside a folder bucket and empty write values", () => {72		const hash = "$argon2id$v=19$m=65536,t=2,p=1$abc$abc";73		expect(() =>74			parseConfig(75				`domain = "kepr.example"\nadmin_password_hash = "${hash}"\n[bucket]\nfolder = "/tmp/x"\n[bucket.write]\naccess_key_id = "x"\nsecret_access_key = "y"`,76			),77		).toThrow();78		expect(() =>79			parseConfig(80				`domain = "kepr.example"\nadmin_password_hash = "${hash}"\n[bucket]\nendpoint = "https://x"\nname = "n"\nregion = "r"\naccess_key_id = "a"\nsecret_access_key = "s"\n[bucket.write]\naccess_key_id = ""\nsecret_access_key = "y"`,81			),82		).toThrow();83	});8485	test("folder buckets and S3 buckets without write keys hide all connect routes", async () => {86		for (const built of [await build(false), await build(false, true)]) {87			const browser = new Browser(built.app);88			expect((await browser.get("/connect?machine=x")).status).toBe(404);89			expect((await browser.post("/connect", { machine: "x" })).status).toBe(404);90			expect((await browser.post("/api/connect", { code: "ABCDEFGH" })).status).toBe(404);91		}92	});9394	test("a disabled connect route is an unknown path, byte for byte", async () => {95		const { app } = await build(false);96		const browser = new Browser(app);97		for (const [method, path] of [98			["GET", "/connect?machine=x"],99			["POST", "/connect"],100			["POST", "/api/connect"],101		] as const) {102			const asked = method === "GET" ? browser.get(path) : browser.post(path, {});103			const unknown =104				method === "GET" ? browser.get("/nothing/here") : browser.post("/nothing/here", {});105			const [found, absent] = await Promise.all([asked, unknown]);106			expect(`${found.status} ${await found.text()}`).toBe(107				`${absent.status} ${await absent.text()}`,108			);109			expect(found.headers.get("cache-control")).toBe(absent.headers.get("cache-control"));110		}111		// Even an oversized body is answered here the way the unknown path answers it.112		const big = { code: "x".repeat(70_000) };113		const oversized = await browser.post("/api/connect", big);114		const unrouted = await browser.post("/nothing/here", big);115		expect(`${oversized.status} ${await oversized.text()}`).toBe(116			`${unrouted.status} ${await unrouted.text()}`,117		);118		expect(oversized.headers.get("cache-control")).toBe(unrouted.headers.get("cache-control"));119	});120121	test("a signed-out request redirects before the machine name is judged", async () => {122		const { app } = await build();123		const browser = new Browser(app);124		const response = await browser.get(`/connect?machine=${encodeURIComponent("x".repeat(65))}`);125		expect(response.status).toBe(302);126		expect(response.headers.get("location")).toBe("/sign-in?next=%2Fconnect");127	});128129	test("connect redirects signed out, preserves next, and rejects unsafe next values", async () => {130		const built = await build();131		const browser = new Browser(built.app);132		const response = await browser.get("/connect?machine=desk%20%26%20rack");133		expect(response.status).toBe(302);134		for (const next of ["https://evil.example", "//evil.example", "/bad\n"]) {135			const sign = await browser.get(`/sign-in?next=${encodeURIComponent(next)}`);136			const html = await sign.text();137			expect(html).toContain('name="next" value="/admin"');138		}139		const sign = await browser.get("/sign-in?next=%2Fconnect%3Fmachine%3Ddesk");140		const form = /name="form" value="([^"]+)"/.exec(await sign.text())?.[1] ?? "";141		const wrong = await browser.request("/sign-in", {142			method: "POST",143			headers: { origin: ORIGIN },144			body: new URLSearchParams({ form, next: "/connect?machine=desk", password: "wrong" }),145		});146		expect(wrong.status).toBe(401);147		expect(await wrong.text()).toContain('name="next" value="/connect?machine=desk"');148		const good = await browser.request("/sign-in", {149			method: "POST",150			headers: { origin: ORIGIN },151			body: new URLSearchParams({ form, next: "/connect?machine=desk", password: PASSWORD }),152		});153		expect(good.status).toBe(303);154		expect(good.headers.get("location")).toBe("/connect?machine=desk");155	});156157	test("connect validates and escapes machine names, and never caches or exposes secrets", async () => {158		const { browser } = await connected();159		for (const machine of ["", "x".repeat(65), "bad\nname", "bad\u0085name", "bad\u202ename"])160			expect((await browser.get(`/connect?machine=${encodeURIComponent(machine)}`)).status).toBe(161				400,162			);163		const result = await issue(browser, "<img src=x onerror=alert(1)>");164		expect(result.page.status).toBe(200);165		expect(result.body).toContain("&lt;img src=x onerror=alert(1)&gt;");166		expect(result.body).not.toContain("WRITE-SECRET");167		expect(result.page.headers.get("cache-control")).toBe("no-store");168	});169});170171describe.serial("connect redemption", () => {172	test("stores only a hash, redeems once with exact credentials, normalizes code, and gives identical failures", async () => {173		const { app, browser, kepr, config } = await connected();174		const { code, body } = await issue(browser, "office");175		expect(code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/);176		expect(body).not.toContain("WRITE-SECRET");177		const row = kepr.query("SELECT code_hash, machine FROM connect_codes").get() as {178			code_hash: string;179			machine: string;180		};181		expect(row.code_hash).toMatch(/^[0-9a-f]{64}$/);182		expect(row.code_hash).not.toContain(code.replace("-", ""));183		expect(row.machine).toBe("office");184		const success = await redeem(app, code.toLowerCase(), "203.0.113.88");185		expect(success.status).toBe(200);186		expect(await success.json()).toEqual({187			endpoint: config.bucket.kind === "s3" ? config.bucket.endpoint : "",188			bucket: "my-bucket",189			region: "fr-par",190			access_key_id: "WRITE-ID",191			secret_access_key: "WRITE-SECRET",192		});193		expect(success.headers.get("cache-control")).toBe("no-store");194		const used = await redeem(app, code, "203.0.113.89");195		const unknown = await redeem(app, "ZZZZ-ZZZZ", "203.0.113.90");196		expect(used.status).toBe(404);197		expect(await used.text()).toBe(await unknown.text());198		expect(used.headers.get("cache-control")).toBe("no-store");199	});200201	test("connect POST requires session, origin and fresh form token", async () => {202		const built = await build();203		const browser = new Browser(built.app);204		expect((await browser.post("/connect", { machine: "x" })).status).toBe(401);205		await browser.signIn();206		expect((await browser.post("/connect", { machine: "x" }, "https://evil.example")).status).toBe(207			403,208		);209		expect((await browser.post("/connect", { machine: "x", form: "stale" })).status).toBe(403);210	});211212	test("expired, malformed and missing codes all return the same no-code response", async () => {213		const { app, browser } = await connected();214		const { code } = await issue(browser);215		clock += 601_000;216		setSystemTime(clock);217		const expired = await redeem(app, code, "198.51.100.201");218		const malformed = await redeem(app, "bad", "198.51.100.202");219		const missing = await new Browser(app, "198.51.100.203").request("/api/connect", {220			method: "POST",221			body: "not-a-form",222		});223		expect(expired.status).toBe(404);224		const expected = await expired.text();225		expect(expected).toBe('{"error":"no_code","message":"no code"}');226		expect(await malformed.text()).toBe(expected);227		expect(await missing.text()).toBe(expected);228	});229230	test("rate limits ten attempts per peer while another peer succeeds", async () => {231		const { app, browser } = await connected();232		const { code } = await issue(browser);233		for (let i = 0; i < 10; i++) await redeem(app, "bad", "192.0.2.40");234		const limited = await redeem(app, code, "192.0.2.40");235		expect(limited.status).toBe(404);236		const other = await redeem(app, code, "192.0.2.41");237		expect(other.status).toBe(200);238		// The window is an hour: once it has passed the same peer is served again.239		clock += 3_601_000;240		setSystemTime(clock);241		const { code: fresh } = await issue(browser, "later");242		expect((await redeem(app, fresh, "192.0.2.40")).status).toBe(200);243	});244245	test("a forged X-Forwarded-For does not buy another bucketful", async () => {246		const { app, browser } = await connected();247		const { code } = await issue(browser);248		for (let i = 0; i < 10; i++) await redeem(app, "bad", "192.0.2.50");249		const forged = await new Browser(app, "192.0.2.50").request("/api/connect", {250			method: "POST",251			headers: {252				"x-forwarded-for": "198.51.100.99",253				"content-type": "application/x-www-form-urlencoded",254			},255			body: new URLSearchParams({ code }),256		});257		expect(forged.status).toBe(404);258	});259260	test("two redemptions at once: exactly one wins", async () => {261		const { app, browser } = await connected();262		const { code } = await issue(browser, "race");263		const [first, second] = await Promise.all([264			redeem(app, code, "203.0.113.101"),265			redeem(app, code, "203.0.113.102"),266		]);267		expect([first.status, second.status].sort()).toEqual([200, 404]);268	});269270	test("an oversized redemption is refused with the same no-code body", async () => {271		const { app } = await connected();272		const response = await new Browser(app, "192.0.2.60").post("/api/connect", {273			code: "x".repeat(70_000),274		});275		expect(response.status).toBe(404);276		expect(await response.text()).toBe('{"error":"no_code","message":"no code"}');277		expect(response.headers.get("cache-control")).toBe("no-store");278	});279280	test("a backup carries the hashed code and never the write key", async () => {281		const { browser, kepr, config } = await connected();282		const { code } = await issue(browser, "laptop");283		const name = makeBackup(kepr, config.dataDir);284		const file = join(config.dataDir, "backups", name, "kepr.db");285		const copy = new Database(file, { readonly: true });286		const rows = copy.query("SELECT machine, code_hash FROM connect_codes").all();287		copy.close();288		expect(rows.length).toBe(1);289		expect(JSON.stringify(rows)).not.toContain(code.replace("-", ""));290		// The write key is read from kepr.toml alone, so no copy of kepr.db can hold it.291		const bytes = await Bun.file(file).bytes();292		expect(new TextDecoder().decode(bytes)).not.toContain("WRITE-SECRET");293	});294});295296describe.serial("connected machines in admin", () => {297	test("lists redeemed machines newest first, at most twenty, with revocation guidance", async () => {298		const { app, browser } = await connected();299		for (let i = 0; i < 22; i++) {300			const { code } = await issue(browser, `machine-${i}`);301			const response = await redeem(app, code, `10.0.0.${i + 1}`);302			expect(response.status).toBe(200);303			clock += 1000;304			setSystemTime(clock);305		}306		const admin = await browser.get("/admin");307		const html = await admin.text();308		expect(html).toContain("Machines connected");309		expect(html).toContain("rotate the write key in Scaleway");310		expect(html).not.toContain("machine-0 ");311		expect(html).toContain("machine-21");312		expect(html.indexOf("machine-21")).toBeLessThan(html.indexOf("machine-20"));313	});314});