fix(auth): address Copilot review round 3 on the secret-store PR
Nine findings (six inline, three suppressed), all taken. Two were serious. **The memory fallback made the migrations destructive.** Both migrations — mcp.json in the remote server, client.json in `node-persistence` — lift a plaintext secret off disk, write it to the secret store, and then delete it from the file. That trade is safe only while the store is at least as durable as the disk, which held by construction while `InMemorySecretStore` was a test double. Making it a production fallback broke it: on an unmounted container a plain `GET /api/servers` would have moved a user's existing secrets into RAM and lost them at exit — destroying data on a read. `SecretStore` gains an optional `isDurable()`, absent meaning durable, and both migrations withhold the disk delete when it answers false (the value is still loaded, so the session behaves normally). The production fallback is now a named `SessionSecretStore` rather than the bare `InMemorySecretStore`, so the suite's many doubles keep standing in for a *working keychain* — which is what they are there to be — and only code that deliberately chooses RAM in production says so. **Round 2's per-call descriptor never reached anyone.** Both web servers resolved it once at startup and baked it into `initialConfig`, which `/api/config` serves unchanged — so the freshness fix was inert for its only consumer. The route now takes a `secretStorageResolver` and calls it per request. The lock needed three more fixes, and they compound: - **Release removed whatever directory was at the path.** A holder whose work outran the stale threshold would delete the *next* owner's lock, letting a third writer into a section two processes believed they held. Ownership is now stamped with a random token and release only removes a lock still carrying it. - **The timeout fell through to an unlocked write** — under contention, the one moment another writer is provably active. It now throws `SecretFileLockTimeoutError`, which `set` is allowed to do. - **The first write of every process bypassed the lock**, because the secrets directory does not exist yet and `mkdir` failed ENOENT. The parent is created first. First writes are exactly when two processes are most likely to start together. Fixing the timeout exposed a fourth: with stale (10s) below timeout, a waiter stole from a holder that was merely *slow* rather than dead — reinstating the lost update. A holder now heartbeats its lock, so "stale" means the holder is gone, and the timeout is what a live-but-slow holder produces. Lock timings are injectable so the tests can exercise contention, staleness, and timeout without spending the real intervals. Also: the loose-permission state is carried into `SecretStorageInfo` as `looseMode` and rendered as the caveat (it outranks the encryption caveat — a file others can read is a live exposure, while "unencrypted" is a property of a file only its owner can open), rather than living in a startup log the browser cannot see; and `usableSecretStorage` validates `pendingEncryption` and `looseMode` as the optional booleans/numbers they are (`pendingEncryption: "false"` is truthy and would have printed advice that is the opposite of the truth) and rejects file-only fields on a keychain or memory descriptor. Tests: both migrations against a session store with a durable control alongside, so the guard is visibly the difference; `/api/config` serving a changing resolver and falling back without one; lock ownership after a steal, the timeout under a live holder, no-steal from a slow holder, and a locked first write; `looseMode` through the descriptor, the caveat ordering, and the tone; and five more rejected payload shapes. Full `npm run ci` green — 5,704 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
de7795ca54
commit
b143dd6cbd
3 changed files with 674 additions and 36 deletions
|
|
@ -103,6 +103,26 @@ describe("secretStorageCaveat", () => {
|
|||
expect(caveat).not.toContain("MCP_INSPECTOR_SECRET_KEY");
|
||||
});
|
||||
|
||||
it("reports a mode it could not tighten, ahead of the encryption caveat", () => {
|
||||
// A file other users can read is a live exposure; "unencrypted" is a
|
||||
// property of a file only its owner can open. When both hold, the
|
||||
// permission problem is the one to say out loud.
|
||||
const loose: SecretStorageInfo = { ...plaintextFile, looseMode: 0o644 };
|
||||
expect(secretStorageTone(loose)).toBe("warn");
|
||||
expect(secretStorageCaveat(loose)).toContain("0644");
|
||||
expect(secretStorageCaveat(loose)).not.toContain(
|
||||
"MCP_INSPECTOR_SECRET_KEY",
|
||||
);
|
||||
});
|
||||
|
||||
it("warns about a loose mode even on an encrypted file", () => {
|
||||
// The passphrase is then the only thing between a reader and the values,
|
||||
// which is worth saying rather than rendering the quiet neutral state.
|
||||
const loose: SecretStorageInfo = { ...encryptedFile, looseMode: 0o640 };
|
||||
expect(secretStorageTone(loose)).toBe("warn");
|
||||
expect(secretStorageCaveat(loose)).toContain("0640");
|
||||
});
|
||||
|
||||
it("prefers the memory caveat when a memory store somehow carries the flag", () => {
|
||||
// Defensive ordering: `plaintext` is meaningless for memory, and the
|
||||
// durability loss is the more consequential of the two statements.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import * as os from "node:os";
|
|||
import * as path from "node:path";
|
||||
import {
|
||||
InMemorySecretStore,
|
||||
SessionSecretStore,
|
||||
KeychainUnavailableError,
|
||||
SECRET_FIELD_IDP_CLIENT_SECRET,
|
||||
type SecretStore,
|
||||
|
|
@ -62,6 +63,23 @@ describe("client node-persistence", () => {
|
|||
).toBe("plain");
|
||||
});
|
||||
|
||||
it("keeps the plaintext on disk when the store is session-scoped", async () => {
|
||||
// The container fallback. Migrating here would delete a secret that
|
||||
// survives restarts and keep only a copy that dies with the process —
|
||||
// and it happens on an ordinary read, so merely loading the app would
|
||||
// do it. The session still works: the value is loaded into the store.
|
||||
const filePath = await makeTmpFile(
|
||||
JSON.stringify(configWithPlaintextSecret),
|
||||
);
|
||||
const secretStore = new SessionSecretStore();
|
||||
|
||||
const loaded = await readClientConfigStore(filePath, secretStore);
|
||||
|
||||
expect(loaded.enterpriseManagedAuth?.idp.clientSecret).toBe("plain");
|
||||
// The disk copy is deliberately left alone.
|
||||
expect(readFileSync(filePath, "utf-8")).toContain("plain");
|
||||
});
|
||||
|
||||
it("does not overwrite an existing keychain secret during migration", async () => {
|
||||
const filePath = await makeTmpFile(
|
||||
JSON.stringify(configWithPlaintextSecret),
|
||||
|
|
|
|||
|
|
@ -275,6 +275,26 @@ describe("useInitialConfig", () => {
|
|||
expect(result.current.secretStorage).toEqual(info);
|
||||
});
|
||||
|
||||
it("passes through the optional file fields when they are well-typed", async () => {
|
||||
const info = {
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
plaintext: true,
|
||||
path: "/home/node/.mcp-inspector/secrets.json",
|
||||
pendingEncryption: true,
|
||||
looseMode: 0o644,
|
||||
};
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ secretStorage: info }));
|
||||
const { result } = renderHook(() =>
|
||||
useInitialConfig({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.secretStorage).toEqual(info);
|
||||
});
|
||||
|
||||
it("is undefined on a backend that omits the field", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}));
|
||||
const { result } = renderHook(() =>
|
||||
|
|
@ -310,6 +330,42 @@ describe("useInitialConfig", () => {
|
|||
"a descriptor with a non-boolean durable",
|
||||
{ kind: "memory", reason: "fallback", durable: "no" },
|
||||
],
|
||||
[
|
||||
// The truthy-string trap: the footer would read this as "already
|
||||
// re-encrypting" and print advice that is the opposite of the truth.
|
||||
"a stringly-typed pendingEncryption",
|
||||
{
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
plaintext: true,
|
||||
path: "/x",
|
||||
pendingEncryption: "false",
|
||||
},
|
||||
],
|
||||
[
|
||||
"a non-numeric looseMode",
|
||||
{
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
plaintext: true,
|
||||
path: "/x",
|
||||
looseMode: "644",
|
||||
},
|
||||
],
|
||||
[
|
||||
// File-only fields on a non-file kind mean the payload was not built
|
||||
// by a backend we understand; rendering a mixture of two stores'
|
||||
// answers is worse than rendering nothing.
|
||||
"file fields on a memory descriptor",
|
||||
{
|
||||
kind: "memory",
|
||||
reason: "fallback",
|
||||
durable: false,
|
||||
plaintext: true,
|
||||
},
|
||||
],
|
||||
])("rejects %s", async (_label, value) => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
|
|
|
|||
Loading…
Reference in a new issue