Fix custom provider sends failing on masked API key
The settings UI displays saved API keys masked with '•' (U+2022). Saving a provider without retyping the key echoed that mask back, and the PUT handler persisted it as the real credential. The masked key then flowed into an Authorization/x-api-key header, throwing "Cannot convert argument to a ByteString ... value 8226" at request time. Treat masked values echoed back on update as "unchanged" so the stored key is preserved, and reject masked values on create/probe. Add tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/fix-custom-provider-masked-api-key.md
Normal file
5
.changeset/fix-custom-provider-masked-api-key.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix custom provider message sends failing with a `ByteString` error (`character ... value 8226`). The settings UI displays the saved API key masked with `•` characters; saving the provider without retyping the key persisted that mask as the real credential, which then broke HTTP header encoding. Masked values echoed back on update are now treated as "unchanged" and the stored key is preserved; masked values on create/probe are rejected. Re-enter the real API key once to clear any already-corrupted key.
|
||||
@@ -274,6 +274,67 @@ describe("custom provider routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("PUT /custom-providers/:id preserves stored key when a masked key is echoed back", async () => {
|
||||
settings.customProviders = [
|
||||
{
|
||||
id: "cp-1",
|
||||
name: "Original",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://original.example.com",
|
||||
apiKey: "sk-real-secret-1234",
|
||||
},
|
||||
];
|
||||
|
||||
const updates: Array<Partial<GlobalSettings>> = [];
|
||||
const app = createApp(settings, (patch) => updates.push(patch));
|
||||
const res = await REQUEST(app, "PUT", "/api/custom-providers/cp-1", {
|
||||
name: "Updated",
|
||||
// The UI sends back the masked key when the field is left untouched.
|
||||
apiKey: "sk-•••••1234",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const persisted = updates[0].customProviders as CustomProvider[];
|
||||
// The original key must survive — never overwritten with the mask.
|
||||
expect(persisted[0]?.apiKey).toBe("sk-real-secret-1234");
|
||||
// And no mask character ever reaches the stored credential.
|
||||
expect(persisted[0]?.apiKey).not.toContain("•");
|
||||
});
|
||||
|
||||
it("PUT /custom-providers/:id updates the key when a real key is provided", async () => {
|
||||
settings.customProviders = [
|
||||
{
|
||||
id: "cp-1",
|
||||
name: "Original",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://original.example.com",
|
||||
apiKey: "sk-old-key-0000",
|
||||
},
|
||||
];
|
||||
|
||||
const updates: Array<Partial<GlobalSettings>> = [];
|
||||
const app = createApp(settings, (patch) => updates.push(patch));
|
||||
const res = await REQUEST(app, "PUT", "/api/custom-providers/cp-1", {
|
||||
apiKey: "sk-brand-new-9999",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const persisted = updates[0].customProviders as CustomProvider[];
|
||||
expect(persisted[0]?.apiKey).toBe("sk-brand-new-9999");
|
||||
});
|
||||
|
||||
it("POST /custom-providers rejects a masked API key", async () => {
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "POST", "/api/custom-providers", {
|
||||
name: "My Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "sk-•••••5678",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("PUT /custom-providers/:id returns 404 for non-existent id", async () => {
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "PUT", "/api/custom-providers/missing", {
|
||||
|
||||
@@ -6,14 +6,31 @@ import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js";
|
||||
|
||||
/**
|
||||
* Sentinel character used to mask API keys for display. A real API key is an
|
||||
* ASCII/Latin1 credential and will never contain this character, so its
|
||||
* presence in an inbound value reliably indicates the client echoed back a
|
||||
* masked (unchanged) key rather than a freshly entered one.
|
||||
*/
|
||||
const API_KEY_MASK_CHAR = "•";
|
||||
|
||||
/**
|
||||
* Masks an API key for safe display, showing only the first 3 and last 4 characters.
|
||||
*/
|
||||
function maskApiKey(key: string): string {
|
||||
if (key.length <= 8) {
|
||||
return "••••••••";
|
||||
return API_KEY_MASK_CHAR.repeat(8);
|
||||
}
|
||||
return key.slice(0, 3) + "•••••" + key.slice(-4);
|
||||
return key.slice(0, 3) + API_KEY_MASK_CHAR.repeat(5) + key.slice(-4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a value is a masked API key echoed back from the UI rather
|
||||
* than a real credential. Persisting a masked value would corrupt the stored
|
||||
* key and break HTTP header encoding (the mask char is not a valid ByteString).
|
||||
*/
|
||||
function isMaskedApiKey(value: string): boolean {
|
||||
return value.includes(API_KEY_MASK_CHAR);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +143,9 @@ function parseCreateBody(body: unknown): Omit<CustomProvider, "id"> {
|
||||
if (typeof row.apiKey !== "string") {
|
||||
throw badRequest("apiKey must be a string");
|
||||
}
|
||||
if (isMaskedApiKey(row.apiKey)) {
|
||||
throw badRequest("apiKey appears to be a masked value; enter the real API key");
|
||||
}
|
||||
if (row.apiKey.trim().length > 0) {
|
||||
provider.apiKey = row.apiKey;
|
||||
}
|
||||
@@ -416,7 +436,13 @@ function parseUpdateBody(body: unknown): Partial<Omit<CustomProvider, "id">> {
|
||||
if (typeof row.apiKey !== "string") {
|
||||
throw badRequest("apiKey must be a string");
|
||||
}
|
||||
updates.apiKey = row.apiKey.trim().length > 0 ? row.apiKey : undefined;
|
||||
// The UI loads the existing key masked (e.g. "abc•••••wxyz"). If the user
|
||||
// saves without retyping it, that masked value is echoed back — leave the
|
||||
// field absent from the update so the stored key is preserved rather than
|
||||
// overwritten with the mask.
|
||||
if (!isMaskedApiKey(row.apiKey)) {
|
||||
updates.apiKey = row.apiKey.trim().length > 0 ? row.apiKey : undefined;
|
||||
}
|
||||
}
|
||||
if (row.models !== undefined) {
|
||||
updates.models = validateModels(row.models);
|
||||
@@ -556,6 +582,9 @@ export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
|
||||
const baseUrl = assertBaseUrl(body.baseUrl);
|
||||
if (typeof body.apiKey === "string" && isMaskedApiKey(body.apiKey)) {
|
||||
throw badRequest("apiKey appears to be a masked value; enter the real API key");
|
||||
}
|
||||
const apiKey =
|
||||
typeof body.apiKey === "string" && body.apiKey.trim().length > 0
|
||||
? body.apiKey.trim()
|
||||
|
||||
Reference in New Issue
Block a user