Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions containers/api-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const STRIPPED_HEADERS = new Set([
'authorization',
'proxy-authorization',
'x-api-key',
'x-goog-api-key',
'forwarded',
'via',
]);
Expand Down Expand Up @@ -167,6 +168,32 @@ function buildUpstreamPath(reqUrl, targetHost, basePath) {
return prefix + targetUrl.pathname + targetUrl.search;
}

/**
* Strip the `key` query parameter from a Gemini request URL.
*
* The @google/genai SDK (and older Gemini SDK versions) may append `?key=<value>`
* to every request URL in addition to setting the `x-goog-api-key` header.
* The proxy injects the real key via the header, so the placeholder `key=`
* value must be removed before forwarding to Google to prevent
* API_KEY_INVALID errors.
*
* @param {string} reqUrl - The incoming request URL (must start with exactly one '/')
* @returns {string} URL with the `key` query parameter removed
*/
function stripGeminiKeyParam(reqUrl) {
// Only operate on relative request paths that begin with exactly one slash.
// Returning other inputs unchanged lets proxyRequest's relative-URL check reject them.
// The guard prevents absolute URLs (e.g. 'http://evil.com/path?key=…') and
// protocol-relative URLs ('//host/path') from being normalized into a relative path.
if (typeof reqUrl !== 'string' || !reqUrl.startsWith('/') || reqUrl.startsWith('//')) {
return reqUrl;
}
const parsed = new URL(reqUrl, 'http://localhost');
parsed.searchParams.delete('key');
// Reconstruct relative path only — never emit the scheme/host from the dummy base.
return parsed.pathname + parsed.search;
}

// Optional base path prefixes for API targets (e.g. /serving-endpoints for Databricks)
const OPENAI_API_BASE_PATH = normalizeBasePath(process.env.OPENAI_API_BASE_PATH);
const ANTHROPIC_API_BASE_PATH = normalizeBasePath(process.env.ANTHROPIC_API_BASE_PATH);
Expand Down Expand Up @@ -485,7 +512,8 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath =
Object.assign(headers, injectHeaders);

// Log auth header injection for debugging credential-isolation issues
const injectedKey = injectHeaders['x-api-key'] || injectHeaders['authorization'];
// Use case-insensitive lookup since providers use mixed casing (e.g. 'Authorization' vs 'authorization')
const injectedKey = Object.entries(injectHeaders).find(([k]) => ['x-api-key', 'authorization', 'x-goog-api-key'].includes(k.toLowerCase()))?.[1];
if (injectedKey) {
const keyPreview = injectedKey.length > 8
? `${injectedKey.substring(0, 8)}...${injectedKey.substring(injectedKey.length - 4)}`
Expand Down Expand Up @@ -1016,12 +1044,18 @@ if (require.main === module) {
const contentLength = parseInt(req.headers['content-length'], 10) || 0;
if (checkRateLimit(req, res, 'gemini', contentLength)) return;

// Strip any ?key= query parameter — the @google/genai SDK may append it to the URL.
// The proxy injects the real key via x-goog-api-key header instead.
req.url = stripGeminiKeyParam(req.url);

proxyRequest(req, res, GEMINI_API_TARGET, {
'x-goog-api-key': GEMINI_API_KEY,
}, 'gemini', GEMINI_API_BASE_PATH);
});

geminiServer.on('upgrade', (req, socket, head) => {
// Strip any ?key= query parameter — the @google/genai SDK may append it to the URL.
req.url = stripGeminiKeyParam(req.url);
proxyWebSocket(req, socket, head, GEMINI_API_TARGET, {
'x-goog-api-key': GEMINI_API_KEY,
}, 'gemini', GEMINI_API_BASE_PATH);
Expand Down Expand Up @@ -1155,4 +1189,4 @@ if (require.main === module) {
}

// Export for testing
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute };
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam };
68 changes: 67 additions & 1 deletion containers/api-proxy/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
const http = require('http');
const tls = require('tls');
const { EventEmitter } = require('events');
const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute } = require('./server');
const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam } = require('./server');

describe('normalizeApiTarget', () => {
it('should strip https:// prefix', () => {
Expand Down Expand Up @@ -449,6 +449,72 @@ describe('buildUpstreamPath', () => {
});
});

describe('shouldStripHeader', () => {
it('should strip authorization header', () => {
expect(shouldStripHeader('authorization')).toBe(true);
expect(shouldStripHeader('Authorization')).toBe(true);
});

it('should strip x-api-key header', () => {
expect(shouldStripHeader('x-api-key')).toBe(true);
expect(shouldStripHeader('X-Api-Key')).toBe(true);
});

it('should strip x-goog-api-key header (Gemini placeholder must be stripped)', () => {
expect(shouldStripHeader('x-goog-api-key')).toBe(true);
expect(shouldStripHeader('X-Goog-Api-Key')).toBe(true);
});

it('should strip proxy-authorization header', () => {
expect(shouldStripHeader('proxy-authorization')).toBe(true);
});

it('should strip x-forwarded-* headers', () => {
expect(shouldStripHeader('x-forwarded-for')).toBe(true);
expect(shouldStripHeader('x-forwarded-host')).toBe(true);
});

it('should not strip content-type header', () => {
expect(shouldStripHeader('content-type')).toBe(false);
});

it('should not strip anthropic-version header', () => {
expect(shouldStripHeader('anthropic-version')).toBe(false);
});
});

describe('stripGeminiKeyParam', () => {
it('should remove the key= query parameter', () => {
expect(stripGeminiKeyParam('/v1/models/gemini-pro:generateContent?key=placeholder'))
.toBe('/v1/models/gemini-pro:generateContent');
});

it('should remove key= while preserving other query parameters', () => {
expect(stripGeminiKeyParam('/v1/models/gemini-pro:generateContent?key=placeholder&alt=json'))
.toBe('/v1/models/gemini-pro:generateContent?alt=json');
});

it('should return path unchanged when no key= parameter is present', () => {
expect(stripGeminiKeyParam('/v1/models/gemini-pro:generateContent'))
.toBe('/v1/models/gemini-pro:generateContent');
});

it('should return path unchanged when only unrelated query parameters exist', () => {
expect(stripGeminiKeyParam('/v1/models/gemini-pro:generateContent?alt=json&stream=true'))
.toBe('/v1/models/gemini-pro:generateContent?alt=json&stream=true');
});

it('should handle root path without key param', () => {
expect(stripGeminiKeyParam('/')).toBe('/');
});

it('should handle path with only key= param, leaving no trailing ?', () => {
// URL.search returns '' when no params remain after deletion
const result = stripGeminiKeyParam('/v1/generateContent?key=abc');
expect(result).toBe('/v1/generateContent');
});
});

// ── Helpers for proxyWebSocket tests ──────────────────────────────────────────

/** Create a minimal mock socket with write/destroy spies. */
Expand Down
Loading