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:
cliffhall 2026-08-23 20:03:37 -04:00
commit 56d8644032
2 changed files with 129 additions and 11 deletions

View file

@ -1213,9 +1213,18 @@ export function createRemoteApp(
// credentials are intentionally lost on first read (hard cutover per
// #1358 decision 4). Users re-enter via the form or hand-edit into the
// flat shape.
// A plain JSON object — the container `_meta` requires. Values are
// deliberately unchecked: `_meta` takes arbitrary JSON (#1910), and anything
// that survived `JSON.parse` already is.
// The *container* `_meta` requires — a plain JSON object.
//
// Value-level validity is deliberately not checked here. Values are
// arbitrary JSON (#1910), and the one class that survives `JSON.parse` yet
// cannot be sent — a non-finite number, from a literal like `1e400` in a
// hand-edited catalog — is filtered **per key** by
// `normalizeStoredMetadata`, which every read routes through. Rejecting the
// whole field here instead would cost a user every other key they had
// configured, for one bad value.
//
// Note the wire cannot carry the bad case at all: a client's own
// `JSON.stringify` turns `Infinity` into `null` before the request is sent.
const isJsonObject = (v: unknown): v is RequestMetadata =>
v !== null && typeof v === "object" && !Array.isArray(v);
const isStringRecord = (v: unknown): v is Record<string, string> => {

View file

@ -30,6 +30,7 @@ import {
} from "../auth/secret-fields.js";
import { toRecord } from "../json/jsonUtils.js";
import type { JsonValue } from "../json/jsonUtils.js";
import { isSerializableJson } from "../json/jsonUtils.js";
// The full set of valid `type` discriminator values, used to reject anything
// else read off disk so unknown strings can't propagate to narrowing sites.
@ -226,9 +227,12 @@ export function normalizeStoredMetadata(
entry === null ||
typeof (entry as { key?: unknown }).key !== "string"
) {
// Structural message only — never the entry. A malformed legacy row
// still holds whatever the user put in it, which for `_meta` can be a
// credential, and this goes to the console of every client that reads
// the catalog.
console.warn(
"Ignoring malformed legacy metadata entry (expected `{ key, value }`):",
entry,
"Ignoring malformed legacy metadata entry (expected `{ key, value }`).",
);
continue;
}
@ -236,13 +240,14 @@ export function normalizeStoredMetadata(
if (key.trim() === "") continue;
// `??`, not `||`: `null` is a legal `_meta` value and must survive the
// migration as `null`. Only a genuinely absent `value` becomes `""`.
//
const resolved = value === undefined ? "" : value;
if (!keepSerializable(key, resolved)) continue;
// `defineProperty` rather than `out[key] = …` because a legacy key of
// `__proto__` would otherwise hit the prototype setter — silently
// dropping the entry (or, for an object value, mutating the prototype)
// instead of storing an own property under that name.
Object.defineProperty(out, key, {
value: value === undefined ? "" : value,
value: resolved,
writable: true,
enumerable: true,
configurable: true,
@ -252,16 +257,51 @@ export function normalizeStoredMetadata(
}
if (typeof metadata === "object") {
return { ...(metadata as RequestMetadata) };
// A hand-edited catalog reaches this untouched by any editor, so it is a
// real source of values that parse but cannot be sent — `1e400` becomes
// `Infinity`, which the next serialization writes as `null`. Same
// invariant the web editor and the CLI enforce, applied per key so one bad
// value does not cost the rest.
const out: RequestMetadata = {};
for (const [key, value] of Object.entries(metadata as RequestMetadata)) {
if (keepSerializable(key, value)) out[key] = value;
}
return out;
}
console.warn(
"Ignoring malformed `metadata` (expected a JSON object):",
metadata,
`Ignoring malformed \`metadata\` (expected a JSON object, got ${describeJsonType(metadata)}).`,
);
return {};
}
/**
* Whether a metadata value can be sent as written, warning by key when it
* cannot.
*
* Names the key and the offending type, never the value: `_meta` carries
* whatever the user configured, credentials included, and these warnings reach
* ordinary console output.
*/
function keepSerializable(key: string, value: unknown): boolean {
if (isSerializableJson(value)) return true;
console.warn(
`Ignoring metadata key "${key}" — its value is not JSON that can be sent ` +
`(got ${describeJsonType(value)}; note \`1e400\` parses to Infinity).`,
);
return false;
}
/** A one-word type name for a diagnostic that must not disclose the value. */
function describeJsonType(value: unknown): string {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
if (typeof value === "number" && !Number.isFinite(value)) {
return Number.isNaN(value) ? "NaN" : "a non-finite number";
}
return typeof value;
}
/**
* Validate a server's `oauth.authorizationParams` as it comes off disk, or
* `undefined` when there is nothing usable. (#2018)