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({