57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
163 lines
5.8 KiB
JavaScript
163 lines
5.8 KiB
JavaScript
/**
|
|
* @file security.test.js
|
|
* @description Tests the network-exposure hardening (GHSA-gr74-4xfh-6jw9):
|
|
* loopback-by-default bind, Host-header allowlist (anti DNS-rebinding),
|
|
* loopback-only CORS, and the optional bearer-token gate on /api/* + WebSocket.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
const { describe, it, afterEach } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const sec = require("../lib/security");
|
|
|
|
const ENV_KEYS = ["DASHBOARD_HOST", "DASHBOARD_ALLOWED_HOSTS", "DASHBOARD_TOKEN"];
|
|
afterEach(() => {
|
|
for (const k of ENV_KEYS) delete process.env[k];
|
|
});
|
|
|
|
function mockRes() {
|
|
return {
|
|
statusCode: 0,
|
|
body: null,
|
|
status(c) {
|
|
this.statusCode = c;
|
|
return this;
|
|
},
|
|
json(b) {
|
|
this.body = b;
|
|
return this;
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("resolveHost", () => {
|
|
it("defaults to loopback (127.0.0.1)", () => {
|
|
assert.equal(sec.resolveHost(), "127.0.0.1");
|
|
});
|
|
it("honors an explicit DASHBOARD_HOST opt-in", () => {
|
|
process.env.DASHBOARD_HOST = "0.0.0.0";
|
|
assert.equal(sec.resolveHost(), "0.0.0.0");
|
|
assert.equal(sec.isLoopbackHostname("0.0.0.0"), true); // treated as loopback-equiv for Host checks
|
|
});
|
|
});
|
|
|
|
describe("Host allowlist (DNS-rebinding defense)", () => {
|
|
it("allows loopback Host headers", () => {
|
|
assert.equal(sec.isHostAllowed("localhost:4820"), true);
|
|
assert.equal(sec.isHostAllowed("127.0.0.1:4820"), true);
|
|
assert.equal(sec.isHostAllowed("[::1]:4820"), true);
|
|
assert.equal(sec.isHostAllowed(""), true); // missing Host (HTTP/1.0 / local tooling)
|
|
});
|
|
it("rejects a rebound attacker Host", () => {
|
|
assert.equal(sec.isHostAllowed("evil.example"), false);
|
|
assert.equal(sec.isHostAllowed("attacker.example:4820"), false);
|
|
});
|
|
it("permits operator-allowlisted hostnames", () => {
|
|
process.env.DASHBOARD_ALLOWED_HOSTS = "dash.internal, 192.168.1.50";
|
|
assert.equal(sec.isHostAllowed("dash.internal:4820"), true);
|
|
assert.equal(sec.isHostAllowed("192.168.1.50:4820"), true);
|
|
assert.equal(sec.isHostAllowed("evil.example"), false);
|
|
});
|
|
it("hostGuard middleware 403s a disallowed Host", () => {
|
|
const res = mockRes();
|
|
let nexted = false;
|
|
sec.hostGuard({ headers: { host: "evil.example" } }, res, () => (nexted = true));
|
|
assert.equal(nexted, false);
|
|
assert.equal(res.statusCode, 403);
|
|
assert.equal(res.body.error.code, "EBADHOST");
|
|
});
|
|
it("hostGuard middleware allows loopback", () => {
|
|
let nexted = false;
|
|
sec.hostGuard({ headers: { host: "localhost:4820" } }, mockRes(), () => (nexted = true));
|
|
assert.equal(nexted, true);
|
|
});
|
|
});
|
|
|
|
describe("CORS", () => {
|
|
const allowed = (origin) =>
|
|
new Promise((resolve) => sec.corsOptions().origin(origin, (_e, ok) => resolve(ok)));
|
|
|
|
it("allows same-origin / no-Origin (curl, the server's own client)", async () => {
|
|
assert.equal(await allowed(undefined), true);
|
|
});
|
|
it("allows loopback origins", async () => {
|
|
assert.equal(await allowed("http://localhost:5173"), true);
|
|
assert.equal(await allowed("http://127.0.0.1:4820"), true);
|
|
});
|
|
it("refuses cross-origin pages", async () => {
|
|
assert.equal(await allowed("https://evil.example"), false);
|
|
});
|
|
});
|
|
|
|
describe("token gate (optional, opt-in)", () => {
|
|
it("is a no-op when DASHBOARD_TOKEN is unset (default)", () => {
|
|
let nexted = false;
|
|
sec.tokenGuard({ path: "/stats", headers: {}, query: {} }, mockRes(), () => (nexted = true));
|
|
assert.equal(nexted, true);
|
|
});
|
|
|
|
it("rejects a missing/invalid token when configured", () => {
|
|
process.env.DASHBOARD_TOKEN = "s3cret";
|
|
const res = mockRes();
|
|
let nexted = false;
|
|
sec.tokenGuard({ path: "/stats", headers: {}, query: {} }, res, () => (nexted = true));
|
|
assert.equal(nexted, false);
|
|
assert.equal(res.statusCode, 401);
|
|
assert.equal(res.body.error.code, "EUNAUTHORIZED");
|
|
|
|
const res2 = mockRes();
|
|
sec.tokenGuard(
|
|
{ path: "/stats", headers: { "x-dashboard-token": "wrong" }, query: {} },
|
|
res2,
|
|
() => {}
|
|
);
|
|
assert.equal(res2.statusCode, 401);
|
|
});
|
|
|
|
it("accepts a correct token via header, bearer, or query", () => {
|
|
process.env.DASHBOARD_TOKEN = "s3cret";
|
|
const ok = (req) => {
|
|
let nexted = false;
|
|
sec.tokenGuard(req, mockRes(), () => (nexted = true));
|
|
return nexted;
|
|
};
|
|
assert.equal(
|
|
ok({ path: "/stats", headers: { "x-dashboard-token": "s3cret" }, query: {} }),
|
|
true
|
|
);
|
|
assert.equal(
|
|
ok({ path: "/stats", headers: { authorization: "Bearer s3cret" }, query: {} }),
|
|
true
|
|
);
|
|
assert.equal(ok({ path: "/stats", headers: {}, query: { token: "s3cret" } }), true);
|
|
});
|
|
|
|
it("exempts health, docs, and local hook ingestion even when a token is set", () => {
|
|
process.env.DASHBOARD_TOKEN = "s3cret";
|
|
const ok = (path) => {
|
|
let nexted = false;
|
|
sec.tokenGuard({ path, headers: {}, query: {} }, mockRes(), () => (nexted = true));
|
|
return nexted;
|
|
};
|
|
assert.equal(ok("/health"), true);
|
|
assert.equal(ok("/openapi.json"), true);
|
|
assert.equal(ok("/hooks/event"), true);
|
|
assert.equal(ok("/sessions/abc"), false); // still gated
|
|
});
|
|
});
|
|
|
|
describe("WebSocket auth", () => {
|
|
it("allows any upgrade when no token is configured", () => {
|
|
assert.equal(sec.isWebSocketAuthorized({ url: "/ws", headers: {} }), true);
|
|
});
|
|
it("requires a matching ?token= when configured", () => {
|
|
process.env.DASHBOARD_TOKEN = "s3cret";
|
|
assert.equal(sec.isWebSocketAuthorized({ url: "/ws?token=s3cret", headers: {} }), true);
|
|
assert.equal(sec.isWebSocketAuthorized({ url: "/ws?token=nope", headers: {} }), false);
|
|
assert.equal(sec.isWebSocketAuthorized({ url: "/ws", headers: {} }), false);
|
|
assert.equal(
|
|
sec.isWebSocketAuthorized({ url: "/ws", headers: { "x-dashboard-token": "s3cret" } }),
|
|
true
|
|
);
|
|
});
|
|
});
|