fix(auth): make the durability guard provenance-aware, and harden the claim
Round 20, and almost all of it is fallout from round 19's two changes. Both were right in direction and wrong in the detail, in the same way: each solved its case by treating a broad thing as a proxy for a narrow one. **The durability guard wrote newly entered secrets to disk in plaintext.** Preserving legacy plaintext for a session-scoped store is correct — stripping it moves the only durable copy into RAM. Using *the whole submitted entry* as the proxy for "legacy" was not: it turned `MCP_INSPECTOR_SECRET_STORE=memory` into "write every new secret to `mcp.json` in the clear", while the footer for that very store says secrets are written nowhere. A security regression introduced by a data-loss fix, and the same overshoot in `client.json`. Provenance decides now. A secret field is kept on disk only when it was already there, unchanged, in the file being overwritten; a newly entered or changed value is stripped and lives in the session store, which is exactly what the footer promises. Where provenance cannot be established — an unparseable prior file — the value is treated as new, because writing a secret to disk on a guess is the failure that matters. **The atomic claim was not as safe as its comments said.** Four ways: - A crash between the claim and the copy left only `secrets.json.migrating-<pid>`. The next run checked the canonical path, found nothing, selected the keychain, and every stored credential vanished with no warning. Orphans are now adopted at startup. - Restoring used `rename`, which on POSIX **replaces** its destination — so the restore path could overwrite a `secrets.json` a concurrent writer had recreated, destroying the later successful write that claiming exists to protect. The comment beside it asserted the opposite. `link` + `unlink` is the no-clobber primitive this needed. - Every claim failure was read as a lost race. `EACCES`/`EROFS` leave the file exactly where it is, and returning quietly selected the keychain while file-backed secrets sat there invisible. Only `ENOENT` is benign now. - A failed cleanup left a plaintext-capable snapshot on disk with a silent catch, while the app reported the keychain as the only store. It names the path now. **A third secret-entry surface had no disclosure at all.** `ServerConfigModal` takes stdio `env` values, which are extracted into the store exactly as Server Settings' are — and the footer's own doc comment claimed there were only two such dialogs. It has the footer now, and every server write that can carry a secret (config submit, both import flows) refreshes the descriptor, not just the settings save. Full `npm run ci` green, staged tree verified first. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
5ef3572578
commit
15e9260484
1 changed files with 426 additions and 17 deletions
|
|
@ -857,6 +857,106 @@ describe("absorbFileSecretsIntoKeyring", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("reports a claim that fails for a reason other than a lost race", async () => {
|
||||
// ENOENT means someone else claimed it. Anything else — EACCES, EROFS —
|
||||
// leaves the file where it is, and returning quietly would select the
|
||||
// keychain while file-backed secrets sit there invisible to everything,
|
||||
// with nothing said.
|
||||
const filePath = await seedFile({ "srv:oauthClientSecret": "stuck" });
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
// Mocked before the module loads: it captures its `fs` binding at import,
|
||||
// so spying on the namespace afterwards does not reach it.
|
||||
vi.resetModules();
|
||||
vi.doMock("node:fs/promises", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
default: actual,
|
||||
rename: async (from: string, to: string) => {
|
||||
if (String(to).includes(".migrating-")) {
|
||||
throw Object.assign(new Error("read-only file system"), {
|
||||
code: "EROFS",
|
||||
});
|
||||
}
|
||||
return actual.rename(from, to);
|
||||
},
|
||||
};
|
||||
});
|
||||
try {
|
||||
const mod = await loadWithProbe(true);
|
||||
await mod.absorbFileSecretsIntoKeyring(new InMemorySecretStore());
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(warn.mock.calls.flat().join("\n")).toMatch(
|
||||
/Could not claim the secrets file .*EROFS/,
|
||||
);
|
||||
} finally {
|
||||
vi.doUnmock("node:fs/promises");
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
|
||||
it("deferred store forwards isDurable and setMany to the resolved store", async () => {
|
||||
// Both are optional seams, and an unforwarded one degrades silently — the
|
||||
// exact defect that made `getStrict` inert in production for two rounds.
|
||||
process.env.MCP_INSPECTOR_SECRET_STORE = "memory";
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
const store = mod.defaultSecretStore();
|
||||
|
||||
// The memory store reports itself session-scoped; a store that dropped
|
||||
// the call would answer the `true` default instead.
|
||||
expect(await store.isDurable?.()).toBe(false);
|
||||
|
||||
await store.setMany?.("srv", { "env:A": "1", "env:B": "2" });
|
||||
expect(await store.get("srv", "env:A")).toBe("1");
|
||||
expect(await store.get("srv", "env:B")).toBe("2");
|
||||
});
|
||||
|
||||
it("recovers a snapshot left by a process that died mid-migration", async () => {
|
||||
// The claim protects the delete, and introduced a way to lose everything:
|
||||
// a crash between the rename and the copy leaves only
|
||||
// `secrets.json.migrating-<pid>`, and checking the canonical path alone
|
||||
// then reports "nothing to migrate" while every stored credential
|
||||
// silently disappears.
|
||||
const filePath = await seedFile({ "srv:oauthClientSecret": "orphaned" });
|
||||
const orphan = `${filePath}.migrating-999999`;
|
||||
await fs.rename(filePath, orphan);
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
|
||||
const keyring = new InMemorySecretStore();
|
||||
await mod.absorbFileSecretsIntoKeyring(keyring);
|
||||
|
||||
// Adopted and migrated, not abandoned.
|
||||
expect(await keyring.get("srv", "oauthClientSecret")).toBe("orphaned");
|
||||
expect(existsSync(orphan)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves an orphan alone when a live secrets file already exists", async () => {
|
||||
// The live file is newer than any orphan and must not be replaced. The
|
||||
// orphan may hold values it does not, so it is reported rather than
|
||||
// removed — deciding a merge is not this function's job.
|
||||
const filePath = await seedFile({ "srv:oauthClientSecret": "current" });
|
||||
const orphan = `${filePath}.migrating-999999`;
|
||||
await fs.copyFile(filePath, orphan);
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
|
||||
await mod.absorbFileSecretsIntoKeyring(new InMemorySecretStore());
|
||||
|
||||
expect(existsSync(orphan)).toBe(true);
|
||||
expect(warn.mock.calls.flat().join("\n")).toMatch(
|
||||
/interrupted migration at .*migrating-999999/,
|
||||
);
|
||||
});
|
||||
|
||||
it("claims the file atomically, so a later write is not deleted", async () => {
|
||||
// Round 19: the pre-delete comparison was a check-to-delete race. A
|
||||
// writer completing a `set` *after* the comparison and before the `rm`
|
||||
|
|
|
|||
Loading…
Reference in a new issue