From cb0135b69543a0ac56a8c1ac27f4d65c3f2553f5 Mon Sep 17 00:00:00 2001 From: KunN-21 Date: Fri, 3 Jul 2026 11:07:08 +0700 Subject: [PATCH] fix: prevent non-SSE stream pipe crash and cross-IdP account overwrites (#2244) - streamingHandler: when upstream returns non-SSE/JSON (e.g. Cloudflare 5xx HTML), read body, sanitize , notify streamController and return a clean JSON error instead of crashing the pipe. - connectionsRepo: dedup OAuth connections on (email + username) so cross-IdP accounts sharing an email no longer overwrite each other; workspace providers keep workspace-id matching. - kimchi: bump User-Agent to 0.1.50, add svg asset + browser-login service, and 21 unit tests. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../handlers/chatCore/streamingHandler.js | 28 ++- open-sse/providers/registry/kimchi.js | 2 +- public/providers/kimchi.png | Bin 13548 -> 3188 bytes public/providers/kimchi.svg | 4 + src/lib/db/repos/connectionsRepo.js | 17 +- src/lib/oauth/services/kimchi.js | 133 ++++++++++ tests/unit/kimchi.test.js | 234 ++++++++++++++++++ 7 files changed, 410 insertions(+), 8 deletions(-) create mode 100644 public/providers/kimchi.svg create mode 100644 src/lib/oauth/services/kimchi.js create mode 100644 tests/unit/kimchi.test.js diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index 41be3d7d..4c076dee 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -43,7 +43,7 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, /** * Handle streaming response — pipe provider SSE through transform stream to client. */ -export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) { +export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) { if (onRequestSuccess) { Promise.resolve() .then(onRequestSuccess) @@ -52,12 +52,30 @@ export function handleStreamingResponse({ providerResponse, provider, model, sou }); } - // Warn when upstream returns unexpected Content-Type for a streaming response. - // This often means the provider returned an HTML error page or plain-text error - // that the SSE transform stream would forward as garbage to the client. + // When upstream returns HTML/text instead of SSE (e.g. Cloudflare 5xx error + // page), piping it through the SSE transform stream causes Next.js + // "failed to pipe response" and crashes the chat router. Read the body, + // pull a short human-readable message from the <title>, sanitize it, and + // return a clean JSON error instead. The message is stripped of HTML tags + // and clamped so untrusted upstream text never reaches the client verbatim + // (the UI may render error.message as HTML). const upstreamContentType = (providerResponse.headers.get('content-type') || '').toLowerCase(); if (upstreamContentType && !upstreamContentType.includes('text/event-stream') && !upstreamContentType.includes('application/json')) { - console.warn('[STREAM] ' + provider + ' | ' + model + ' | unexpected Content-Type: ' + upstreamContentType); + const bodyText = await providerResponse.text().catch(() => ''); + const titleMatch = bodyText.match(/<title>([^<]+)<\/title>/i); + const sanitizedTitle = (titleMatch?.[1] || '').replace(/<[^>]*>/g, '').replace(/[\r\n]+/g, ' ').trim().slice(0, 160); + const shortMsg = sanitizedTitle + || (bodyText.length < 200 ? bodyText.replace(/<[^>]*>/g, '').trim().slice(0, 160) : `Upstream returned non-SSE response (${upstreamContentType})`); + const status = providerResponse.status || 502; + console.warn(`[STREAM] ${provider} | ${model} | blocked pipe: ${shortMsg} [${status}]`); + streamController?.handleError?.(new Error(`upstream non-SSE: ${status}`)); + return { + success: false, + response: new Response(JSON.stringify({ error: { message: `[${status}]: ${shortMsg}` } }), { + status, + headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, + }), + }; } const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }); diff --git a/open-sse/providers/registry/kimchi.js b/open-sse/providers/registry/kimchi.js index 10061379..99facd53 100644 --- a/open-sse/providers/registry/kimchi.js +++ b/open-sse/providers/registry/kimchi.js @@ -20,7 +20,7 @@ export default { baseUrl: "https://llm.kimchi.dev/openai/v1/chat/completions", format: "openai", headers: { - "User-Agent": "kimchi/0.1.40", + "User-Agent": "kimchi/0.1.50", }, auth: { combined: true, diff --git a/public/providers/kimchi.png b/public/providers/kimchi.png index ff116adf3c2f4a56fc323d81758e01ad094a93c9..8f328158936ba25745557144e68b5b5db4404c0d 100644 GIT binary patch literal 3188 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 a%<(_R4U7fTS^rl60000<MNUMnLSTY|Q3)jg literal 13548 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 diff --git a/public/providers/kimchi.svg b/public/providers/kimchi.svg new file mode 100644 index 00000000..cc019be6 --- /dev/null +++ b/public/providers/kimchi.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="104" height="104" viewBox="0 0 104 104"> +<circle cx="52" cy="52" r="52" fill="#FF521D"/> +<path d="M68.4459 24.9438C69.8639 24.1762 71.6471 23.7178 73.4821 24.2163C73.8278 24.2966 74.1339 24.3916 74.4362 24.5024L75.9186 25.0464L75.5104 26.5727C75.3469 27.1827 75.202 27.723 75.0387 28.3325L74.6442 29.8042L73.1354 29.5972C73.0242 29.5819 72.9246 29.5753 72.8082 29.5747C72.2474 29.5865 71.7542 29.8645 71.2662 30.6772C70.8883 31.307 70.5939 32.1486 70.3815 33.1079C74.871 36.0331 77.8444 41.0994 77.8444 46.8638C77.8443 49.5292 77.2072 52.0505 76.0758 54.2798C71.5108 64.3996 54.1783 84.8815 23.4655 79.0083C21.8797 78.7048 20.9918 77.4165 20.902 76.1245C20.8141 74.8561 21.479 73.487 22.8981 72.8813C31.0698 69.394 35.5884 65.7091 38.5983 61.2329C41.6553 56.6866 43.2606 51.1907 45.3287 43.7251C46.7942 36.1645 53.4462 30.4547 61.4362 30.4546L61.8834 30.4604C62.4398 30.4755 62.9894 30.519 63.5309 30.5884C63.9515 29.7288 64.4424 28.8863 65.0211 28.0659L65.1745 27.8423C65.9623 26.731 67.1108 25.6667 68.4459 24.9438Z" fill="#18181A"/> +</svg> diff --git a/src/lib/db/repos/connectionsRepo.js b/src/lib/db/repos/connectionsRepo.js index d1bfe341..6075f234 100644 --- a/src/lib/db/repos/connectionsRepo.js +++ b/src/lib/db/repos/connectionsRepo.js @@ -98,13 +98,26 @@ export async function createProviderConnection(data) { let existing = null; if (data.authType === "oauth" && data.email) { + const incomingUsername = data.providerSpecificData?.username; const incomingWs = data.providerSpecificData?.chatgptAccountId; existing = all.find(c => { if (c.authType !== "oauth" || c.email !== data.email) return false; - // If both sides have a workspace ID, they must match for dedup + // Workspace providers (Codex) use workspace ID when both sides have it const existingWs = c.providerSpecificData?.chatgptAccountId; if (incomingWs && existingWs) return incomingWs === existingWs; - return true; // fallback: email-only match for non-workspace providers + if (incomingWs && !existingWs) return false; + if (!incomingWs && existingWs) return false; + // Non-workspace providers: match on (email + username) so cross-IdP + // accounts don't overwrite each other. Require username on both sides + // — if only one side has it, treat as a distinct identity rather than + // collapsing onto the bare-email fallback (which would re-introduce + // the cross-IdP overwrite). + const existingUsername = c.providerSpecificData?.username; + if (incomingUsername && existingUsername) { + return incomingUsername === existingUsername; + } + if (incomingUsername || existingUsername) return false; + return true; }); } else if (data.authType === "apikey" && data.name) { existing = all.find(c => c.authType === "apikey" && c.name === data.name); diff --git a/src/lib/oauth/services/kimchi.js b/src/lib/oauth/services/kimchi.js new file mode 100644 index 00000000..7e929e9e --- /dev/null +++ b/src/lib/oauth/services/kimchi.js @@ -0,0 +1,133 @@ +// Kimchi browser-login service. +// +// Ports Kimchi CLI's authenticateViaBrowser (src/cli-auth/index.ts) onto +// 9Router's shared startLocalServer util (same one xai/antigravity use). +// Simpler than those: the token arrives directly on the callback query +// string — no authorization-code exchange, no PKCE. +// +// In-flight logins are held in `sessions` keyed by state. The OAuthModal +// device_code flow starts one via requestDeviceCode(); pollToken() peeks +// at the resolved token; the generic [provider]/[action] route calls +// createProviderConnection with the real token. +import { randomBytes } from "node:crypto"; +import { startLocalServer } from "../utils/server.js"; +import { KIMCHI_CONFIG } from "../constants/oauth.js"; + +const sessions = new Map(); // state -> { result, close, timeout, done, resolved } +const SESSION_TTL_MS = 5 * 60 * 1000; + +export function buildKimchiAuthUrl(callbackUrl, state) { + const params = new URLSearchParams({ callback: callbackUrl, state }); + return `${KIMCHI_CONFIG.webAppUrl}/cli-auth?${params.toString()}`; +} + +export function generateState() { + return randomBytes(32).toString("hex"); +} + +// Returns the resolved { token } if the session for `state` has completed, +// or null if it is still pending / unknown. +export function getResolvedSession(state) { + const s = sessions.get(state); + if (!s || !s.done || !s.resolved) return null; + return s.resolved; +} + +export class KimchiService { + async startLogin() { + const state = generateState(); + let resolveResult; + const result = new Promise((resolve) => { resolveResult = resolve; }); + + const { port, close } = await startLocalServer((params) => { + this._handleCallback(params, state) + .then(resolveResult) + .catch((err) => resolveResult({ error: err.message })); + }); + + const timeout = setTimeout(() => { + resolveResult({ error: "Browser login timed out — please try again" }); + close(); + }, KIMCHI_CONFIG.callbackTimeoutMs); + + sessions.set(state, { result, close, timeout, done: false, resolved: null }); + + // Stash the resolved value so pollToken() can retrieve the real token, + // close the loopback server, and reap the session after a TTL so the + // Map can't grow unbounded across many logins. + result.then((r) => { + const s = sessions.get(state); + if (!s) return; + s.done = true; + s.resolved = r; + clearTimeout(s.timeout); + try { s.close(); } catch { /* already closed */ } + setTimeout(() => sessions.delete(state), SESSION_TTL_MS).unref?.(); + }); + + const callbackUrl = `http://127.0.0.1:${port}${KIMCHI_CONFIG.callbackPath}`; + const authUrl = buildKimchiAuthUrl(callbackUrl, state); + return { authUrl, port, state, result, close }; + } + + async _handleCallback(params, expectedState) { + if (params.error) { + throw new Error(params.error_description || params.error); + } + const candidate = params.state; + if (!candidate || candidate !== expectedState) { + throw new Error("This request isn't valid. Please restart the Kimchi login flow."); + } + const token = params.token; + if (!token) { + throw new Error("No token was returned by the Kimchi authentication server"); + } + const check = await this.validateToken(token); + if (!check.valid) { + throw new Error(check.error || "Kimchi token validation failed"); + } + return { token }; + } + + async fetchProfile(token) { + try { + const res = await fetch(KIMCHI_CONFIG.meUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return {}; + const j = await res.json(); + return { displayName: j.name, email: j.email, username: j.username }; + } catch { + return {}; + } + } + + // Validate a token against Kimchi's supported-providers endpoint. + // 200 → valid; 401/403 → invalid; anything else (incl. network/timeout) + // → fail-open valid so a flaky validation never blocks a good login. + async validateToken(token) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10_000); + let status = 0; + try { + const res = await fetch(KIMCHI_CONFIG.validationUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, + signal: controller.signal, + }); + status = res.status; + } catch { + // Network error / abort → fail-open + return { valid: true }; + } finally { + clearTimeout(timer); + } + if (status === 200) return { valid: true }; + if (status === 401) return { valid: false, error: "Kimchi token invalid or expired" }; + if (status === 403) return { valid: false, error: "Kimchi token lacks required scope" }; + return { valid: true }; + } +} diff --git a/tests/unit/kimchi.test.js b/tests/unit/kimchi.test.js new file mode 100644 index 00000000..1f300f8d --- /dev/null +++ b/tests/unit/kimchi.test.js @@ -0,0 +1,234 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; + +// Load the registry entry once for the suite so a load failure is reported +// next to the failing test instead of cascading as "undefined" in every +// later assertion. +let kimchiEntry; + +describe("kimchi registry entry", () => { + before(async () => { + kimchiEntry = (await import("../../open-sse/providers/registry/kimchi.js")).default; + }); + + it("is an oauth provider auto-listed via byCategory", () => { + assert.equal(kimchiEntry.id, "kimchi"); + assert.equal(kimchiEntry.category, "oauth"); + }); + + it("points at the OpenAI-compatible gateway with an authenticated UA", () => { + assert.equal( + kimchiEntry.transport.baseUrl, + "https://llm.kimchi.dev/openai/v1/chat/completions", + ); + // UA must be a non-empty string the gateway can identify; the value + // itself is owned by the Kimchi CLI release and may change upstream. + const ua = kimchiEntry.transport.headers["User-Agent"]; + assert.ok(typeof ua === "string" && ua.length > 0, `User-Agent missing: ${ua}`); + }); + + it("uses Bearer auth", () => { + assert.deepEqual(kimchiEntry.transport.auth, { + combined: true, + header: "Authorization", + scheme: "bearer", + }); + }); + + it("exposes the upstream static models", () => { + const ids = kimchiEntry.models.map((m) => m.id); + assert.ok(ids.includes("kimi-k2.7")); + assert.ok(ids.includes("minimax-m3")); + assert.ok(ids.includes("nemotron-3-ultra-fp4")); + assert.ok(ids.length >= 5, `expected >= 5 static models, got ${ids.length}`); + }); + + it("passes through models not in the static list", () => { + assert.equal(kimchiEntry.passthroughModels, true); + }); +}); + +// ── Pure-function clones of the service logic (tested in isolation so +// node --test works without resolving the Next.js Webpack "open-sse" +// alias that src/lib/oauth/services/kimchi.js's dependency imports). ── + +function buildKimchiAuthUrl(callbackUrl, state) { + const params = new URLSearchParams({ callback: callbackUrl, state }); + return `https://app.kimchi.dev/cli-auth?${params.toString()}`; +} + +async function _handleCallback(params, expectedState) { + if (params.error) { + throw new Error(params.error_description || params.error); + } + const candidate = params.state; + if (!candidate || candidate !== expectedState) { + throw new Error( + "This request isn't valid. Please restart the Kimchi login flow.", + ); + } + const token = params.token; + if (!token) { + throw new Error("No token was returned by the Kimchi authentication server"); + } + return { token }; +} + +describe("kimchi oauth", () => { + it("builds the cli-auth URL with encoded callback + state", () => { + const url = buildKimchiAuthUrl("http://127.0.0.1:4321/callback", "abc123"); + const parsed = new URL(url); + assert.equal(parsed.origin, "https://app.kimchi.dev"); + assert.equal(parsed.pathname, "/cli-auth"); + assert.equal(parsed.searchParams.get("callback"), "http://127.0.0.1:4321/callback"); + assert.equal(parsed.searchParams.get("state"), "abc123"); + }); + + it("rejects a callback whose state does not match", async () => { + await assert.rejects( + () => _handleCallback({ token: "castai_v1_x", state: "wrong" }, "expected"), + /restart/i, + ); + }); + + it("accepts a callback with matching state and returns the token", async () => { + const res = await _handleCallback({ token: "castai_v1_x", state: "match" }, "match"); + assert.equal(res.token, "castai_v1_x"); + }); +}); + +// ── kimchiModels service (pure mapping logic, tested in isolation) ── + +// Clone of the metadata→model mapper so node --test resolves without the +// open-sse/Webpack alias chain the real module imports. +function mapKimchiMetadata(raw) { + if (!Array.isArray(raw)) return []; + return raw.map((m) => ({ + id: m.slug, + name: m.display_name || m.slug, + contextLength: m.limits?.context_window || null, + maxOutputTokens: m.limits?.max_output_tokens || null, + isReasoning: m.reasoning === true, + })); +} + +describe("kimchiModels", () => { + it("maps Kimchi metadata entries to 9router model shape", () => { + const raw = [{ + slug: "glm-5.2-fp8", + display_name: "GLM 5.2", + reasoning: true, + limits: { context_window: 1048576, max_output_tokens: 1048576 }, + }]; + const models = mapKimchiMetadata(raw); + assert.equal(models.length, 1); + assert.deepEqual(models[0], { + id: "glm-5.2-fp8", + name: "GLM 5.2", + contextLength: 1048576, + maxOutputTokens: 1048576, + isReasoning: true, + }); + }); + + it("falls back to slug as name when display_name is empty", () => { + const models = mapKimchiMetadata([{ slug: "kimi-k2.7", display_name: "", reasoning: false, limits: {} }]); + assert.equal(models[0].name, "kimi-k2.7"); + assert.equal(models[0].contextLength, null); + assert.equal(models[0].isReasoning, false); + }); + + it("returns empty array for non-array input", () => { + assert.deepEqual(mapKimchiMetadata(null), []); + assert.deepEqual(mapKimchiMetadata({}), []); + }); +}); + +// ── validateToken logic (pure decision over a status code) ── + +// Mirrors the decision in KimchiService.validateToken without importing the +// service (which pulls the open-sse Webpack alias chain). +function decideValidity(status) { + if (status === 200) return { valid: true }; + if (status === 401) return { valid: false, error: "Kimchi token invalid or expired" }; + if (status === 403) return { valid: false, error: "Kimchi token lacks required scope" }; + return { valid: true }; // fail-open on unknown / network error +} + +describe("kimchi validateToken", () => { + it("200 → valid", () => { + assert.deepEqual(decideValidity(200), { valid: true }); + }); + it("401 → invalid, expired message", () => { + const r = decideValidity(401); + assert.equal(r.valid, false); + assert.match(r.error, /invalid or expired/i); + }); + it("403 → invalid, scope message", () => { + const r = decideValidity(403); + assert.equal(r.valid, false); + assert.match(r.error, /scope/i); + }); + it("unknown / network error → fail-open valid", () => { + assert.equal(decideValidity(500).valid, true); + assert.equal(decideValidity(0).valid, true); + }); +}); + +// ── OAuth dedup logic (pure clone of connectionsRepo matcher) ── +// Mimics the find() predicate in createProviderConnection for OAuth +// connections, so we can test the IdP-collision fix in isolation. +function findExistingOAuth(all, incoming) { + const incomingEmail = incoming.email; + const incomingUsername = incoming.providerSpecificData?.username; + const incomingWs = incoming.providerSpecificData?.chatgptAccountId; + return all.find((c) => { + if (c.authType !== "oauth" || c.email !== incomingEmail) return false; + const existingWs = c.providerSpecificData?.chatgptAccountId; + if (incomingWs && existingWs) return incomingWs === existingWs; + if (incomingWs && !existingWs) return false; + if (!incomingWs && existingWs) return false; + const existingUsername = c.providerSpecificData?.username; + if (incomingUsername && existingUsername) { + return incomingUsername === existingUsername; + } + if (incomingUsername || existingUsername) return false; + return true; + }); +} + +describe("kimchi OAuth dedup", () => { + const google = { authType: "oauth", email: "x@y.com", providerSpecificData: { username: "google-oauth2|123" } }; + const hf = { authType: "oauth", email: "x@y.com", providerSpecificData: { username: "huggingface|456" } }; + const legacy = { authType: "oauth", email: "x@y.com", providerSpecificData: {} }; + const other = { authType: "oauth", email: "z@y.com", providerSpecificData: { username: "google-oauth2|789" } }; + + it("different email never matches", () => { + assert.equal(findExistingOAuth([other], google), undefined); + }); + + it("same email + same username = dedup (re-login same IdP)", () => { + const found = findExistingOAuth([google], { ...google }); + assert.equal(found, google); + }); + + it("same email + different username = NO match (cross-IdP, the bug)", () => { + assert.equal(findExistingOAuth([google], hf), undefined); + }); + + it("legacy row without username matches incoming without username (backward compat)", () => { + assert.equal(findExistingOAuth([legacy], { ...legacy }), legacy); + }); + + it("incoming without username does not match legacy row with username", () => { + assert.equal(findExistingOAuth([google], { ...legacy }), undefined); + }); + + it("workspaces still dedupe on workspace ID when both sides have one", () => { + const ws1 = { authType: "oauth", email: "a@b.com", providerSpecificData: { chatgptAccountId: "ws1" } }; + const ws1dup = { authType: "oauth", email: "a@b.com", providerSpecificData: { chatgptAccountId: "ws1" } }; + const ws2 = { authType: "oauth", email: "a@b.com", providerSpecificData: { chatgptAccountId: "ws2" } }; + assert.equal(findExistingOAuth([ws1], ws1dup), ws1); + assert.equal(findExistingOAuth([ws1], ws2), undefined); + }); +});