From d4dadec1cb7ceded1ae724f2dafc9a66a73ec155 Mon Sep 17 00:00:00 2001 From: ghostface Date: Sat, 14 Feb 2026 13:41:00 -0500 Subject: [PATCH 1/3] fix: accept x-goog-api-key header for bot token extraction in proxy The Google GenAI SDK (@google/genai) sends API keys via the x-goog-api-key header. The proxy already supports this header on the outgoing side (VENDOR_CONFIGS.google.authHeader), but the incoming bot token extraction only checked Authorization: Bearer and x-api-key. This causes all Google/Gemini requests through the proxy to fail with 401 "Missing authorization" since the bot's proxy token is sent via x-goog-api-key but never extracted. Co-Authored-By: Claude Opus 4.6 --- proxy/src/routes/proxy.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/proxy/src/routes/proxy.ts b/proxy/src/routes/proxy.ts index bd4d93c..ad3f7d0 100644 --- a/proxy/src/routes/proxy.ts +++ b/proxy/src/routes/proxy.ts @@ -22,8 +22,10 @@ export function registerProxyRoutes( } const vendorConfig = VENDOR_CONFIGS[vendor]; - // Extract bot token from either Authorization header or x-api-key - // This supports both OpenAI-style (Bearer token) and Anthropic-style (x-api-key) auth + // Extract bot token from auth header — each vendor SDK sends it differently + // OpenAI-style: Authorization: Bearer + // Anthropic-style: x-api-key: + // Google-style: x-goog-api-key: let botToken: string | undefined; const auth = req.headers.authorization; @@ -31,6 +33,8 @@ export function registerProxyRoutes( botToken = auth.slice(7); } else if (req.headers['x-api-key'] && typeof req.headers['x-api-key'] === 'string') { botToken = req.headers['x-api-key']; + } else if (req.headers['x-goog-api-key'] && typeof req.headers['x-goog-api-key'] === 'string') { + botToken = req.headers['x-goog-api-key']; } if (!botToken) { From 25d1b2d2a7d215fc12e695e86285aa460aa143bc Mon Sep 17 00:00:00 2001 From: ghostface Date: Sat, 14 Feb 2026 13:53:17 -0500 Subject: [PATCH 2/3] fix: preserve query parameters when forwarding to upstream Fastify strips query parameters from wildcard route params, so ?alt=sse (required by Google's streaming API) was silently dropped. This caused Google to return JSON instead of SSE, which the GenAI SDK's stream parser couldn't handle ("Incomplete JSON segment"). Co-Authored-By: Claude Opus 4.6 --- proxy/src/routes/proxy.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/proxy/src/routes/proxy.ts b/proxy/src/routes/proxy.ts index ad3f7d0..4ee3826 100644 --- a/proxy/src/routes/proxy.ts +++ b/proxy/src/routes/proxy.ts @@ -13,7 +13,10 @@ export function registerProxyRoutes( // Catch-all route for proxy requests: /v1/{vendor}/{path...} app.all('/v1/:vendor/*', async (req: FastifyRequest, reply: FastifyReply) => { const { vendor } = req.params as { vendor: string; '*': string }; - const path = '/' + (req.params as { '*': string })['*']; + const rawPath = '/' + (req.params as { '*': string })['*']; + // Preserve query string — Fastify strips it from wildcard params + const queryString = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : ''; + const path = rawPath + queryString; // Validate vendor if (!(vendor in VENDOR_CONFIGS)) { From eb325659743eb7a6d6df30f6d7d80d6ae089ca5c Mon Sep 17 00:00:00 2001 From: ghostface Date: Sat, 14 Feb 2026 15:09:03 -0500 Subject: [PATCH 3/3] test: add proxy route tests for auth headers and query param forwarding Tests cover: - Bot token extraction from all three auth headers (Bearer, x-api-key, x-goog-api-key) - 401/403 for missing/invalid tokens - Query parameter preservation (?alt=sse, multiple params, no params) - Vendor validation (unknown vendor, missing API keys) Co-Authored-By: Claude Opus 4.6 --- proxy/src/routes/proxy.test.ts | 199 +++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 proxy/src/routes/proxy.test.ts diff --git a/proxy/src/routes/proxy.test.ts b/proxy/src/routes/proxy.test.ts new file mode 100644 index 0000000..cc6c5e1 --- /dev/null +++ b/proxy/src/routes/proxy.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { FastifyInstance } from 'fastify'; +import { mkdirSync, rmSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { randomUUID, randomBytes } from 'crypto'; +import { ProxyDatabase } from '../db/index.js'; +import { KeyringService } from '../services/keyring.js'; +import { registerProxyRoutes } from './proxy.js'; +import { hashToken, encrypt } from '../crypto/encryption.js'; + +// Mock upstream to avoid real HTTP calls +vi.mock('../services/upstream.js', () => ({ + forwardToUpstream: vi.fn(async (req, reply) => { + // Echo back the path so tests can verify query params are preserved + reply.status(200).send({ proxiedPath: req.path, vendor: req.vendorConfig.host }); + return 200; + }), +})); + +import { forwardToUpstream } from '../services/upstream.js'; + +interface ErrorResponse { + error: string; +} + +interface ProxiedResponse { + proxiedPath: string; + vendor: string; +} + +describe('Proxy Routes', () => { + let testDir: string; + let db: ProxyDatabase; + let app: FastifyInstance; + let masterKey: Buffer; + let keyring: KeyringService; + const botToken = 'test-bot-token-abc123'; + + beforeEach(async () => { + testDir = join(tmpdir(), `proxy-routes-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + + const srcSchemaPath = join(import.meta.dirname, '../db/schema.sql'); + const destSchemaPath = join(testDir, 'schema.sql'); + writeFileSync(destSchemaPath, readFileSync(srcSchemaPath, 'utf-8')); + + const dbPath = join(testDir, 'test.db'); + db = new ProxyDatabase(dbPath); + masterKey = randomBytes(32); + keyring = new KeyringService(db, masterKey); + + // Register a bot + db.addBot('bot-1', 'test-bot', hashToken(botToken)); + + // Register an API key for openai and google vendors + db.addKey(randomUUID(), 'openai', encrypt('sk-real-key', masterKey)); + db.addKey(randomUUID(), 'google', encrypt('google-real-key', masterKey)); + + app = Fastify(); + registerProxyRoutes(app, db, keyring); + await app.ready(); + + vi.mocked(forwardToUpstream).mockClear(); + }); + + afterEach(async () => { + await app.close(); + db.close(); + rmSync(testDir, { recursive: true, force: true }); + }); + + describe('Bot token extraction', () => { + it('should accept Authorization: Bearer header (OpenAI-style)', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/openai/chat/completions', + headers: { Authorization: `Bearer ${botToken}` }, + payload: { model: 'gpt-4' }, + }); + + expect(response.statusCode).toBe(200); + expect(forwardToUpstream).toHaveBeenCalled(); + }); + + it('should accept x-api-key header (Anthropic-style)', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/openai/chat/completions', + headers: { 'x-api-key': botToken }, + payload: { model: 'gpt-4' }, + }); + + expect(response.statusCode).toBe(200); + expect(forwardToUpstream).toHaveBeenCalled(); + }); + + it('should accept x-goog-api-key header (Google-style)', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/google/models/gemini-2.0-flash:generateContent', + headers: { 'x-goog-api-key': botToken }, + payload: { contents: [] }, + }); + + expect(response.statusCode).toBe(200); + expect(forwardToUpstream).toHaveBeenCalled(); + }); + + it('should return 401 when no auth header is provided', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/openai/chat/completions', + payload: { model: 'gpt-4' }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().error).toBe('Missing authorization'); + }); + + it('should return 403 for invalid bot token', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/openai/chat/completions', + headers: { Authorization: 'Bearer wrong-token' }, + payload: { model: 'gpt-4' }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().error).toBe('Invalid bot token'); + }); + }); + + describe('Query parameter forwarding', () => { + it('should preserve query parameters when forwarding to upstream', async () => { + await app.inject({ + method: 'POST', + url: '/v1/google/models/gemini-2.0-flash:streamGenerateContent?alt=sse', + headers: { 'x-goog-api-key': botToken }, + payload: { contents: [] }, + }); + + expect(forwardToUpstream).toHaveBeenCalled(); + const call = vi.mocked(forwardToUpstream).mock.calls[0][0]; + expect(call.path).toContain('?alt=sse'); + }); + + it('should preserve multiple query parameters', async () => { + await app.inject({ + method: 'POST', + url: '/v1/google/models/gemini-2.0-flash:streamGenerateContent?alt=sse&key=abc', + headers: { 'x-goog-api-key': botToken }, + payload: { contents: [] }, + }); + + const call = vi.mocked(forwardToUpstream).mock.calls[0][0]; + expect(call.path).toContain('?alt=sse&key=abc'); + }); + + it('should work without query parameters', async () => { + await app.inject({ + method: 'POST', + url: '/v1/openai/chat/completions', + headers: { Authorization: `Bearer ${botToken}` }, + payload: { model: 'gpt-4' }, + }); + + const call = vi.mocked(forwardToUpstream).mock.calls[0][0]; + expect(call.path).toBe('/chat/completions'); + }); + }); + + describe('Vendor validation', () => { + it('should return 400 for unknown vendor', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/unknown-vendor/chat/completions', + headers: { Authorization: `Bearer ${botToken}` }, + payload: {}, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toContain('Unknown vendor'); + }); + + it('should return 503 when no API keys available for vendor', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/anthropic/v1/messages', + headers: { 'x-api-key': botToken }, + payload: {}, + }); + + // No anthropic key was registered in beforeEach + expect(response.statusCode).toBe(503); + expect(response.json().error).toContain('No API keys available'); + }); + }); +});