mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat(providers): client-identity header profiles for compatible nodes (re-cut) + forbid cookie in custom headers (#5812)
This commit is contained in:
committed by
GitHub
parent
6a04114f0e
commit
e12bbd33ad
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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")}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label={t("clientIdentityLabel")}
|
||||
options={CLIENT_IDENTITY_PROFILE_OPTIONS.map((option) => ({ ...option }))}
|
||||
value={formData.clientIdentityProfile}
|
||||
onChange={(e) => setFormData({ ...formData, clientIdentityProfile: e.target.value })}
|
||||
hint={t("clientIdentityHint")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4508,6 +4508,8 @@
|
||||
"modelsPathLabel": "Models Endpoint Path",
|
||||
"modelsPathPlaceholder": "/models",
|
||||
"modelsPathHint": "Custom models path for validation (e.g. /v4/models)",
|
||||
"clientIdentityLabel": "Client Identity",
|
||||
"clientIdentityHint": "Optional. Adds client fingerprint headers (e.g. User-Agent) matching a known CLI for compatible gateways that expect one.",
|
||||
"statusDeactivated": "Deactivated (Manual)",
|
||||
"statusBanned": "Banned / Sandbox Violation",
|
||||
"statusCreditsExhausted": "Insufficient Balance / Quota Exhausted",
|
||||
|
||||
89
src/shared/constants/clientIdentityProfiles.ts
Normal file
89
src/shared/constants/clientIdentityProfiles.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Named "client identity" header presets for OpenAI-/Anthropic-compatible
|
||||
* provider nodes (e.g. mimicking a known CLI's `User-Agent`).
|
||||
*
|
||||
* This module is intentionally dumb: it only supplies preset header
|
||||
* VALUES. It introduces NO new header-merge path — selecting a profile in
|
||||
* the compatible-provider UI merges its headers into the SAME
|
||||
* `providerSpecificData.customHeaders` field already wired end-to-end
|
||||
* (node -> connection -> `DefaultExecutor.buildHeaders()` ->
|
||||
* `applyCustomHeaders()` in `open-sse/executors/default.ts`), which already
|
||||
* sanitizes via `isForbiddenCustomHeaderName` (`upstreamHeaders.ts`) and is
|
||||
* applied AFTER the credential-auth headers are set. Auth/cookie headers
|
||||
* therefore always win over anything a profile (or a hand-edited custom
|
||||
* header) tries to set — no new precedence logic is needed here.
|
||||
*/
|
||||
|
||||
export interface ClientIdentityProfile {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
const DEFAULT_PROFILE: ClientIdentityProfile = Object.freeze({
|
||||
id: "default",
|
||||
label: "Default",
|
||||
headers: Object.freeze({}),
|
||||
});
|
||||
|
||||
const CLAUDE_CLI_PROFILE: ClientIdentityProfile = Object.freeze({
|
||||
id: "claude-cli",
|
||||
label: "Claude CLI",
|
||||
headers: Object.freeze({
|
||||
"User-Agent": "claude-cli/2.1.195 (external, cli)",
|
||||
"X-App": "cli",
|
||||
}),
|
||||
});
|
||||
|
||||
const CODEX_CLI_PROFILE: ClientIdentityProfile = Object.freeze({
|
||||
id: "codex-cli",
|
||||
label: "Codex CLI",
|
||||
headers: Object.freeze({
|
||||
"User-Agent": "codex_cli_rs/0.136.0",
|
||||
originator: "codex_cli_rs",
|
||||
}),
|
||||
});
|
||||
|
||||
const GEMINI_CLI_PROFILE: ClientIdentityProfile = Object.freeze({
|
||||
id: "gemini-cli",
|
||||
label: "Gemini CLI",
|
||||
headers: Object.freeze({
|
||||
"User-Agent": "GeminiCLI/0.1.0 (linux; x64)",
|
||||
}),
|
||||
});
|
||||
|
||||
/** Ordered so `CLIENT_IDENTITY_PROFILE_OPTIONS` renders "Default" first. */
|
||||
export const CLIENT_IDENTITY_PROFILES: Readonly<Record<string, ClientIdentityProfile>> =
|
||||
Object.freeze({
|
||||
default: DEFAULT_PROFILE,
|
||||
"claude-cli": CLAUDE_CLI_PROFILE,
|
||||
"codex-cli": CODEX_CLI_PROFILE,
|
||||
"gemini-cli": GEMINI_CLI_PROFILE,
|
||||
});
|
||||
|
||||
export const CLIENT_IDENTITY_PROFILE_IDS: readonly string[] = Object.keys(
|
||||
CLIENT_IDENTITY_PROFILES
|
||||
);
|
||||
|
||||
export const CLIENT_IDENTITY_PROFILE_OPTIONS: ReadonlyArray<{ value: string; label: string }> =
|
||||
CLIENT_IDENTITY_PROFILE_IDS.map((id) => ({
|
||||
value: id,
|
||||
label: CLIENT_IDENTITY_PROFILES[id].label,
|
||||
}));
|
||||
|
||||
export function isClientIdentityProfileId(value: unknown): value is string {
|
||||
return typeof value === "string" && Object.prototype.hasOwnProperty.call(CLIENT_IDENTITY_PROFILES, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain (mutable, unfrozen) copy of the preset's headers so callers
|
||||
* can safely spread/merge it into `providerSpecificData.customHeaders`
|
||||
* without ever needing to sanitize here — that happens downstream in
|
||||
* `applyCustomHeaders()` regardless of where the header entries came from.
|
||||
*/
|
||||
export function getClientIdentityProfileHeaders(
|
||||
profileId: string | undefined | null
|
||||
): Record<string, string> {
|
||||
if (!isClientIdentityProfileId(profileId)) return {};
|
||||
return { ...CLIENT_IDENTITY_PROFILES[profileId].headers };
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export function isForbiddenUpstreamHeaderName(name: string): boolean {
|
||||
* apply-loop (open-sse/executors/default.ts) cannot drift apart.
|
||||
*/
|
||||
const FORBIDDEN_AUTH = new Set(
|
||||
["authorization", "x-api-key", "x-goog-api-key", "api-key"].map((s) => s.toLowerCase())
|
||||
["authorization", "x-api-key", "x-goog-api-key", "api-key", "cookie"].map((s) => s.toLowerCase())
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
174
tests/unit/client-identity-profiles.test.ts
Normal file
174
tests/unit/client-identity-profiles.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// DefaultExecutor transitively touches the DB layer (provider/key rotation
|
||||
// lookups) at import/call time. Point DATA_DIR at an isolated temp dir
|
||||
// BEFORE importing it so these tests never read/write the operator's real
|
||||
// ~/.omniroute database (see CLAUDE.md "Database Handles in Tests").
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-client-identity-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const {
|
||||
CLIENT_IDENTITY_PROFILES,
|
||||
CLIENT_IDENTITY_PROFILE_OPTIONS,
|
||||
getClientIdentityProfileHeaders,
|
||||
isClientIdentityProfileId,
|
||||
} = await import("../../src/shared/constants/clientIdentityProfiles.ts");
|
||||
const { isForbiddenCustomHeaderName } = await import("../../src/shared/constants/upstreamHeaders.ts");
|
||||
const { DefaultExecutor } = await import("../../open-sse/executors/default.ts");
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("getClientIdentityProfileHeaders: default profile adds no headers", () => {
|
||||
assert.deepEqual(getClientIdentityProfileHeaders("default"), {});
|
||||
assert.deepEqual(getClientIdentityProfileHeaders(undefined), {});
|
||||
assert.deepEqual(getClientIdentityProfileHeaders(null), {});
|
||||
});
|
||||
|
||||
test("getClientIdentityProfileHeaders: unknown profile id falls back to no headers", () => {
|
||||
assert.deepEqual(getClientIdentityProfileHeaders("not-a-real-profile"), {});
|
||||
});
|
||||
|
||||
test("getClientIdentityProfileHeaders: known CLI profiles expose their preset headers", () => {
|
||||
const claudeCli = getClientIdentityProfileHeaders("claude-cli");
|
||||
assert.equal(claudeCli["User-Agent"], "claude-cli/2.1.195 (external, cli)");
|
||||
assert.equal(claudeCli["X-App"], "cli");
|
||||
|
||||
const codexCli = getClientIdentityProfileHeaders("codex-cli");
|
||||
assert.equal(codexCli["User-Agent"], "codex_cli_rs/0.136.0");
|
||||
assert.equal(codexCli.originator, "codex_cli_rs");
|
||||
|
||||
const geminiCli = getClientIdentityProfileHeaders("gemini-cli");
|
||||
assert.equal(geminiCli["User-Agent"], "GeminiCLI/0.1.0 (linux; x64)");
|
||||
});
|
||||
|
||||
test("getClientIdentityProfileHeaders: returns a fresh mutable copy (catalog stays frozen)", () => {
|
||||
const headers = getClientIdentityProfileHeaders("claude-cli");
|
||||
headers["User-Agent"] = "tampered";
|
||||
assert.equal(
|
||||
CLIENT_IDENTITY_PROFILES["claude-cli"].headers["User-Agent"],
|
||||
"claude-cli/2.1.195 (external, cli)"
|
||||
);
|
||||
});
|
||||
|
||||
test("isClientIdentityProfileId / CLIENT_IDENTITY_PROFILE_OPTIONS stay in sync with the catalog", () => {
|
||||
for (const id of Object.keys(CLIENT_IDENTITY_PROFILES)) {
|
||||
assert.equal(isClientIdentityProfileId(id), true);
|
||||
}
|
||||
assert.equal(isClientIdentityProfileId("bogus"), false);
|
||||
|
||||
const optionValues = CLIENT_IDENTITY_PROFILE_OPTIONS.map((o) => o.value);
|
||||
assert.deepEqual(optionValues, Object.keys(CLIENT_IDENTITY_PROFILES));
|
||||
assert.equal(CLIENT_IDENTITY_PROFILE_OPTIONS[0].value, "default");
|
||||
});
|
||||
|
||||
test("a selected profile's headers land in providerSpecificData.customHeaders", () => {
|
||||
// This is exactly what the compatible-provider modal does when an operator
|
||||
// picks a profile from the <Select>: 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<string, string>;
|
||||
|
||||
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<string, string> = {
|
||||
"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<string, string>;
|
||||
|
||||
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<string, string> = {};
|
||||
|
||||
globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => {
|
||||
capturedHeaders = (init.headers as Record<string, string>) || {};
|
||||
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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user