diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index e26c19edbce..e8cf2644625 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -798,6 +798,37 @@ describe("restoreRedactedValues", () => { expect(restoreRedactedValues_orig(undefined, { token: "x" }).ok).toBe(false); }); + it("rejects non-object inputs", () => { + expect(restoreRedactedValues_orig("token-value", { token: "x" })).toEqual({ + ok: false, + error: "input not an object", + }); + }); + + it("returns a human-readable error when sentinel cannot be restored", () => { + const incoming = { + channels: { newChannel: { token: REDACTED_SENTINEL } }, + }; + const result = restoreRedactedValues_orig(incoming, {}); + expect(result.ok).toBe(false); + expect(result.humanReadableMessage).toContain(REDACTED_SENTINEL); + expect(result.humanReadableMessage).toContain("channels.newChannel.token"); + }); + + it("keeps unmatched wildcard array entries unchanged outside extension paths", () => { + const hints: ConfigUiHints = { + "custom.*": { sensitive: true }, + }; + const incoming = { + custom: { items: [REDACTED_SENTINEL] }, + }; + const original = { + custom: { items: ["original-secret-value"] }, + }; + const result = restoreRedactedValues(incoming, original, hints) as typeof incoming; + expect(result.custom.items[0]).toBe(REDACTED_SENTINEL); + }); + it("round-trips config through redact → restore", () => { const originalConfig = { gateway: { auth: { token: "gateway-auth-secret-token-value" }, port: 18789 }, diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index 243dcc3c295..d377e961d53 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -404,6 +404,20 @@ function toObjectRecord(value: unknown): Record { return {}; } +function shouldPassThroughRestoreValue(incoming: unknown): boolean { + return incoming === null || incoming === undefined || typeof incoming !== "object"; +} + +function toRestoreArrayContext( + incoming: unknown, + prefix: string, +): { incoming: unknown[]; path: string } | null { + if (!Array.isArray(incoming)) { + return null; + } + return { incoming, path: `${prefix}[]` }; +} + function restoreArrayItemWithLookup(params: { item: unknown; index: number; @@ -478,26 +492,25 @@ function restoreRedactedValuesWithLookup( prefix: string, hints: ConfigUiHints, ): unknown { - if (incoming === null || incoming === undefined) { + if (shouldPassThroughRestoreValue(incoming)) { return incoming; } - if (typeof incoming !== "object") { - return incoming; - } - if (Array.isArray(incoming)) { + + const arrayContext = toRestoreArrayContext(incoming, prefix); + if (arrayContext) { // Note: If the user removed an item in the middle of the array, // we have no way of knowing which one. In this case, the last // element(s) get(s) chopped off. Not good, so please don't put // sensitive string array in the config... - const path = `${prefix}[]`; + const { incoming: incomingArray, path } = arrayContext; if (!lookup.has(path)) { if (!isExtensionPath(prefix)) { - return incoming; + return incomingArray; } - return restoreRedactedValuesGuessing(incoming, original, prefix, hints); + return restoreRedactedValuesGuessing(incomingArray, original, prefix, hints); } return mapRedactedArray({ - incoming, + incoming: incomingArray, original, path, mapItem: (item, index, originalArray) => @@ -551,19 +564,18 @@ function restoreRedactedValuesGuessing( prefix: string, hints?: ConfigUiHints, ): unknown { - if (incoming === null || incoming === undefined) { + if (shouldPassThroughRestoreValue(incoming)) { return incoming; } - if (typeof incoming !== "object") { - return incoming; - } - if (Array.isArray(incoming)) { + + const arrayContext = toRestoreArrayContext(incoming, prefix); + if (arrayContext) { // Note: If the user removed an item in the middle of the array, // we have no way of knowing which one. In this case, the last // element(s) get(s) chopped off. Not good, so please don't put // sensitive string array in the config... - const path = `${prefix}[]`; - return restoreGuessingArray(incoming, original, path, hints); + const { incoming: incomingArray, path } = arrayContext; + return restoreGuessingArray(incomingArray, original, path, hints); } const orig = toObjectRecord(original); const result: Record = {};