Stop the config API accepting any key and any value

Adversarial pass, first finding. `PUT /api/v1/config/:key` took the key from
the URL and the value from the body with no validation beyond blocking two
credential verifiers. Two ways that goes wrong, both silent:

A typo is stored forever. `inactivity_timout` was accepted, written into the
encrypted config, and answered ok — while the setting it was meant to change
never moved. Nothing downstream reads an unknown key, so this failure can
only ever be silent; the caller has no way to find out.

A wrong type is stored verbatim. `inactivity_timeout: "soon"` turns every
later comparison into NaN, which compares false, so the vault simply never
times out. That is a security setting failing open because a string reached
a numeric field.

There is now an allowlist of 23 keys, each with a normalizer that rejects
values it cannot make sense of: negative delays, a zero inactivity timeout
(indistinguishable from a broken vault), an empty relay list (not a
configuration, an outage), a string where an array belongs. Unknown keys get
Python's 400 "Unknown key"; the credential verifiers move from 400 to 403,
matching Python, since the key is real and the refusal is about authority.

The allowlist is deliberately a SUPERSET of Python's, which cannot set the
password-policy keys this implementation honours. Copying Python exactly
would have deleted working behaviour to reproduce a limitation. Recorded in
TODO.md as a divergence to settle rather than left as an accident.

Separately: the shared API test fixture was inheriting the default 100
requests/minute budget and had grown past it, so unrelated tests failed with
429 depending on how many requests ran before them. Rate limiting is tested
on its own servers with explicit budgets, so the fixture no longer enforces
one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-19 19:34:23 -04:00
1 parent d6ffbac312
commit ae3889bfca
4 files changed
+210 -9

No files matched your search

+62 -3
View File
@@ -123,7 +123,12 @@ beforeAll(async () => {
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(index))),
);
server = new ApiServer({ host: "127.0.0.1", port: 0, token: TOKEN });
// Generous on purpose. The default budget is 100 requests a minute, and
// this file passed it as it grew — which made unrelated tests fail with
// 429 depending on how many requests ran before them. Rate limiting has
// its own tests below, each on a server with a budget it sets explicitly,
// so the shared fixture should not be enforcing one at all.
server = new ApiServer({ host: "127.0.0.1", port: 0, token: TOKEN, rateLimit: 100_000 });
ctx = buildContext({ app, fingerprint: FINGERPRINT, mnemonic: MNEMONIC });
registerRoutes(server, ctx);
const bound = await server.listen();
@@ -206,12 +211,66 @@ describe("what the API refuses", () => {
for (const key of ["password_hash", "pin_hash"]) {
const got = await call("GET", `/api/v1/config/${key}`);
expect(got.json.value).toBe("<redacted>");
// And it cannot be set either — nothing legitimate needs to.
// And it cannot be set either — nothing legitimate needs to. 403, not
// 400: the key is real and the refusal is about authority, which is
// also the status Python answers.
const set = await call("PUT", `/api/v1/config/${key}`, { body: { value: "x" } });
expect(set.status).toBe(400);
expect(set.status).toBe(403);
}
});
it("refuses a config key it does not know, instead of storing it forever", async () => {
// This route used to take the key from the URL and the value from the
// body with no validation at all, so `inactivity_timout` was accepted,
// written into the encrypted config permanently, and reported as ok —
// while the setting it was meant to change never moved. Nothing reads an
// unknown key, so the failure could only ever be silent.
const typo = await call("PUT", "/api/v1/config/inactivity_timout", {
body: { value: 60 },
});
expect(typo.status).toBe(400);
expect(typo.json.detail).toContain("Unknown key");
expect((await call("GET", "/api/v1/config/inactivity_timout")).json.value).toBeNull();
// Nor a key that only LOOKS like an object property.
expect(
(await call("PUT", "/api/v1/config/__proto__", { body: { value: {} } })).status,
).toBe(400);
expect(
(await call("PUT", "/api/v1/config/constructor", { body: { value: 1 } })).status,
).toBe(400);
});
it("refuses a value of the wrong type for a key it does know", async () => {
// The allowlist alone is not enough: `inactivity_timeout: "soon"` used to
// be stored verbatim and turn every later comparison into NaN, which
// compares false — so the vault would simply never time out.
for (const [key, value] of [
["inactivity_timeout", "soon"],
["inactivity_timeout", 0],
["clipboard_clear_delay", -1],
["relays", "wss://one.example"],
["relays", []],
["min_uppercase", "lots"],
] as const) {
const res = await call("PUT", `/api/v1/config/${key}`, { body: { value } });
expect(res.status).toBe(400);
}
// And the well-typed versions still go through, so the check is
// rejecting the value rather than the key.
expect(
(await call("PUT", "/api/v1/config/inactivity_timeout", { body: { value: 120 } }))
.status,
).toBe(200);
expect((await call("GET", "/api/v1/config/inactivity_timeout")).json.value).toBe(120);
expect(
(await call("PUT", "/api/v1/config/relays", {
body: { value: ["wss://one.example"] },
})).status,
).toBe(200);
});
it("caps the request body rather than buffering whatever arrives", async () => {
const small = new ApiServer({ host: "127.0.0.1", port: 0, token: TOKEN, maxBodyBytes: 64 });
const smallCtx = buildContext({ app, fingerprint: FINGERPRINT, mnemonic: MNEMONIC });