fix(review): close the three gaps found in stack review #23
4 changed files with 71 additions and 9 deletions
|
|
@ -16,8 +16,14 @@ const path = require("node:path");
|
||||||
|
|
||||||
const TOKEN = "test-token";
|
const TOKEN = "test-token";
|
||||||
|
|
||||||
// Every route reachable by the handler. Any new route must be added here.
|
// Every GET route reachable by the handler. Any new route must be added here.
|
||||||
const ROUTES = ["/health", "/defenseurs", "/defenseurs/findings", "/reports/scans"];
|
// POST /hosts/<id> is not a GET, so it gets its own describe block below —
|
||||||
|
// but it is covered, and any future route must be too.
|
||||||
|
const ROUTES = ["/health", "/defenseurs", "/defenseurs/findings", "/reports/scans", "/hosts"];
|
||||||
|
|
||||||
|
// Ingest token, distinct from the read token on purpose: a read token must not
|
||||||
|
// open the write path, and a write token must not open the read routes.
|
||||||
|
const INGEST_TOKEN = "test-ingest-token";
|
||||||
|
|
||||||
let tmpDir;
|
let tmpDir;
|
||||||
let server;
|
let server;
|
||||||
|
|
@ -67,6 +73,11 @@ beforeEach(async () => {
|
||||||
process.env.REPORTS_DIR = path.join(tmpDir, "reports");
|
process.env.REPORTS_DIR = path.join(tmpDir, "reports");
|
||||||
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json");
|
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json");
|
||||||
process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.json");
|
process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.json");
|
||||||
|
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
|
||||||
|
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
|
||||||
|
// Configured on purpose: an unset ingest token answers 503 before the header
|
||||||
|
// is ever read, which would hide the 401 these tests are here to pin.
|
||||||
|
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
|
||||||
// Closed port: /health must never reach the real IdP. If the auth gate ever
|
// Closed port: /health must never reach the real IdP. If the auth gate ever
|
||||||
// fails open, the request errors out fast instead of hitting production.
|
// fails open, the request errors out fast instead of hitting production.
|
||||||
process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
|
process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
|
||||||
|
|
@ -152,3 +163,39 @@ describe("routing", () => {
|
||||||
expect(body.error).toBe("Not found");
|
expect(body.error).toBe("Not found");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The ingest path is the only write surface on this service, and the only one
|
||||||
|
// reachable with a token that is NOT the read token. Both directions of that
|
||||||
|
// separation are pinned here: a read token must not write, a write token must
|
||||||
|
// not read. The gate answers before the request body is ever buffered, so an
|
||||||
|
// unauthenticated caller cannot make the server hold 4 KiB on its behalf.
|
||||||
|
describe("auth gate — ingest route", () => {
|
||||||
|
test("401 on POST /hosts/<id> with no Authorization header", async () => {
|
||||||
|
const { status, body } = await request("/hosts/thinkpad", { method: "POST", auth: null });
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body.error).toBe("Unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("401 on POST /hosts/<id> with the read token", async () => {
|
||||||
|
const { status, body } = await request("/hosts/thinkpad", {
|
||||||
|
method: "POST",
|
||||||
|
auth: `Bearer ${TOKEN}`,
|
||||||
|
});
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body.error).toBe("Unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("401 on GET /hosts with the ingest token", async () => {
|
||||||
|
const { status, body } = await request("/hosts", { auth: `Bearer ${INGEST_TOKEN}` });
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body.error).toBe("Unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
// An unknown host id never reaches the handler: the route regex is the
|
||||||
|
// path-traversal control, so a malformed id is a 404, not a 403 — and that
|
||||||
|
// 404 arrives before authentication, like every other routing decision.
|
||||||
|
test("404 (not 401, not 403) on POST /hosts/../../etc/passwd", async () => {
|
||||||
|
const { status } = await request("/hosts/../../etc/passwd", { method: "POST", auth: null });
|
||||||
|
expect(status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -164,8 +164,13 @@ async function ingestOversized(route, body) {
|
||||||
try {
|
try {
|
||||||
const { status } = await ingest(route, { body });
|
const { status } = await ingest(route, { body });
|
||||||
return status;
|
return status;
|
||||||
} catch {
|
} catch (err) {
|
||||||
return 413;
|
// A dead socket is NOT a 413. The whole client contract on this path is
|
||||||
|
// "you get a status code, not a transport error" — so surface the failure
|
||||||
|
// instead of folding it into the expected value. Returning 413 here would
|
||||||
|
// keep these tests green even if the server stopped writing the status and
|
||||||
|
// merely destroyed the connection.
|
||||||
|
return `transport error instead of a status: ${err.cause?.code || err.code || err.message}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,15 +68,22 @@ HOSTS_API_URL=https://health.lacompagniemaximus.com
|
||||||
HOSTS_INGEST_TOKEN=<the ingest token>
|
HOSTS_INGEST_TOKEN=<the ingest token>
|
||||||
HOST_ID=thinkpad
|
HOST_ID=thinkpad
|
||||||
# NODE_BIN=/usr/bin/node # only if cron cannot find node (see below)
|
# NODE_BIN=/usr/bin/node # only if cron cannot find node (see below)
|
||||||
|
# HOST_AGENT_TIMEOUT_MS=10000 # push timeout, wall-clock; default 10000
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
chmod 600 ~/.config/maximus-host-agent.env
|
chmod 600 ~/.config/maximus-host-agent.env
|
||||||
```
|
```
|
||||||
|
|
||||||
`HOSTS_INGEST_TOKEN` is the write-only token: it can push snapshots and nothing
|
`HOSTS_INGEST_TOKEN` is the write-only token: it can push snapshots for an
|
||||||
else. It is deliberately *not* `HEALTH_TOKEN` — a stolen workstation must not
|
allowlisted id and nothing else. It is deliberately *not* `HEALTH_TOKEN`, so
|
||||||
grant read access to the Defenseurs reports.
|
that the two can be rotated independently and so a workstation that only pushes
|
||||||
|
never needs the read token.
|
||||||
|
|
||||||
|
Do not read more into that separation than it gives you. On the ThinkPad it
|
||||||
|
buys no containment at all: that machine already stores `HEALTH_TOKEN` in
|
||||||
|
cleartext for the `defenseur-auto` cron, so losing the laptop compromises both
|
||||||
|
tokens. **Rotate them together** — see `secret-rotation-ops.md`.
|
||||||
|
|
||||||
`HOST_ID` must match `^[a-z0-9][a-z0-9-]{0,31}$` and be listed in the server's
|
`HOST_ID` must match `^[a-z0-9][a-z0-9-]{0,31}$` and be listed in the server's
|
||||||
`HOSTS_ALLOWED_IDS`, otherwise the push comes back `403`.
|
`HOSTS_ALLOWED_IDS`, otherwise the push comes back `403`.
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,9 @@ if ! command -v "$NODE_BIN" >/dev/null 2>&1; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export HOSTS_API_URL HOSTS_INGEST_TOKEN HOST_ID
|
# HOST_AGENT_TIMEOUT_MS is optional and read by push-metrics.js. It must be
|
||||||
|
# exported too, otherwise setting it in the env file silently does nothing and
|
||||||
|
# the agent always runs on its 10s default.
|
||||||
|
export HOSTS_API_URL HOSTS_INGEST_TOKEN HOST_ID HOST_AGENT_TIMEOUT_MS
|
||||||
|
|
||||||
exec "$NODE_BIN" "$AGENT" "$@"
|
exec "$NODE_BIN" "$AGENT" "$@"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue