diff --git a/docs/api-reference/openai.mdx b/docs/api-reference/openai.mdx index 7c3000a..8a85465 100644 --- a/docs/api-reference/openai.mdx +++ b/docs/api-reference/openai.mdx @@ -11,7 +11,7 @@ POST /openai/v1/responses ``` -These endpoints receive PII detection and masking. Other OpenAI endpoints (`/models`, `/embeddings`, `/files`, etc.) are proxied directly without modification. +These endpoints receive PII detection and masking. Other OpenAI endpoints (`/models`, `/embeddings`, `/files`, etc.) pass their request bodies through unchanged. ## Chat Completions request diff --git a/docs/configuration/providers.mdx b/docs/configuration/providers.mdx index c206b22..59c241b 100644 --- a/docs/configuration/providers.mdx +++ b/docs/configuration/providers.mdx @@ -19,7 +19,7 @@ providers: | Option | Description | |--------|-------------| | `base_url` | API endpoint (any OpenAI-compatible URL) | -| `api_key` | Optional. Used if client doesn't send Authorization header | +| `api_key` | Optional fallback Bearer token | ### Compatible APIs @@ -72,7 +72,7 @@ providers: | Option | Description | |--------|-------------| | `base_url` | Anthropic API endpoint | -| `api_key` | Optional. Used if client doesn't send `x-api-key` header | +| `api_key` | Optional fallback `x-api-key` | ## Codex CLI Backend @@ -145,20 +145,14 @@ local: api_key: ${LOCAL_API_KEY} # if required ``` -## API Key Handling +## Client Headers -PasteGuard forwards your client's authentication headers to the configured upstream. You can optionally set `api_key` in config as a fallback for OpenAI and Anthropic. Codex CLI traffic uses the CLI's ChatGPT-login authorization header: +OpenAI, Anthropic, and Codex exclude `x-pasteguard-*`, Open WebUI identity headers (`x-openwebui-user-*`, `x-openwebui-chat-id`), and original host/connection headers. Other headers pass through unmasked. -```yaml -providers: - openai: - base_url: https://api.openai.com/v1 - api_key: ${OPENAI_API_KEY} # Used if client doesn't send auth +JSON calls rebuild body headers; passthrough preserves content type, encoding, and length. - anthropic: - base_url: https://api.anthropic.com - api_key: ${ANTHROPIC_API_KEY} # Used if client doesn't send x-api-key +## API Key Handling - codex: - base_url: https://chatgpt.com/backend-api/codex -``` +Client auth takes priority. Configured keys are fallbacks for protected OpenAI/Anthropic calls. Local reroutes use local credentials. + +Anthropic supplies `anthropic-version: 2023-06-01` only when absent. diff --git a/package.json b/package.json index 27731b5..4c1038b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pasteguard", - "version": "0.9.4", + "version": "0.9.5", "description": "Privacy proxy for LLMs. Masks personal data and secrets before sending to your provider.", "type": "module", "main": "src/index.ts", diff --git a/src/providers/anthropic/client.ts b/src/providers/anthropic/client.ts index 41c095c..7cf8a9c 100644 --- a/src/providers/anthropic/client.ts +++ b/src/providers/anthropic/client.ts @@ -4,6 +4,7 @@ import { type AnthropicProviderConfig, getConfig } from "../../config"; import { ProviderError } from "../errors"; +import { buildUpstreamHeaders } from "../forwarded-headers"; import type { AnthropicRequest, AnthropicResponse } from "./types"; export const ANTHROPIC_VERSION = "2023-06-01"; @@ -24,47 +25,20 @@ export type AnthropicResult = model: string; }; -/** - * Client headers forwarded from the request - */ -export interface AnthropicClientHeaders { - apiKey?: string; - authorization?: string; - beta?: string; -} - /** * Call Anthropic Messages API - * - * Transparent header forwarding - all auth headers from client are passed through. - * Config api_key is only used as fallback when no client auth headers present. */ export async function callAnthropic( request: AnthropicRequest, config: AnthropicProviderConfig, - clientHeaders?: AnthropicClientHeaders, + clientHeaders: Record = {}, ): Promise { const isStreaming = request.stream ?? false; const baseUrl = (config.base_url || DEFAULT_ANTHROPIC_URL).replace(/\/$/, ""); - const headers: Record = { - "Content-Type": "application/json", - "anthropic-version": ANTHROPIC_VERSION, - }; - - // Transparent auth forwarding - client headers take priority - if (clientHeaders?.apiKey) { - headers["x-api-key"] = clientHeaders.apiKey; - } else if (clientHeaders?.authorization) { - headers.Authorization = clientHeaders.authorization; - } else if (config.api_key) { - // Fallback to config only if no client auth - headers["x-api-key"] = config.api_key; - } - - // Forward client's beta header unchanged - if (clientHeaders?.beta) { - headers["anthropic-beta"] = clientHeaders.beta; + const headers = buildUpstreamHeaders(clientHeaders, config.api_key, "x-api-key"); + if (!headers.has("anthropic-version")) { + headers.set("anthropic-version", ANTHROPIC_VERSION); } const timeoutMs = getConfig().server.request_timeout * 1000; diff --git a/src/providers/forwarded-headers.ts b/src/providers/forwarded-headers.ts new file mode 100644 index 0000000..2c14feb --- /dev/null +++ b/src/providers/forwarded-headers.ts @@ -0,0 +1,57 @@ +// Strip identifying metadata and headers tied to the original connection. +const EXCLUDED_HEADERS = new Set([ + "connection", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "x-openwebui-chat-id", +]); + +export function forwardClientHeaders(clientHeaders: Record): Headers { + const headers = new Headers(clientHeaders); + const connectionHeaders = new Set( + (headers.get("connection") ?? "").split(",").map((name) => name.trim().toLowerCase()), + ); + + for (const name of Object.keys(clientHeaders)) { + const lower = name.toLowerCase(); + if ( + lower.startsWith("x-pasteguard-") || + lower.startsWith("x-openwebui-user-") || + EXCLUDED_HEADERS.has(lower) || + connectionHeaders.has(lower) + ) { + headers.delete(name); + } + } + + return headers; +} + +export function buildUpstreamHeaders( + clientHeaders: Record, + providerApiKey?: string, + authHeader: "authorization" | "x-api-key" = "authorization", +): Headers { + const headers = forwardClientHeaders(clientHeaders); + headers.delete("content-length"); + headers.delete("content-encoding"); + headers.set("content-type", "application/json"); + if ( + providerApiKey && + !headers.has("authorization") && + !headers.has("api-key") && + !headers.has("x-api-key") + ) { + headers.set( + authHeader, + authHeader === "authorization" ? `Bearer ${providerApiKey}` : providerApiKey, + ); + } + return headers; +} diff --git a/src/providers/local.ts b/src/providers/local.ts index cb20624..165a4a8 100644 --- a/src/providers/local.ts +++ b/src/providers/local.ts @@ -7,6 +7,7 @@ import { getConfig, type LocalProviderConfig } from "../config"; import { HEALTH_CHECK_TIMEOUT_MS } from "../constants/timeouts"; import type { AnthropicResult } from "./anthropic/client"; import type { AnthropicRequest, AnthropicResponse } from "./anthropic/types"; +import { buildUpstreamHeaders } from "./forwarded-headers"; import { ProviderError, type ProviderResult } from "./openai/client"; import type { OpenAIRequest } from "./openai/types"; @@ -21,17 +22,12 @@ export async function callLocal( const endpoint = config.type === "ollama" ? `${baseUrl}/v1/chat/completions` : `${baseUrl}/chat/completions`; - const headers: Record = { "Content-Type": "application/json" }; - if (config.api_key) { - headers.Authorization = `Bearer ${config.api_key}`; - } - const isStreaming = request.stream ?? false; const timeoutMs = getConfig().server.request_timeout * 1000; const response = await fetch(endpoint, { method: "POST", - headers, + headers: buildUpstreamHeaders({}, config.api_key), body: JSON.stringify({ ...request, model: config.model, stream: isStreaming }), signal: timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined, }); @@ -63,19 +59,12 @@ export async function callLocalAnthropic( // Ollama's Anthropic-compatible endpoint const endpoint = `${baseUrl}/v1/messages`; - const headers: Record = { - "Content-Type": "application/json", - }; - if (config.api_key) { - headers.Authorization = `Bearer ${config.api_key}`; - } - const isStreaming = request.stream ?? false; const timeoutMs = getConfig().server.request_timeout * 1000; const response = await fetch(endpoint, { method: "POST", - headers, + headers: buildUpstreamHeaders({}, config.api_key), body: JSON.stringify({ ...request, model: config.model, stream: isStreaming }), signal: timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined, }); diff --git a/src/providers/openai/client.ts b/src/providers/openai/client.ts index 902ee08..f17eddd 100644 --- a/src/providers/openai/client.ts +++ b/src/providers/openai/client.ts @@ -4,6 +4,7 @@ import { getConfig, type OpenAIProviderConfig } from "../../config"; import { ProviderError } from "../errors"; +import { buildUpstreamHeaders } from "../forwarded-headers"; import type { OpenAIRequest, OpenAIResponse } from "./types"; export { ProviderError } from "../errors"; @@ -29,7 +30,7 @@ export type ProviderResult = export async function callOpenAI( request: OpenAIRequest, config: OpenAIProviderConfig, - authHeader?: string, + clientHeaders: Record = {}, ): Promise { const model = request.model; const isStreaming = request.stream ?? false; @@ -41,17 +42,6 @@ export async function callOpenAI( const baseUrl = config.base_url.replace(/\/$/, ""); const endpoint = `${baseUrl}/chat/completions`; - const headers: Record = { - "Content-Type": "application/json", - }; - - // Use client's auth header if provided, otherwise fall back to config - if (authHeader) { - headers.Authorization = authHeader; - } else if (config.api_key) { - headers.Authorization = `Bearer ${config.api_key}`; - } - // Build request body const body: Record = { ...request, @@ -68,7 +58,7 @@ export async function callOpenAI( const timeoutMs = getConfig().server.request_timeout * 1000; const response = await fetch(endpoint, { method: "POST", - headers, + headers: buildUpstreamHeaders(clientHeaders, config.api_key), body: JSON.stringify(body), signal: timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined, }); diff --git a/src/routes/anthropic.test.ts b/src/routes/anthropic.test.ts index dcb4f39..640cda4 100644 --- a/src/routes/anthropic.test.ts +++ b/src/routes/anthropic.test.ts @@ -35,6 +35,8 @@ app.route("/anthropic", anthropicRoutes); const originalFetch = globalThis.fetch; const config = getConfig(); const originalMode = config.mode; +const originalAPIKey = config.providers.anthropic.api_key; +const originalLocal = config.local ? { ...config.local } : undefined; const originalPiiScanRoles = [...config.pii_detection.scan_roles]; const originalSecretsEnabled = config.secrets_detection.enabled; const originalSecretsAction = config.secrets_detection.action; @@ -43,6 +45,9 @@ const originalSecretsScanRoles = [...config.secrets_detection.scan_roles]; afterEach(() => { globalThis.fetch = originalFetch; config.mode = originalMode; + config.providers.anthropic.api_key = originalAPIKey; + if (originalLocal) config.local = { ...originalLocal }; + else delete config.local; config.pii_detection.scan_roles = [...originalPiiScanRoles]; config.secrets_detection.enabled = originalSecretsEnabled; config.secrets_detection.action = originalSecretsAction; @@ -53,6 +58,141 @@ afterEach(() => { }); describe("POST /anthropic/v1/messages", () => { + test("forwards client headers except internal and transport headers", async () => { + config.mode = "mask"; + config.providers.anthropic.api_key = "configured-key"; + const forwardedHeaders = { + "Anthropic-Beta": "prompt-caching-2024-07-31", + "X-New-Provider-Feature": "future-value", + "X-Stainless-Runtime": "bun", + Cookie: "provider-session=value", + }; + const rejectedHeaders = { + Host: "proxy.example", + Connection: "keep-alive, X-Hop-Token", + "Keep-Alive": "timeout=5", + "Content-Encoding": "gzip", + "Content-Length": "999", + "Proxy-Authorization": "Bearer proxy-token", + "X-Hop-Token": "connection-only", + "X-PasteGuard-Source": "client", + "X-PasteGuard-Future": "internal", + "X-OpenWebUI-User-Email": "identity@example.com", + "X-OpenWebUI-Chat-Id": "chat-123", + }; + let upstreamHeaders = new Headers(); + globalThis.fetch = (async (_target: string | URL | Request, init?: RequestInit) => { + upstreamHeaders = new Headers(init?.headers); + return Response.json({ content: [], model: "claude-test" }); + }) as typeof fetch; + + for (const clientVersion of [undefined, "2025-01-01"]) { + const response = await app.request("/anthropic/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + ...(clientVersion + ? { "Anthropic-Version": clientVersion, "X-Api-Key": "client-key" } + : {}), + ...forwardedHeaders, + ...rejectedHeaders, + }, + body: JSON.stringify({ + model: "claude-test", + max_tokens: 128, + messages: [{ role: "user", content: "Reply ok" }], + }), + }); + + expect(response.status).toBe(200); + expect(upstreamHeaders.get("anthropic-version")).toBe(clientVersion ?? "2023-06-01"); + expect(upstreamHeaders.get("x-api-key")).toBe( + clientVersion ? "client-key" : "configured-key", + ); + expect(upstreamHeaders.get("content-type")).toBe("application/json"); + for (const [name, value] of Object.entries(forwardedHeaders)) { + expect(upstreamHeaders.get(name)).toBe(value); + } + for (const name of Object.keys(rejectedHeaders)) { + expect(upstreamHeaders.get(name)).toBeNull(); + } + } + }); + + test("isolates local credentials and remasks restored tool-history secrets while keeping PII clear", async () => { + config.mode = "route"; + config.secrets_detection.enabled = true; + config.secrets_detection.action = "mask"; + config.secrets_detection.scan_roles = ["user", "tool", "function", "mcp"]; + config.local = { + type: "ollama", + base_url: "http://local.test", + model: "local-model", + api_key: "local-key", + }; + const email = "jane@example.com"; + const secret = "sk-proj-abc123def456ghi789jkl012mno345pqr678stu901vwx"; + mockAnalyzeRequest.mockResolvedValueOnce({ + hasPII: true, + spanEntities: [[{ entity_type: "EMAIL_ADDRESS", start: 0, end: email.length, score: 0.99 }]], + allEntities: [{ entity_type: "EMAIL_ADDRESS", start: 0, end: email.length, score: 0.99 }], + scanTimeMs: 2, + }); + let upstream: Request | undefined; + globalThis.fetch = (async (target: string | URL | Request, init?: RequestInit) => { + upstream = target instanceof Request ? target : new Request(target, init); + return Response.json({ content: [], model: "local-model" }); + }) as typeof fetch; + + const response = await app.request("/anthropic/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer cloud-token", + "X-Api-Key": "cloud-key", + Cookie: "cloud-session=value", + "X-Client-Metadata": "cloud-context", + }, + body: JSON.stringify({ + model: "claude-test", + max_tokens: 128, + messages: [ + { role: "user", content: `${email} key ${secret}` }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool_123", + name: "send_email", + input: { email, key: secret }, + }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "tool_123", content: "sent" }], + }, + ], + }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get("X-PasteGuard-Provider")).toBe("local"); + const body = await upstream!.text(); + expect(body).toContain(email); + expect(body).not.toContain(secret); + expect(JSON.parse(body).messages[1].content[0].input).toEqual({ + email, + key: "[[API_KEY_SK_1]]", + }); + expect(upstream?.url).toBe("http://local.test/v1/messages"); + expect(upstream?.headers.get("authorization")).toBe("Bearer local-key"); + for (const name of ["x-api-key", "cookie", "x-client-metadata"]) { + expect(upstream?.headers.get(name)).toBeNull(); + } + }); + test("remasks known PII in assistant tool-use history before forwarding", async () => { const email = "jane@example.com"; const userText = `Email ${email}`; diff --git a/src/routes/anthropic.ts b/src/routes/anthropic.ts index c52198c..0e9b2fb 100644 --- a/src/routes/anthropic.ts +++ b/src/routes/anthropic.ts @@ -20,6 +20,7 @@ import { AnthropicRequestSchema, type AnthropicResponse, } from "../providers/anthropic/types"; +import { forwardClientHeaders } from "../providers/forwarded-headers"; import { callLocalAnthropic } from "../providers/local"; import type { SecretsProcessResult } from "../secrets/request"; import { @@ -114,7 +115,11 @@ anthropicRoutes.post( if (shouldRouteToLocal) { return sendToLocal(c, request, { - request: privacy.requestAfterSecrets, + request: remaskAnthropicToolUseHistory( + privacy.requestAfterSecrets, + undefined, + secretsResult.maskingContext, + ), startTime, piiResult, secretsResult, @@ -154,14 +159,16 @@ anthropicRoutes.all("/*", async (c) => { const baseUrl = config.providers.anthropic.base_url || "https://api.anthropic.com"; const path = c.req.path.replace(/^\/anthropic/, ""); const query = c.req.url.includes("?") ? c.req.url.slice(c.req.url.indexOf("?")) : ""; + const headers = forwardClientHeaders(c.req.header()); + const originalHost = c.req.header("host"); + headers.delete("X-Forwarded-Host"); + if (originalHost) { + headers.set("X-Forwarded-Host", originalHost); + } return proxy(`${baseUrl}${path}${query}`, { ...c.req, - headers: { - ...c.req.header(), - "X-Forwarded-Host": c.req.header("host"), - host: undefined, - }, + headers, }); }); @@ -322,14 +329,8 @@ async function sendToAnthropic(c: Context, request: AnthropicRequest, opts: Send toSecretsHeaderData(secretsResult), ); - const clientHeaders = { - apiKey: c.req.header("x-api-key"), - authorization: c.req.header("Authorization"), - beta: c.req.header("anthropic-beta"), - }; - try { - const result = await callAnthropic(request, config.providers.anthropic!, clientHeaders); + const result = await callAnthropic(request, config.providers.anthropic!, c.req.header()); logRequest( createLogData({ diff --git a/src/routes/codex.test.ts b/src/routes/codex.test.ts index e760808..6648985 100644 --- a/src/routes/codex.test.ts +++ b/src/routes/codex.test.ts @@ -99,6 +99,53 @@ describe("Codex proxy", () => { expect(mockLogRequest).toHaveBeenCalled(); }); + test("forwards client headers without PasteGuard or transport headers on protected responses", async () => { + let upstreamHeaders = new Headers(); + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + upstreamHeaders = new Headers(init?.headers); + return Response.json({ output: [] }); + }) as typeof fetch; + + const res = await app.request("/codex/responses", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.5", input: "Reply ok" }), + headers: { + Authorization: "Bearer chatgpt-token", + "X-Future-Provider-Feature": "enabled", + "X-PasteGuard-Internal": "private", + "X-OpenWebUI-User-Email": "identity@example.com", + "X-OpenWebUI-Chat-Id": "chat-123", + Host: "pasteguard.example", + "X-Forwarded-Host": "client-supplied.example", + "Content-Type": "application/json; charset=utf-8", + "Content-Encoding": "gzip", + "Content-Length": "999", + Connection: "keep-alive, X-Client-Connection", + "Keep-Alive": "timeout=5", + "X-Client-Connection": "private", + }, + }); + + expect(res.status).toBe(200); + expect(upstreamHeaders.get("authorization")).toBe("Bearer chatgpt-token"); + expect(upstreamHeaders.get("x-future-provider-feature")).toBe("enabled"); + expect(upstreamHeaders.get("x-forwarded-host")).toBe("pasteguard.example"); + expect(upstreamHeaders.get("content-type")).toBe("application/json"); + for (const name of [ + "x-pasteguard-internal", + "x-openwebui-user-email", + "x-openwebui-chat-id", + "host", + "content-length", + "content-encoding", + "connection", + "keep-alive", + "x-client-connection", + ]) { + expect(upstreamHeaders.has(name)).toBe(false); + } + }); + test("masks PII in POST /codex/responses and logs it for the dashboard", async () => { mockAnalyzeRequest.mockResolvedValueOnce({ hasPII: true, @@ -454,6 +501,12 @@ describe("Codex proxy", () => { const res = await app.request("/codex/models?client_version=0.128.0", { headers: { Authorization: "Bearer chatgpt-token", + "X-Future-Provider-Feature": "enabled", + "X-PasteGuard-Internal": "private", + Host: "pasteguard.example", + "X-Forwarded-Host": "client-supplied.example", + Connection: "X-Client-Connection", + "X-Client-Connection": "private", }, }); @@ -461,5 +514,12 @@ describe("Codex proxy", () => { expect(calls[0].url).toBe( `${getConfig().providers.codex.base_url.replace(/\/$/, "")}/models?client_version=0.128.0`, ); + expect(calls[0].headers.get("authorization")).toBe("Bearer chatgpt-token"); + expect(calls[0].headers.get("x-future-provider-feature")).toBe("enabled"); + expect(calls[0].headers.get("x-forwarded-host")).toBe("pasteguard.example"); + expect(calls[0].headers.has("x-pasteguard-internal")).toBe(false); + expect(calls[0].headers.has("host")).toBe(false); + expect(calls[0].headers.has("connection")).toBe(false); + expect(calls[0].headers.has("x-client-connection")).toBe(false); }); }); diff --git a/src/routes/codex.ts b/src/routes/codex.ts index 445b4dd..e5fe837 100644 --- a/src/routes/codex.ts +++ b/src/routes/codex.ts @@ -21,6 +21,7 @@ import { } from "../privacy/pipeline"; import { createResponsesUnmaskingStream } from "../protocols/responses/stream-transformer"; import { ProviderError } from "../providers/errors"; +import { buildUpstreamHeaders, forwardClientHeaders } from "../providers/forwarded-headers"; import type { SecretsProcessResult } from "../secrets/request"; import { createLogData, @@ -99,7 +100,7 @@ codexRoutes.post( piiMaskingContext: privacy.piiMaskingContext, secretsResult, startTime, - headers: getForwardHeaders(c), + headers: c.req.header(), }); }, ); @@ -109,14 +110,14 @@ codexRoutes.all("/*", (c) => { const normalizedBaseUrl = config.providers.codex.base_url.replace(/\/$/, ""); const path = c.req.path.replace(/^\/codex/, ""); const query = c.req.url.includes("?") ? c.req.url.slice(c.req.url.indexOf("?")) : ""; + const headers = forwardClientHeaders(c.req.header()); + const host = c.req.header("host"); + headers.delete("X-Forwarded-Host"); + if (host) headers.set("X-Forwarded-Host", host); return proxy(`${normalizedBaseUrl}${path}${query}`, { ...c.req, - headers: { - ...c.req.header(), - "X-Forwarded-Host": c.req.header("host"), - host: undefined, - }, + headers, }); }); @@ -127,13 +128,12 @@ async function callCodex( ): Promise { const endpoint = `${baseUrl.replace(/\/$/, "")}/responses`; const timeoutMs = getConfig().server.request_timeout * 1000; + const upstreamHeaders = buildUpstreamHeaders(headers); + upstreamHeaders.set("X-Forwarded-Host", headers.host || ""); const response = await fetch(endpoint, { method: "POST", - headers: { - ...headers, - "Content-Type": "application/json", - }, + headers: upstreamHeaders, body: JSON.stringify(request), signal: timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined, }); @@ -154,17 +154,6 @@ interface CodexOptions { headers: Record; } -function getForwardHeaders(c: Context): Record { - const headers: Record = {}; - for (const [key, value] of Object.entries(c.req.header())) { - const lower = key.toLowerCase(); - if (lower === "host" || lower === "content-length" || lower === "content-type") continue; - headers[key] = value; - } - headers["X-Forwarded-Host"] = c.req.header("host") || ""; - return headers; -} - function formatCodexForLog(request: CodexResponsesRequest): string | undefined { const config = getConfig(); return formatMaskedRequestForLog(request, responsesExtractor, config); diff --git a/src/routes/header-forwarding.test.ts b/src/routes/header-forwarding.test.ts new file mode 100644 index 0000000..8cee846 --- /dev/null +++ b/src/routes/header-forwarding.test.ts @@ -0,0 +1,178 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import { getConfig } from "../config"; +import { buildUpstreamHeaders } from "../providers/forwarded-headers"; +import { anthropicRoutes } from "./anthropic"; +import { codexRoutes } from "./codex"; +import { openaiRoutes } from "./openai"; + +const app = new Hono(); +app.route("/openai", openaiRoutes); +app.route("/anthropic", anthropicRoutes); +app.route("/codex", codexRoutes); + +const originalFetch = globalThis.fetch; +const config = getConfig(); +const originalAnthropic = config.providers.anthropic; +const identityHeaders = { + "X-OpenWebUI-User-Email": "identity@example.com", + "x-oPeNwEbUi-UsEr-NaMe": "Synthetic User", + "X-OpenWebUI-User-Id": "user-123", + "X-OpenWebUI-Chat-Id": "chat-123", + "X-OpenWebUI-User-Future": "private", +}; + +afterEach(() => { + globalThis.fetch = originalFetch; + config.providers.anthropic = originalAnthropic; +}); + +describe("Provider passthrough headers", () => { + test.each([ + ["openai", "/openai/v1/files", "/files"], + ["anthropic", "/anthropic/v1/files", "/v1/files"], + ["codex", "/codex/files", "/files"], + ] as const)("%s preserves raw body headers and excludes internal/connection headers", async (provider, path, upstreamPath) => { + config.providers.anthropic ??= { base_url: "https://anthropic.test" }; + const compressed = Bun.gzipSync(new TextEncoder().encode("file payload")); + let upstream: Request | undefined; + globalThis.fetch = (async (target: string | URL | Request, init?: RequestInit) => { + upstream = target instanceof Request ? target : new Request(target, init); + return Response.json({ id: "file_test" }); + }) as typeof fetch; + + for (const host of [undefined, "pasteguard.test"]) { + const response = await app.request(`${path}?purpose=test`, { + method: "POST", + headers: { + ...identityHeaders, + Authorization: "Bearer client-token", + "Content-Type": "application/octet-stream", + "Content-Encoding": "gzip", + "Content-Length": String(compressed.length), + ...(host ? { Host: host } : {}), + "X-Forwarded-Host": "client-supplied.example", + Connection: "keep-alive, X-Hop-Token", + "Keep-Alive": "timeout=5", + "X-Hop-Token": "connection-only", + "X-PasteGuard-Source": "client", + "X-New-Provider-Feature": "future-value", + }, + body: compressed, + }); + + expect(response.status).toBe(200); + expect(upstream?.url).toBe( + `${config.providers[provider]!.base_url.replace(/\/$/, "")}${upstreamPath}?purpose=test`, + ); + expect(upstream?.method).toBe("POST"); + expect(upstream?.headers.get("authorization")).toBe("Bearer client-token"); + expect(upstream?.headers.get("x-new-provider-feature")).toBe("future-value"); + expect(upstream?.headers.get("x-forwarded-host")).toBe(host ?? null); + expect(upstream?.headers.get("content-type")).toBe("application/octet-stream"); + expect(upstream?.headers.get("content-encoding")).toBe("gzip"); + expect(upstream?.headers.get("content-length")).toBe(String(compressed.length)); + for (const name of [ + "host", + "connection", + "keep-alive", + "x-hop-token", + "x-pasteguard-source", + ...Object.keys(identityHeaders), + ]) { + expect(upstream?.headers.get(name)).toBeNull(); + } + expect(new Uint8Array(await upstream!.arrayBuffer())).toEqual(compressed); + } + }); + + test.each([ + ["openai", "/openai/v1/files"], + ["openai", "/openai/v1/audio/transcriptions"], + ["anthropic", "/anthropic/v1/files"], + ["codex", "/codex/files"], + ] as const)("keeps fixed-length uploads over HTTP on %s (%s)", async (provider, path) => { + const prefix = + '--upload\r\nContent-Disposition: form-data; name="file"; filename="test.txt"\r\n\r\n'; + const suffix = "\r\n--upload--\r\n"; + const payload = new TextEncoder().encode( + prefix + "x".repeat(5000 - prefix.length - suffix.length) + suffix, + ); + let upstreamHeaders = new Headers(); + let upstreamBody = new Uint8Array(); + const upstream = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + upstreamHeaders = request.headers; + upstreamBody = new Uint8Array(await request.arrayBuffer()); + return new Response("ok", { status: request.headers.has("content-length") ? 200 : 411 }); + }, + }); + const proxy = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: app.fetch }); + const originalProvider = config.providers[provider]; + config.providers[provider] = { + ...originalProvider, + base_url: `http://127.0.0.1:${upstream.port}`, + }; + + try { + const response = await fetch(`http://127.0.0.1:${proxy.port}${path}`, { + method: "POST", + headers: { + ...identityHeaders, + "Content-Type": "multipart/form-data; boundary=upload", + "Content-Length": String(payload.length), + }, + body: payload, + signal: AbortSignal.timeout(5000), + }); + await response.text(); + expect(response.status).toBe(200); + expect(upstreamHeaders.get("content-length")).toBe(String(payload.length)); + expect(upstreamHeaders.get("transfer-encoding")).toBeNull(); + for (const name of Object.keys(identityHeaders)) { + expect(upstreamHeaders.get(name)).toBeNull(); + } + expect(upstreamBody).toEqual(payload); + } finally { + config.providers[provider] = originalProvider; + proxy.stop(true); + upstream.stop(true); + } + }); +}); + +describe("Shared upstream authentication", () => { + test.each([ + "authorization", + "x-api-key", + ] as const)("uses configured %s only without client authentication", (authHeader) => { + const fallback = authHeader === "authorization" ? "Bearer configured-key" : "configured-key"; + expect(buildUpstreamHeaders({}, "configured-key", authHeader).get(authHeader)).toBe(fallback); + expect(buildUpstreamHeaders({}, undefined, authHeader).get(authHeader)).toBeNull(); + + for (const clientHeaders of [ + { Authorization: "Bearer client-token" }, + { "Api-Key": "client-key" }, + { "X-Api-Key": "client-key" }, + { Authorization: "Bearer client-token", "X-Api-Key": "client-key" }, + ] as Record[]) { + const upstream = buildUpstreamHeaders(clientHeaders, "configured-key", authHeader); + for (const name of ["authorization", "api-key", "x-api-key"]) { + expect(upstream.get(name)).toBe(new Headers(clientHeaders).get(name)); + } + } + + for (const clientHeaders of [ + { "Proxy-Authorization": "Bearer hop-token" }, + { Authorization: "Bearer hop-token", Connection: "aUtHoRiZaTiOn" }, + { "X-Api-Key": "hop-key", Connection: "X-Api-Key" }, + ] as Record[]) { + const upstream = buildUpstreamHeaders(clientHeaders, "configured-key", authHeader); + expect(upstream.get(authHeader)).toBe(fallback); + expect(upstream.has("proxy-authorization")).toBe(false); + expect(upstream.has("connection")).toBe(false); + } + }); +}); diff --git a/src/routes/openai-responses.ts b/src/routes/openai-responses.ts index 3dec403..ed964fe 100644 --- a/src/routes/openai-responses.ts +++ b/src/routes/openai-responses.ts @@ -21,6 +21,7 @@ import { } from "../privacy/pipeline"; import { createResponsesUnmaskingStream } from "../protocols/responses/stream-transformer"; import { ProviderError } from "../providers/errors"; +import { buildUpstreamHeaders } from "../providers/forwarded-headers"; import type { SecretsProcessResult } from "../secrets/request"; import { createLogData, @@ -54,28 +55,6 @@ const OpenAIResponsesRequestSchema = z } }); -const FORWARDED_HEADERS = new Set([ - "accept", - "anthropic-beta", - "api-key", - "authorization", - "http-referer", - "idempotency-key", - "openai-beta", - "openai-organization", - "openai-project", - "traceparent", - "tracestate", - "user-agent", - "x-anthropic-beta", - "x-api-key", - "x-client-request-id", - "x-request-id", - "x-title", -]); - -const FORWARDED_HEADER_PREFIXES = ["x-openai-", "x-openrouter-", "x-stainless-"]; - export const openaiResponsesRoutes = new Hono(); function registerResponsesRoute(path: "/responses" | "/responses/") { @@ -250,7 +229,7 @@ async function callOpenAIResponses( const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0]; const response = await fetch(`${provider.base_url.replace(/\/$/, "")}/responses${query}`, { method: "POST", - headers: buildUpstreamHeaders(clientHeaders, provider), + headers: buildUpstreamHeaders(clientHeaders, provider.api_key), body: JSON.stringify(request), signal, }); @@ -261,33 +240,6 @@ async function callOpenAIResponses( return response; } -function buildUpstreamHeaders( - clientHeaders: Record, - provider: OpenAIProviderConfig, -): Record { - const headers: Record = { "Content-Type": "application/json" }; - let hasClientAuth = false; - - for (const [name, value] of Object.entries(clientHeaders)) { - const lower = name.toLowerCase(); - if ( - !FORWARDED_HEADERS.has(lower) && - !FORWARDED_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix)) - ) { - continue; - } - headers[name] = value; - if (lower === "authorization" || lower === "api-key" || lower === "x-api-key") { - hasClientAuth = true; - } - } - - if (!hasClientAuth && provider.api_key) { - headers.Authorization = `Bearer ${provider.api_key}`; - } - return headers; -} - function remaskKnownValues( request: ResponsesRequest, ...contexts: Array diff --git a/src/routes/openai.test.ts b/src/routes/openai.test.ts index 433a5f0..c4004bf 100644 --- a/src/routes/openai.test.ts +++ b/src/routes/openai.test.ts @@ -39,6 +39,7 @@ app.route("/openai", openaiRoutes); const originalFetch = globalThis.fetch; const config = getConfig(); const originalMode = config.mode; +const originalAPIKey = config.providers.openai.api_key; const originalLocal = config.local ? { ...config.local } : undefined; const originalSecretsDetection = { enabled: config.secrets_detection.enabled, @@ -50,6 +51,7 @@ const originalSecretsDetection = { afterEach(() => { globalThis.fetch = originalFetch; config.mode = originalMode; + config.providers.openai.api_key = originalAPIKey; if (originalLocal) config.local = { ...originalLocal }; else delete config.local; config.secrets_detection.enabled = originalSecretsDetection.enabled; @@ -62,6 +64,85 @@ afterEach(() => { }); describe("POST /openai/v1/chat/completions", () => { + test.each([ + "mask", + "route", + ] as const)("forwards client headers except internal and transport headers in %s mode", async (mode) => { + config.mode = mode; + config.providers.openai.api_key = "configured-key"; + const forwardedHeaders = { + Accept: "application/json", + "OpenAI-Organization": "org-test", + "OpenAI-Project": "proj-test", + "HTTP-Referer": "https://client.example", + "X-Opencode-Session": "session-123", + "X-OpenRouter-Title": "Test client", + "X-Stainless-Runtime": "bun", + "X-New-Provider-Feature": "future-value", + "X-OpenWebUI-Feature": "enabled", + "X-Forwarded-For": "192.0.2.1", + Cookie: "provider-session=value", + }; + const rejectedHeaders = { + Host: "proxy.example", + Connection: "keep-alive, X-Hop-Token", + "Keep-Alive": "timeout=5", + "Content-Encoding": "gzip", + "Content-Length": "999", + "Proxy-Authenticate": "Basic realm=proxy", + "Proxy-Authorization": "Bearer proxy-token", + TE: "trailers", + Trailer: "X-Trailer", + "Transfer-Encoding": "chunked", + Upgrade: "websocket", + "X-Hop-Token": "connection-only", + "X-PasteGuard-Source": "client", + "X-PasteGuard-Future": "internal", + "X-OpenWebUI-User-Email": "identity@example.com", + "X-OpenWebUI-User-Name": "Synthetic User", + "X-OpenWebUI-User-Id": "user-123", + "X-OpenWebUI-Chat-Id": "chat-123", + "X-OpenWebUI-User-Future": "private", + }; + let upstream: Request | undefined; + globalThis.fetch = (async (target: string | URL | Request, init?: RequestInit) => { + upstream = target instanceof Request ? target : new Request(target, init); + return Response.json({ choices: [], output: [] }); + }) as typeof fetch; + + for (const endpoint of ["chat/completions", "responses"]) { + for (const authorization of [undefined, "Bearer client-token"]) { + const response = await app.request(`/openai/v1/${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + ...(authorization ? { Authorization: authorization } : {}), + ...forwardedHeaders, + ...rejectedHeaders, + }, + body: JSON.stringify({ + model: "gpt-test", + messages: [{ role: "user", content: "Reply ok" }], + input: "Reply ok", + }), + }); + + expect(response.status).toBe(200); + expect(upstream?.url).toBe(`${config.providers.openai.base_url}/${endpoint}`); + expect(upstream?.headers.get("authorization")).toBe( + authorization ?? "Bearer configured-key", + ); + expect(upstream?.headers.get("content-type")).toBe("application/json"); + for (const [name, value] of Object.entries(forwardedHeaders)) { + expect(upstream?.headers.get(name)).toBe(value); + } + for (const name of Object.keys(rejectedHeaders)) { + expect(upstream?.headers.get(name)).toBeNull(); + } + } + } + }); + test("returns 400 for missing messages", async () => { const res = await app.request("/openai/v1/chat/completions", { method: "POST", @@ -333,6 +414,7 @@ describe("POST /openai/v1/chat/completions", () => { type: "openai", base_url: "http://local.test/v1", model: "local-model", + api_key: "local-provider-token", }; config.secrets_detection.enabled = true; config.secrets_detection.action = "mask"; @@ -368,9 +450,11 @@ describe("POST /openai/v1/chat/completions", () => { let upstreamUrl: string | undefined; let upstreamBody: Record | undefined; + let upstreamHeaders = new Headers(); globalThis.fetch = (async (target: string | URL | Request, init?: RequestInit) => { upstreamUrl = target instanceof Request ? target.url : String(target); upstreamBody = JSON.parse(String(init?.body)); + upstreamHeaders = new Headers(init?.headers); return Response.json({ id: "chatcmpl_local", object: "chat.completion", @@ -400,7 +484,13 @@ describe("POST /openai/v1/chat/completions", () => { try { response = await app.request("/openai/v1/chat/completions", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Authorization: "Bearer cloud-token", + "X-Api-Key": "cloud-api-key", + Cookie: "cloud-session=value", + "X-New-Provider-Feature": "cloud-only", + }, body: JSON.stringify({ model: "gpt-test", messages: [ @@ -430,6 +520,10 @@ describe("POST /openai/v1/chat/completions", () => { expect(response.status).toBe(200); expect(response.headers.get("X-PasteGuard-Provider")).toBe("local"); expect(upstreamUrl).toBe("http://local.test/v1/chat/completions"); + expect(upstreamHeaders.get("authorization")).toBe("Bearer local-provider-token"); + expect(upstreamHeaders.get("x-api-key")).toBeNull(); + expect(upstreamHeaders.get("cookie")).toBeNull(); + expect(upstreamHeaders.get("x-new-provider-feature")).toBeNull(); const messages = upstreamBody?.messages as Array>; expect(messages[0].content).toBe(`Email ${email} Key [[API_KEY_SK_1]]`); expect( diff --git a/src/routes/openai.ts b/src/routes/openai.ts index 9017e35..3205d7e 100644 --- a/src/routes/openai.ts +++ b/src/routes/openai.ts @@ -14,6 +14,7 @@ import { type PrivacyPipelineResult, processPrivacyPipeline, } from "../privacy/pipeline"; +import { forwardClientHeaders } from "../providers/forwarded-headers"; import { callLocal } from "../providers/local"; import { callOpenAI, getOpenAIInfo, type ProviderResult } from "../providers/openai/client"; import { createUnmaskingStream } from "../providers/openai/stream-transformer"; @@ -106,7 +107,7 @@ openaiRoutes.post( piiMaskingContext: privacy.piiMaskingContext, secretsResult, startTime, - authHeader: c.req.header("Authorization"), + clientHeaders: c.req.header(), }); }, ); @@ -130,14 +131,14 @@ openaiRoutes.all("/*", (c) => { const { baseUrl } = getOpenAIInfo(config.providers.openai); const path = c.req.path.replace(/^\/openai\/v1/, ""); const query = c.req.url.includes("?") ? c.req.url.slice(c.req.url.indexOf("?")) : ""; + const headers = forwardClientHeaders(c.req.header()); + const host = c.req.header("host"); + headers.delete("X-Forwarded-Host"); + if (host) headers.set("X-Forwarded-Host", host); return proxy(`${baseUrl}${path}${query}`, { ...c.req, - headers: { - ...c.req.header(), - "X-Forwarded-Host": c.req.header("host"), - host: undefined, - }, + headers, }); }); @@ -187,7 +188,7 @@ interface OpenAIOptions { piiMaskingContext?: PlaceholderContext; secretsResult: SecretsProcessResult; startTime: number; - authHeader?: string; + clientHeaders?: Record; } interface LocalOptions { @@ -264,7 +265,7 @@ function respondDetectionError(c: Context, body: OpenAIRequest, startTime: numbe async function sendToOpenAI(c: Context, originalRequest: OpenAIRequest, opts: OpenAIOptions) { const config = getConfig(); - const { request, piiResult, piiMaskingContext, secretsResult, startTime, authHeader } = opts; + const { request, piiResult, piiMaskingContext, secretsResult, startTime, clientHeaders } = opts; const maskedContent = piiResult.hasPII || secretsResult.masked ? formatRequestForLog(request) : undefined; @@ -278,7 +279,7 @@ async function sendToOpenAI(c: Context, originalRequest: OpenAIRequest, opts: Op ); try { - const result = await callOpenAI(request, config.providers.openai, authHeader); + const result = await callOpenAI(request, config.providers.openai, clientHeaders); logRequest( createLogData({