fix: stop leaking metadata values into diagnostics, and salvage per key
Two credential leaks, the same class already fixed for the client logger: `serverList` printed the malformed entry verbatim — a legacy row missing `key` still holds whatever the user configured — and the CLI overflow error echoed the whole `key=value` pair into stderr and CI logs. Both now name the key and the offending *type*; a test plants a `sk-live-…` and asserts it appears nowhere while the key still does. `normalizeStoredMetadata` now also drops a value that parses but cannot be sent. A hand-edited catalog is the reachable source: it is parsed server-side, so `1e400` really does become `Infinity` there, and the next write would store `null`. Filtered per key, matching the per-item salvage this file already does elsewhere, so one bad value does not cost the user the rest. Deliberately *not* added to the `/api/servers` guard, which review also suggested. Writing that test is what showed why: a client's own `JSON.stringify` turns `Infinity` into `null` before the request is sent, so the wire cannot carry the bad case at all — and the check made things worse, rejecting the whole metadata field where the normalizer drops one key. The guard stays a container check, with a comment recording both points. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
7f5992081e
commit
56d8644032
3 changed files with 129 additions and 11 deletions
|
|
@ -911,6 +911,34 @@ describe("Metadata Tests", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("names the key but never the value when rejecting", async () => {
|
||||
// The pair can carry a credential, and this message reaches stderr and
|
||||
// CI logs.
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
'credentials={"accessToken":"sk-live-nope","n":1e400}',
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
const output = `${result.stderr}${result.stdout}`;
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(output).toContain("credentials");
|
||||
expect(output).not.toContain("sk-live");
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle metadata parsing validation", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
|
|
|
|||
|
|
@ -504,8 +504,11 @@ function parseKeyValuePair(
|
|||
// and better than falling back to the literal string, which would also not
|
||||
// be what was asked for.
|
||||
if (!isSerializableJson(parsedValue)) {
|
||||
// Names the key, never the value: the pair can carry a credential
|
||||
// (`credentials={"accessToken":"…","n":1e400}`) and this message lands in
|
||||
// stderr and CI logs.
|
||||
throw new Error(
|
||||
`Invalid parameter value: ${value}. Numbers must be finite (a value like 1e400 overflows and cannot be sent).`,
|
||||
`Invalid value for "${key}": numbers must be finite (a literal like 1e400 overflows to Infinity and cannot be sent).`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1477,6 +1477,44 @@ describe("normalizeStoredMetadata (#1910)", () => {
|
|||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops a value that parses but cannot be sent, keeping its siblings", () => {
|
||||
// A hand-edited catalog reaches this untouched by any editor, so it is a
|
||||
// real source of `1e400` — which parses to Infinity and serializes to null.
|
||||
const out = normalizeStoredMetadata(
|
||||
JSON.parse('{"good":1,"bad":1e400,"alsoGood":{"n":2}}'),
|
||||
);
|
||||
expect(out).toEqual({ good: 1, alsoGood: { n: 2 } });
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops a non-serializable value nested inside an object", () => {
|
||||
const out = normalizeStoredMetadata(JSON.parse('{"a":{"deep":[1e400]}}'));
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
it("applies the same check to a legacy pair value", () => {
|
||||
const out = normalizeStoredMetadata([
|
||||
{ key: "ok", value: 1 },
|
||||
{ key: "bad", value: JSON.parse("1e400") },
|
||||
]);
|
||||
expect(out).toEqual({ ok: 1 });
|
||||
});
|
||||
|
||||
it("never prints the offending value in a warning", () => {
|
||||
// `_meta` can hold credentials, and these warnings go to ordinary console
|
||||
// output — so a diagnostic may name the key and the type, never the value.
|
||||
normalizeStoredMetadata([
|
||||
{ value: { accessToken: "sk-live-nope" } },
|
||||
{ key: "leaky", value: JSON.parse('{"token":1e400}') },
|
||||
]);
|
||||
normalizeStoredMetadata("sk-live-also-nope");
|
||||
const printed = JSON.stringify(warn.mock.calls);
|
||||
expect(printed).not.toContain("sk-live");
|
||||
expect(printed).not.toContain("accessToken");
|
||||
// The key is still named, so the warning stays actionable.
|
||||
expect(printed).toContain("leaky");
|
||||
});
|
||||
|
||||
it("migrates a legacy file to the object shape on the next write", () => {
|
||||
// Read side accepts the pair array; write side emits only the object, so
|
||||
// one round-trip is the migration.
|
||||
|
|
|
|||
Loading…
Reference in a new issue