From e12bbd33ad644fd8856eb3bb72d55fb36870b1bd Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:23:23 -0300 Subject: [PATCH] feat(providers): client-identity header profiles for compatible nodes (re-cut) + forbid cookie in custom headers (#5812) --- CHANGELOG.md | 1 + .../components/AddCompatibleProviderModal.tsx | 19 ++ src/i18n/messages/en.json | 2 + .../constants/clientIdentityProfiles.ts | 89 +++++++++ src/shared/constants/upstreamHeaders.ts | 2 +- tests/unit/client-identity-profiles.test.ts | 174 ++++++++++++++++++ 6 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 src/shared/constants/clientIdentityProfiles.ts create mode 100644 tests/unit/client-identity-profiles.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 28590f99b4..8069afabe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. - **feat(claude-code):** add an opt-in auto-permission classifier compat mode (off/auto/always) for Claude Code, toggleable from the CLI Code settings. +- **feat(providers):** add optional client-identity header profiles for compatible nodes — preset User-Agent/fingerprint headers (e.g. matching a known CLI) merged into the existing customHeaders field. ### 🔧 Bug Fixes diff --git a/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx b/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx index 7f7fcc0ac9..ee107c63b6 100644 --- a/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx @@ -4,6 +4,10 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { Badge, Button, Input, Modal, Select } from "@/shared/components"; +import { + CLIENT_IDENTITY_PROFILE_OPTIONS, + getClientIdentityProfileHeaders, +} from "@/shared/constants/clientIdentityProfiles"; type CompatibleMode = "openai" | "anthropic" | "cc"; type CompatibleProviderNode = { id: string } & Record; @@ -24,6 +28,7 @@ interface CompatibleFormState { chatPath: string; modelsPath: string; iconUrl: string; + clientIdentityProfile: string; } const CC_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; @@ -77,6 +82,7 @@ function createInitialForm(mode: CompatibleMode): CompatibleFormState { chatPath: defaults.chatPath, modelsPath: "", iconUrl: "", + clientIdentityProfile: "default", }; } @@ -184,6 +190,12 @@ export default function AddCompatibleProviderModal({ if (defaults.hasModelsPath) body.modelsPath = formData.modelsPath || ""; if (defaults.compatMode) body.compatMode = defaults.compatMode; body.iconUrl = formData.iconUrl.trim(); + // Merge the selected identity profile's preset headers into the SAME + // `customHeaders` field the node already persists (see + // src/lib/db/providers/nodes.ts + open-sse/executors/default.ts + // `applyCustomHeaders`) — no separate profile field, no new pipeline. + const identityHeaders = getClientIdentityProfileHeaders(formData.clientIdentityProfile); + if (Object.keys(identityHeaders).length > 0) body.customHeaders = identityHeaders; const res = await fetch("/api/provider-nodes", { method: "POST", @@ -320,6 +332,13 @@ export default function AddCompatibleProviderModal({ hint={t("modelsPathHint")} /> )} + : merge the preset onto the existing + // customHeaders record before persisting the node/connection. + const profileHeaders = getClientIdentityProfileHeaders("codex-cli"); + const providerSpecificData = { + baseUrl: "https://proxy.example.com/v1", + customHeaders: { ...profileHeaders, "X-Operator-Set": "keep-me" }, + }; + + assert.equal(providerSpecificData.customHeaders["User-Agent"], "codex_cli_rs/0.136.0"); + assert.equal(providerSpecificData.customHeaders.originator, "codex_cli_rs"); + assert.equal(providerSpecificData.customHeaders["X-Operator-Set"], "keep-me"); +}); + +test("profile headers merged into customHeaders survive applyCustomHeaders sanitization via DefaultExecutor", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + const profileHeaders = getClientIdentityProfileHeaders("claude-cli"); + + const headers = executor.buildHeaders( + { + apiKey: "test-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: profileHeaders, + }, + }, + true + ) as Record; + + assert.equal(headers["User-Agent"], "claude-cli/2.1.195 (external, cli)"); + assert.equal(headers["X-App"], "cli"); + assert.equal(headers["Authorization"], "Bearer test-key"); +}); + +test("a malicious profile-shaped header set has its auth/cookie entries dropped by applyCustomHeaders", () => { + const executor = new DefaultExecutor("openai-compatible-test"); + + // Simulate a compromised/hand-crafted profile that tries to smuggle in + // credential-owning header names alongside a legitimate identity header. + // isForbiddenCustomHeaderName is the single source of truth used by both + // the Zod schema and the executor, so assert against it directly too. + const maliciousProfileHeaders: Record = { + "User-Agent": "totally-legit-cli/1.0", + Authorization: "Bearer stolen-token", + "x-api-key": "stolen-key", + cookie: "session=stolen", + }; + assert.equal(isForbiddenCustomHeaderName("Authorization"), true); + assert.equal(isForbiddenCustomHeaderName("x-api-key"), true); + assert.equal(isForbiddenCustomHeaderName("cookie"), true); + + const headers = executor.buildHeaders( + { + apiKey: "real-key", + providerSpecificData: { + baseUrl: "https://proxy.example.com/v1", + customHeaders: maliciousProfileHeaders, + }, + }, + true + ) as Record; + + assert.equal(headers["User-Agent"], "totally-legit-cli/1.0"); + assert.equal(headers["Authorization"], "Bearer real-key"); + assert.notEqual(headers["Authorization"], "Bearer stolen-token"); + assert.equal(headers["x-api-key"], undefined); + assert.equal(headers["cookie"], undefined); +}); + +test("DefaultExecutor.execute sends the selected profile's headers for a compatible-node connection", async () => { + const executor = new DefaultExecutor("openai-compatible-test"); + const originalFetch = globalThis.fetch; + let capturedHeaders: Record = {}; + + globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => { + capturedHeaders = (init.headers as Record) || {}; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + await executor.execute({ + model: "gpt-4.1", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { + apiKey: "real-key", + providerSpecificData: { + baseUrl: "https://test.proxy.com/v1", + customHeaders: getClientIdentityProfileHeaders("gemini-cli"), + }, + }, + }); + + assert.equal(capturedHeaders["User-Agent"], "GeminiCLI/0.1.0 (linux; x64)"); + assert.equal(capturedHeaders["Authorization"], "Bearer real-key"); + } finally { + globalThis.fetch = originalFetch; + } +});