Open the API's first write surface. Workstations push their CPU / memory /
disk snapshot, the admin dashboard reads it back with a server-computed
freshness.
Routing moves from an exact path list to a descriptor table, keeping ONE
authentication checkpoint:
resolveRoute() -> no match means an immediate 404, deny by default
checkAuth() -> the single gate; only the expected token varies per
descriptor (HEALTH_TOKEN for reads, HOSTS_INGEST_TOKEN
for ingestion)
dispatch
The routing-before-auth ordering is preserved on purpose: an unknown path or
a wrong method still answers 404, never 401, exactly as before. The two
`404 (not 401)` tests added in #12 pin that ordering and still pass.
Security controls on the new write path:
- The route regex ^/hosts/([a-z0-9][a-z0-9-]{0,31})$, POST-only, IS the path
traversal control. URL() normalises /hosts/../../etc/passwd to /etc/passwd
and encoded traversals fail the match, so every hostile id lands on 404.
403 is reserved for well-formed ids outside the allowlist.
- 401 wins over 403, so an unauthenticated caller cannot probe the allowlist.
- tokenMatches() compares SHA-256 digests with timingSafeEqual. Scoped to the
ingestion path only; realigning HEALTH_TOKEN touches every read route in
production and is tracked separately.
- Body capped at 4 KiB by counting received bytes, never Content-Length:
a client can lie in the header and chunked encoding omits it. Past the cap,
413 then req.destroy() once the response has flushed.
- The persisted object is rebuilt field by field from a whitelist — finite
numbers, strings capped at 128 chars, everything else dropped — because the
file is read back by GET /hosts and ends up in the admin React tree. The
read path re-runs the same whitelist: the bind-mount is writable, so the
file on disk earns no more trust than the payload did.
- Snapshots are written to a temp file in the same directory then renamed, so
a concurrent GET /hosts can never observe a truncated JSON.
- HOSTS_ALLOWED_IDS entries are validated at startup with the same regex;
rejects are logged and dropped rather than becoming file paths.
- Every 401/403 is logged with X-Real-IP, the host id and the reason. Log
values are filtered to printable ASCII so a crafted header cannot forge
extra log lines.
- receivedAt is stamped by the server on arrival; a client-supplied timestamp
is discarded by the whitelist. online = ageSeconds <= HOSTS_STALE_SECONDS.
Runtime stays zero-dependency — node:crypto is a builtin and no new module
file was added, so the Dockerfile's explicit COPY list is unchanged.
Tests: 61 (39 existing untouched + 22 new). __tests__/hosts.test.js is smoke
coverage of the decisions that would be silent to regress; the exhaustive
matrix is issue #14.
Docs: .env.example gains HOSTS_DIR / HOSTS_ALLOWED_IDS / HOSTS_INGEST_TOKEN /
HOSTS_STALE_SECONDS; CLAUDE.md and README.md document the endpoints, the
routing/auth ordering and the config. The CLAUDE.md "read-only" gotcha is
corrected — the API now writes, and HOSTS_DIR must be writable by uid 1000.
Resolves #13
277 lines
10 KiB
JavaScript
277 lines
10 KiB
JavaScript
const http = require("node:http");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
|
|
// Smoke coverage for the workstation snapshot surface (issue #13).
|
|
//
|
|
// This is deliberately NOT the exhaustive matrix — that is issue #14. What is
|
|
// pinned here is the handful of decisions that would be expensive to get wrong
|
|
// and silent to regress: the routing-before-auth ordering, the narrow host id
|
|
// regex as the traversal control, 401 winning over 403, the whitelist rebuild,
|
|
// and the server-stamped freshness.
|
|
|
|
const TOKEN = "test-token";
|
|
const INGEST_TOKEN = "test-ingest-token";
|
|
|
|
let tmpDir;
|
|
let hostsDir;
|
|
let server;
|
|
let baseUrl;
|
|
let handler;
|
|
|
|
function validSnapshot() {
|
|
return {
|
|
hostname: "thinkpad-x1",
|
|
uptime: 4242,
|
|
cpu: { model: "Intel Core i7", cores: 8, loadAvg: [0.5, 0.4, 0.3], usagePercent: 12 },
|
|
memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 },
|
|
disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 },
|
|
};
|
|
}
|
|
|
|
function startServer() {
|
|
// Fresh import so module-level constants pick up the stubbed env.
|
|
delete require.cache[require.resolve("../index.js")];
|
|
({ handler } = require("../index.js"));
|
|
server = http.createServer(handler);
|
|
return new Promise((resolve) => {
|
|
server.listen(0, () => {
|
|
const { port } = server.address();
|
|
baseUrl = `http://127.0.0.1:${port}`;
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function stopServer() {
|
|
return new Promise((resolve) => {
|
|
if (!server) return resolve();
|
|
server.close(() => resolve());
|
|
});
|
|
}
|
|
|
|
async function request(route, { method = "GET", auth, body } = {}) {
|
|
const headers = {};
|
|
if (auth) headers.Authorization = auth;
|
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
const res = await fetch(`${baseUrl}${route}`, { method, headers, body });
|
|
const parsed = await res.json().catch(() => ({}));
|
|
return { status: res.status, body: parsed };
|
|
}
|
|
|
|
function ingest(route, { auth = `Bearer ${INGEST_TOKEN}`, body = JSON.stringify(validSnapshot()) } = {}) {
|
|
return request(route, { method: "POST", auth, body });
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-hosts-test-"));
|
|
hostsDir = path.join(tmpDir, "hosts");
|
|
|
|
process.env.HEALTH_TOKEN = TOKEN;
|
|
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
|
|
process.env.HOSTS_DIR = hostsDir;
|
|
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
|
|
delete process.env.HOSTS_STALE_SECONDS;
|
|
process.env.REPORTS_DIR = path.join(tmpDir, "reports");
|
|
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json");
|
|
process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.json");
|
|
|
|
await startServer();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await stopServer();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
delete process.env.HOSTS_INGEST_TOKEN;
|
|
delete process.env.HOSTS_DIR;
|
|
delete process.env.HOSTS_ALLOWED_IDS;
|
|
delete process.env.HOSTS_STALE_SECONDS;
|
|
});
|
|
|
|
describe("POST /hosts/<id> — routing is the traversal control", () => {
|
|
// Every one of these must be 404, not 400/403: the request never reaches a
|
|
// handler. URL() normalises the traversal away, the rest fail the regex.
|
|
const REJECTED = [
|
|
["dot-dot traversal", "/hosts/../../etc/passwd"],
|
|
["encoded traversal", "/hosts/..%2f..%2fetc%2fpasswd"],
|
|
["uppercase id", "/hosts/THINKPAD"],
|
|
["id with a slash", "/hosts/thinkpad/extra"],
|
|
["empty id", "/hosts/"],
|
|
];
|
|
|
|
test.each(REJECTED)("404 on POST %s", async (_label, route) => {
|
|
const { status, body } = await ingest(route);
|
|
expect(status).toBe(404);
|
|
expect(body.error).toBe("Not found");
|
|
});
|
|
|
|
test("404 on GET /hosts/thinkpad — the id route is POST-only", async () => {
|
|
const { status } = await request("/hosts/thinkpad", { auth: `Bearer ${TOKEN}` });
|
|
expect(status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("POST /hosts/<id> — auth gate", () => {
|
|
test("401 with no Authorization header", async () => {
|
|
const { status, body } = await ingest("/hosts/thinkpad", { auth: null });
|
|
expect(status).toBe(401);
|
|
expect(body.error).toBe("Unauthorized");
|
|
});
|
|
|
|
test("401 when presenting the read token instead of the ingest token", async () => {
|
|
const { status } = await ingest("/hosts/thinkpad", { auth: `Bearer ${TOKEN}` });
|
|
expect(status).toBe(401);
|
|
});
|
|
|
|
// 401 must win over 403 so an unauthenticated caller cannot probe the
|
|
// allowlist for valid workstation ids.
|
|
test("401 (not 403) on a non-allowlisted id without a token", async () => {
|
|
const { status } = await ingest("/hosts/popos", { auth: null });
|
|
expect(status).toBe(401);
|
|
});
|
|
|
|
test("403 on a well-formed id outside the allowlist", async () => {
|
|
const { status, body } = await ingest("/hosts/popos");
|
|
expect(status).toBe(403);
|
|
expect(body.error).toBe("Forbidden");
|
|
});
|
|
|
|
test("503 when HOSTS_INGEST_TOKEN is unset (fail-closed)", async () => {
|
|
delete process.env.HOSTS_INGEST_TOKEN;
|
|
await stopServer();
|
|
await startServer();
|
|
const { status, body } = await ingest("/hosts/thinkpad");
|
|
expect(status).toBe(503);
|
|
expect(body.error).toBe("HOSTS_INGEST_TOKEN not configured");
|
|
});
|
|
});
|
|
|
|
describe("POST /hosts/<id> — body handling", () => {
|
|
test("204 and an atomically written file on a valid snapshot", async () => {
|
|
const { status } = await ingest("/hosts/thinkpad");
|
|
expect(status).toBe(204);
|
|
const written = JSON.parse(fs.readFileSync(path.join(hostsDir, "thinkpad.json"), "utf-8"));
|
|
expect(written.hostname).toBe("thinkpad-x1");
|
|
expect(typeof written.receivedAt).toBe("string");
|
|
// No leftover temp file next to the snapshot.
|
|
expect(fs.readdirSync(hostsDir)).toEqual(["thinkpad.json"]);
|
|
});
|
|
|
|
test("the persisted object is rebuilt from the whitelist, not stored verbatim", async () => {
|
|
const raw = `{"__proto__":{"polluted":true},"extra":"nope","receivedAt":"1999-01-01T00:00:00.000Z",${JSON.stringify(
|
|
{ ...validSnapshot(), hostname: "h".repeat(300) },
|
|
).slice(1)}`;
|
|
const { status } = await ingest("/hosts/thinkpad", { body: raw });
|
|
expect(status).toBe(204);
|
|
|
|
const written = JSON.parse(fs.readFileSync(path.join(hostsDir, "thinkpad.json"), "utf-8"));
|
|
expect(Object.keys(written)).toEqual([
|
|
"id",
|
|
"hostname",
|
|
"uptime",
|
|
"cpu",
|
|
"memory",
|
|
"disk",
|
|
"receivedAt",
|
|
]);
|
|
expect(written.extra).toBeUndefined();
|
|
expect(written.polluted).toBeUndefined();
|
|
expect({}.polluted).toBeUndefined();
|
|
// Strings capped at 128 chars, timestamp stamped by the server.
|
|
expect(written.hostname.length).toBe(128);
|
|
expect(written.receivedAt).not.toBe("1999-01-01T00:00:00.000Z");
|
|
expect(Date.now() - new Date(written.receivedAt).getTime()).toBeLessThan(10000);
|
|
});
|
|
|
|
test("400 on unparsable JSON", async () => {
|
|
const { status } = await ingest("/hosts/thinkpad", { body: "{not json" });
|
|
expect(status).toBe(400);
|
|
});
|
|
|
|
test("400 on a missing or mistyped field", async () => {
|
|
const payload = validSnapshot();
|
|
delete payload.memory;
|
|
const { status, body } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) });
|
|
expect(status).toBe(400);
|
|
expect(body.error).toMatch(/memory/);
|
|
expect(fs.existsSync(path.join(hostsDir, "thinkpad.json"))).toBe(false);
|
|
});
|
|
|
|
test("413 past 4 KiB, and nothing is written", async () => {
|
|
const payload = { ...validSnapshot(), padding: "x".repeat(5000) };
|
|
let status;
|
|
try {
|
|
({ status } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) }));
|
|
} catch {
|
|
// The socket is destroyed right after the 413 flushes; a client that
|
|
// loses the race sees a transport error instead. Either way the write
|
|
// must not have happened, which is what the assertion below pins.
|
|
status = 413;
|
|
}
|
|
expect(status).toBe(413);
|
|
expect(fs.existsSync(path.join(hostsDir, "thinkpad.json"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("GET /hosts", () => {
|
|
test("401 when presenting the ingest token instead of the read token", async () => {
|
|
const { status } = await request("/hosts", { auth: `Bearer ${INGEST_TOKEN}` });
|
|
expect(status).toBe(401);
|
|
});
|
|
|
|
test("reports an allowlisted host that never checked in", async () => {
|
|
const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(status).toBe(200);
|
|
expect(body.staleAfterSeconds).toBe(900);
|
|
expect(body.hosts).toHaveLength(1);
|
|
expect(body.hosts[0]).toMatchObject({ id: "thinkpad", neverSeen: true, online: false });
|
|
});
|
|
|
|
test("serves the snapshot back with a server-computed freshness", async () => {
|
|
await ingest("/hosts/thinkpad");
|
|
const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(status).toBe(200);
|
|
const host = body.hosts[0];
|
|
expect(host.neverSeen).toBeUndefined();
|
|
expect(host.hostname).toBe("thinkpad-x1");
|
|
expect(host.ageSeconds).toBeLessThan(5);
|
|
expect(host.online).toBe(true);
|
|
expect(Object.keys(host.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
|
|
});
|
|
|
|
test("online flips to false past HOSTS_STALE_SECONDS", async () => {
|
|
fs.mkdirSync(hostsDir, { recursive: true });
|
|
const stale = {
|
|
id: "thinkpad",
|
|
...validSnapshot(),
|
|
receivedAt: new Date(Date.now() - 3600 * 1000).toISOString(),
|
|
};
|
|
fs.writeFileSync(path.join(hostsDir, "thinkpad.json"), JSON.stringify(stale));
|
|
|
|
const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(body.hosts[0].online).toBe(false);
|
|
expect(body.hosts[0].ageSeconds).toBeGreaterThan(3000);
|
|
});
|
|
|
|
test("degrades a corrupted snapshot instead of failing the whole response", async () => {
|
|
fs.mkdirSync(hostsDir, { recursive: true });
|
|
fs.writeFileSync(path.join(hostsDir, "thinkpad.json"), "{ truncated");
|
|
|
|
const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(status).toBe(200);
|
|
expect(body.hosts).toHaveLength(1);
|
|
expect(body.hosts[0].neverSeen).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("HOSTS_ALLOWED_IDS validation", () => {
|
|
test("entries that are not valid host ids are dropped at startup", async () => {
|
|
process.env.HOSTS_ALLOWED_IDS = "thinkpad, ../defenseurs/status ,POPOS,,popos";
|
|
await stopServer();
|
|
await startServer();
|
|
|
|
const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(body.hosts.map((h) => h.id)).toEqual(["popos", "thinkpad"]);
|
|
});
|
|
});
|