fix: fully redact custom credentials in key inventory
This commit is contained in:
@@ -10,7 +10,8 @@
|
|||||||
* tests/auth-provider-keys.test.ts) — the settings UI reads it optionally
|
* tests/auth-provider-keys.test.ts) — the settings UI reads it optionally
|
||||||
* with a graceful fallback.
|
* with a graceful fallback.
|
||||||
*
|
*
|
||||||
* Masked only — last 4 + length; PLAINTEXT NEVER LEAVES THE STORE THROUGH
|
* Masked only — last 4 + length for ordinary keys; custom keys are fully
|
||||||
|
* redacted at every length (no credential suffix). PLAINTEXT NEVER LEAVES THE STORE THROUGH
|
||||||
* THIS API (owner decision). `source` tells the page whether the live
|
* THIS API (owner decision). `source` tells the page whether the live
|
||||||
* value comes from the environment (dev .env.local / launch env) or the
|
* value comes from the environment (dev .env.local / launch env) or the
|
||||||
* file, and `shadowedByEnv` explains why a saved key is not the live one
|
* file, and `shadowedByEnv` explains why a saved key is not the live one
|
||||||
|
|||||||
@@ -207,7 +207,8 @@ export interface AuthKeyStatus {
|
|||||||
info: AuthKeyInfo | null;
|
info: AuthKeyInfo | null;
|
||||||
/** A key is present when EITHER the environment or the file has it. */
|
/** A key is present when EITHER the environment or the file has it. */
|
||||||
present: boolean;
|
present: boolean;
|
||||||
/** The masked tail + character count, e.g. "…ab12 (64 chars)". */
|
/** Ordinary masked tail + count, e.g. "…ab12 (64 chars)"; custom keys
|
||||||
|
* use full redaction, with no credential characters at any length. */
|
||||||
masked: string | null;
|
masked: string | null;
|
||||||
/** Where the live value comes from, given env-wins precedence. */
|
/** Where the live value comes from, given env-wins precedence. */
|
||||||
source: "env" | "file" | "none";
|
source: "env" | "file" | "none";
|
||||||
|
|||||||
+3
-1
@@ -162,7 +162,9 @@ export function resolveKeyStatuses(
|
|||||||
id,
|
id,
|
||||||
info: AUTH_KEYS.find((k) => k.id === id) ?? null,
|
info: AUTH_KEYS.find((k) => k.id === id) ?? null,
|
||||||
present: live !== undefined,
|
present: live !== undefined,
|
||||||
masked: live !== undefined ? `${maskKey(live)} (${live.trim().length} chars)` : null,
|
// Custom keys may be one character: even a masked suffix can expose
|
||||||
|
// the entire credential. Preserve inventory, return no key characters.
|
||||||
|
masked: live !== undefined ? id === CUSTOM_PROVIDER.envVar ? "••••" : `${maskKey(live)} (${live.trim().length} chars)` : null,
|
||||||
source: isRealEnv ? "env" : live !== undefined ? "file" : "none",
|
source: isRealEnv ? "env" : live !== undefined ? "file" : "none",
|
||||||
shadowedByEnv: isRealEnv && fileValue !== undefined && envValue !== fileValue,
|
shadowedByEnv: isRealEnv && fileValue !== undefined && envValue !== fileValue,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,6 +52,32 @@ test("new keys have no arbitrary minimum; the exact probed key is stored", async
|
|||||||
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "k");
|
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "k");
|
||||||
const body = await get().text(); assert.ok(!body.includes("Bearer"));
|
const body = await get().text(); assert.ok(!body.includes("Bearer"));
|
||||||
});
|
});
|
||||||
|
test("actual GET /api/keys fully redacts every custom key length and preserves other providers", async () => {
|
||||||
|
const inventory = await import("../app/api/keys/route.ts");
|
||||||
|
const rows = async () => (await (await inventory.GET()).json()).keys as import("../src/core/auth-keys.ts").AuthKeyStatus[];
|
||||||
|
assert.equal(keys.saveKey("OPENAI_API_KEY", "ordinary-provider-key-abcd").ok, true);
|
||||||
|
const ordinary = (await rows()).find(row => row.id === "OPENAI_API_KEY")!;
|
||||||
|
assert.equal(ordinary.masked, "…abcd (26 chars)");
|
||||||
|
try {
|
||||||
|
for (const value of ["1", "12", "123", "1234", "synthetic-custom-secret-longer-than-four"]) {
|
||||||
|
assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: value })).status, 200);
|
||||||
|
assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), value, "short keys still save unchanged");
|
||||||
|
const statuses = await rows();
|
||||||
|
const custom = statuses.find(row => row.id === CUSTOM_PROVIDER.envVar)!;
|
||||||
|
assert.equal(custom.masked, "••••"); assert.equal(custom.present, true); assert.equal(custom.source, "file");
|
||||||
|
assert.ok(!JSON.stringify(custom).includes(value));
|
||||||
|
assert.deepEqual(statuses.find(row => row.id === "OPENAI_API_KEY"), ordinary);
|
||||||
|
}
|
||||||
|
process.env.LH_IGNORE_ENV_KEYS = "0";
|
||||||
|
process.env[CUSTOM_PROVIDER.envVar] = "765"; authEnvRecord().realEnv.add(CUSTOM_PROVIDER.envVar);
|
||||||
|
const custom = (await rows()).find(row => row.id === CUSTOM_PROVIDER.envVar)!;
|
||||||
|
assert.equal(custom.masked, "••••"); assert.equal(custom.source, "env"); assert.equal(custom.present, true); assert.equal(custom.shadowedByEnv, true);
|
||||||
|
assert.ok(!JSON.stringify(custom).includes("765"));
|
||||||
|
} finally {
|
||||||
|
authEnvRecord().realEnv.delete(CUSTOM_PROVIDER.envVar); delete process.env[CUSTOM_PROVIDER.envVar]; process.env.LH_IGNORE_ENV_KEYS = "1";
|
||||||
|
keys.deleteKey("OPENAI_API_KEY");
|
||||||
|
}
|
||||||
|
});
|
||||||
test("changing destination cannot silently reuse an existing credential", async () => {
|
test("changing destination cannot silently reuse an existing credential", async () => {
|
||||||
setFileKey("private-old");
|
setFileKey("private-old");
|
||||||
const payload = { baseUrl: "http://new/v1", authMode: "apiKey" };
|
const payload = { baseUrl: "http://new/v1", authMode: "apiKey" };
|
||||||
|
|||||||
Reference in New Issue
Block a user