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
1 change: 1 addition & 0 deletions .agents/skills/lean-ci/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ locally-runnable scripts; CI just calls them.
| npm script | What it does | When to use in CI |
|------------|-------------|-------------------|
| `npm run lint` | Lint all workspace packages | Quality gate |
| `npm run check:no-query-secrets` | Ban query-secret URL patterns in sources (DR-035) | Quality gate |
| `npm test` | Test all workspace packages | Quality gate |
| `npm run ci:build` | Full build with `--skip-proto` (SEA + VSIX + tar.gz) | Build artifacts |
| `npm run ci:package-python` | Build Python wheel via `uvx hatch build` | Python artifact |
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
- name: Lint
run: npm run lint

- name: Check no query-string secrets
run: npm run check:no-query-secrets

- name: Disable GPU acceleration
run: |
mkdir -p ~/.vscode
Expand Down
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,8 @@ internal MCP tool for cross-session retrieval.
3. Frontend makes API calls to Express routes:
- GET /api/providers → state.listProviders()
- GET /api/models → state.listModels()
- GET|POST /api/discover-models/:engineId → state.discoverModels()
(provider API key via `X-Api-Key` / JSON body only — never `?apiKey=`; DR-035)
- GET /api/config → loadConfig()
- POST /api/config → saveConfig()
- POST /api/secrets → state.secretStore.set()
Expand Down
20 changes: 20 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,26 @@ Call APIs with:
curl -H "Authorization: Bearer $ABBENAY_API_TOKEN" http://127.0.0.1:8787/api/health
```

Provider API keys for model discovery must never appear in query strings
(DR-035). Use `X-Api-Key` or a JSON body (daemon Bearer auth stays on
`Authorization`):

```bash
# Header
curl -H "Authorization: Bearer $ABBENAY_API_TOKEN" \
-H "X-Api-Key: $GOOGLE_API_KEY" \
http://127.0.0.1:8787/api/discover-models/gemini

# Body
curl -X POST -H "Authorization: Bearer $ABBENAY_API_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"apiKey\":\"$GOOGLE_API_KEY\"}" \
http://127.0.0.1:8787/api/discover-models/gemini
```

`GET/POST ...?apiKey=` is rejected (HTTP 400). Outbound Gemini calls use the
`x-goog-api-key` header, not `?key=` in the URL.

The dashboard uses SameSite=Strict cookies plus a CSRF token for browser
session auth. Open `http://127.0.0.1:8787/login` (or `POST /login` with the
token in the body) to establish a session — prefer that over putting the
Expand Down
1 change: 1 addition & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ The build handles macOS-specific SEA requirements automatically: it passes `--ma
| `build:dev` | `node build.js --skip-zip --code-install` | Build + install VSIX, no zip |
| `build:proto` | `node build.js --proto-only` | Regenerate proto stubs only |
| `lint` | `npm run lint --workspaces --if-present` | Lint all packages |
| `check:no-query-secrets` | `npm run check:no-query-secrets` | Ban query-string API key patterns in sources (DR-035) |
| `test` | `npm run test --workspaces --if-present` | Test all packages |
| `ci:build` | `node build.js --skip-proto` | Full build, skip proto (stubs committed) |
| `ci:package-python` | `cd packages/python && uvx hatch build` | Build Python wheel |
Expand Down
2 changes: 2 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ Tests that start real servers, make real HTTP/gRPC calls.
| `tests/integration/openai-compat.test.ts` | OpenAI-compatible API: /v1/models, streaming, non-streaming, errors, tool calls |
| `tests/integration/sessions.test.ts` | Session REST API: CRUD endpoints, session chat SSE streaming + persistence |
| `tests/integration/mcp-http-policy.test.ts` | `/mcp` auth + connection consent + tool_policy E2E |
| `tests/integration/http-security.test.ts` | HTTP auth, CORS, bind defaults, dashboard login |
| `tests/integration/discover-models-auth.test.ts` | discover-models: reject `?apiKey=`; accept `X-Api-Key` / body |

### Mock Engine

Expand Down
17 changes: 17 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,3 +539,20 @@ consent closes the remaining C3 recommendation and stops token-bearing callers
from silently opening an MCP session. API-token holders can both call `/mcp`
and approve via `/api/mcp/connections`, so consent is interactive friction, not
a second principal against a stolen/shared token.

---

## DR-035: No API keys in URL query strings

**Date:** 2026-07-17
**Decision:** Never place provider API keys in request URLs. Gemini outbound
calls use the `x-goog-api-key` header (same as `@ai-sdk/google`). The
`/api/discover-models/:engineId` endpoint accepts provider keys only via the
`X-Api-Key` header or JSON body (`POST`); `?apiKey=` is rejected with HTTP 400.
Non-secret params (`baseUrl`, `providerId`) may remain on the query string for
GET. CI runs `npm run check:no-query-secrets` to block regressions of common
query-secret patterns (`?key=`, `query.apiKey` / `query['apiKey']`,
`params.set/append('apiKey'`, `searchParams.set('key'`) in production sources.
**Rationale:** Query-string secrets leak into access logs, reverse proxies,
browser history, and `Referer` headers. Header/body transport matches other
engines (Bearer / `x-api-key`) and closes findings H2/H3.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"lint": "npm run lint --workspaces --if-present",
"test": "npm run test --workspaces --if-present",
"audit:check": "node scripts/audit-check.js",
"check:no-query-secrets": "node scripts/check-no-query-secrets.js",
"ci:build": "node build.js --skip-proto",
"ci:package-python": "cd packages/python && uvx hatch build",
"ci:publish-vscode": "node scripts/publish-vscode.js"
Expand Down
47 changes: 45 additions & 2 deletions packages/daemon/src/core/engines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
* JSON→SSE conversion).
*/

import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { sanitizeVertexRequestBody, convertAnthropicJsonToSse } from './engines.js';
import { sanitizeVertexRequestBody, convertAnthropicJsonToSse, fetchModels } from './engines.js';

const ENGINES_SRC = fs.readFileSync(
path.join(__dirname, 'engines.ts'),
Expand Down Expand Up @@ -221,3 +221,46 @@ describe('convertAnthropicJsonToSse', () => {
expect(deltaData.delta.stop_reason).toBe('end_turn');
});
});

describe('Gemini model discovery auth (no query-string keys)', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('should send API key via x-goog-api-key and never put key material in the URL', async () => {
const apiKey = 'test-gemini-secret-key-xyz';
let capturedUrl = '';
let capturedHeaders: HeadersInit | undefined;

vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
capturedUrl = String(input);
capturedHeaders = init?.headers;
return new Response(JSON.stringify({
models: [{
name: 'models/gemini-2.0-flash',
supportedGenerationMethods: ['generateContent'],
inputTokenLimit: 1048576,
}],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}));

const models = await fetchModels('gemini', apiKey);
expect(models).toHaveLength(1);
expect(models[0].id).toBe('gemini-2.0-flash');

expect(capturedUrl).toContain('/v1beta/models');
expect(capturedUrl).not.toContain('key=');
expect(capturedUrl).not.toContain(apiKey);

const headers = new Headers(capturedHeaders);
expect(headers.get('x-goog-api-key')).toBe(apiKey);
});

it('should not construct Gemini URLs with query-string API keys', () => {
// Ban executable patterns; comments documenting the ban may mention key=.
expect(ENGINES_SRC).not.toMatch(/`\$\{[^`]*\}\?key=\$\{/);
expect(ENGINES_SRC).not.toMatch(/['"`]\?key=\$\{/);
expect(ENGINES_SRC).toMatch(/x-goog-api-key/);
});
});
12 changes: 10 additions & 2 deletions packages/daemon/src/core/engines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,9 +805,17 @@ async function fetchGeminiModels(
baseUrl?: string,
): Promise<DiscoveredModel[]> {
const base = baseUrl || 'https://generativelanguage.googleapis.com';
const url = `${base}/v1beta/models${apiKey ? `?key=${apiKey}` : ''}`;
// Never put API keys in the URL (?key= leaks via logs, proxies, history, Referer).
// Match @ai-sdk/google: send the key via x-goog-api-key header.
const url = `${base}/v1beta/models`;
const headers: Record<string, string> = {
Accept: 'application/json',
};
if (apiKey) {
headers['x-goog-api-key'] = apiKey;
}

const resp = await fetch(url, { signal: AbortSignal.timeout(15000) });
const resp = await fetch(url, { headers, signal: AbortSignal.timeout(15000) });
if (!resp.ok) {
throw new Error(`Gemini HTTP ${resp.status}`);
}
Expand Down
62 changes: 48 additions & 14 deletions packages/daemon/src/daemon/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,27 +523,58 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions):
});

/**
* GET /api/discover-models/:providerId - Discover all models a provider offers
* Ignores config — for browsing/selection UI. Does NOT trigger notifications.
*/
/**
* GET /api/discover-models/:engineId - Discover all models an engine offers
* Operates on the actual layer (engine, not virtual provider).
* Query params: ?apiKey=xxx&baseUrl=xxx (optional, for authenticated discovery)
* Discover models for an engine (actual layer). Does NOT trigger notifications.
*
* API keys MUST NOT be passed as query params (leaks via logs, proxies, history,
* Referer). Accept provider keys only via:
* - Header: X-Api-Key
* - JSON body: { apiKey } (POST)
* Non-secret params (baseUrl, providerId) may be query (GET) or body (POST).
* Query ?apiKey= is rejected with 400.
*/
app.get('/api/discover-models/:engineId', async (req, res) => {
const handleDiscoverModels = async (req: Request, res: Response): Promise<void> => {
try {
let apiKey = req.query.apiKey as string | undefined;
let baseUrl = req.query.baseUrl as string | undefined;
const providerId = req.query.providerId as string | undefined;

// Reject query-string secrets without naming req.query.apiKey (CI ban pattern).
if (Object.prototype.hasOwnProperty.call(req.query, 'apiKey')) {
res.status(400).json({
error:
'apiKey must not be passed as a query parameter. ' +
'Send it via the X-Api-Key header or JSON body (POST).',
});
return;
}

const body = (req.body && typeof req.body === 'object' ? req.body : {}) as {
apiKey?: unknown;
baseUrl?: unknown;
providerId?: unknown;
};

const headerKey = req.headers['x-api-key'];
const headerKeyStr = Array.isArray(headerKey) ? headerKey[0] : headerKey;
let apiKey: string | undefined;
if (typeof headerKeyStr === 'string' && headerKeyStr.length > 0) {
apiKey = headerKeyStr;
} else if (typeof body.apiKey === 'string' && body.apiKey.length > 0) {
apiKey = body.apiKey;
}

let baseUrl =
(typeof body.baseUrl === 'string' && body.baseUrl) ||
(typeof req.query.baseUrl === 'string' ? req.query.baseUrl : undefined) ||
undefined;
const providerId =
(typeof body.providerId === 'string' && body.providerId) ||
(typeof req.query.providerId === 'string' ? req.query.providerId : undefined) ||
undefined;

// If providerId is given (edit mode), resolve API key and base URL from config
if (providerId && !apiKey) {
const resolved = await state.resolveProviderCredentials(providerId);
if (resolved.apiKey) apiKey = resolved.apiKey;
if (resolved.baseUrl && !baseUrl) baseUrl = resolved.baseUrl;
}

const models = await state.discoverModels(req.params.engineId, apiKey, baseUrl);
res.json({
models: models.map((m) => ({
Expand All @@ -561,7 +592,10 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions):
console.error('[Web] /api/discover-models error:', msg);
res.status(500).json({ error: msg });
}
});
};

app.get('/api/discover-models/:engineId', handleDiscoverModels);
app.post('/api/discover-models/:engineId', handleDiscoverModels);

/**
* GET /api/models - List models from configured providers
Expand Down
27 changes: 16 additions & 11 deletions packages/daemon/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1723,10 +1723,11 @@ <h3>Save config to</h3>
this.wizardFromModelsTab = true;
// Discover models so the dual-list has data if user clicks "Back to models"
try {
const params = new URLSearchParams();
params.set('providerId', model.provider);
const url = `/api/discover-models/${encodeURIComponent(model.engine)}?${params}`;
const resp = await apiFetch(url);
const resp = await apiFetch(`/api/discover-models/${encodeURIComponent(model.engine)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ providerId: model.provider }),
});
const data = await resp.json();
this.wizardDiscoveredModels = data.models || [];
} catch (e) { console.error('Discovery failed:', e); }
Expand All @@ -1746,17 +1747,21 @@ <h3>Save config to</h3>
this.wizardStep = 2;
} else if (this.wizardStep === 2) {
if (!this.wizardName) return;
// Discover models for Step 3
// Discover models for Step 3 — API keys go in header/body only (never query)
try {
const params = new URLSearchParams();
if (this.wizardApiKey) params.set('apiKey', this.wizardApiKey);
if (this.wizardBaseUrl) params.set('baseUrl', this.wizardBaseUrl);
const body = {};
if (this.wizardBaseUrl) body.baseUrl = this.wizardBaseUrl;
// In edit mode without a new key, tell backend to resolve from config
if (this.wizardEditMode && !this.wizardApiKey) {
params.set('providerId', this.wizardEditId);
body.providerId = this.wizardEditId;
}
const url = `/api/discover-models/${encodeURIComponent(this.wizardEngine)}?${params}`;
const resp = await apiFetch(url);
const headers = { 'Content-Type': 'application/json' };
if (this.wizardApiKey) headers['X-Api-Key'] = this.wizardApiKey;
const resp = await apiFetch(`/api/discover-models/${encodeURIComponent(this.wizardEngine)}`, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
const data = await resp.json();
this.wizardDiscoveredModels = data.models || [];
} catch (e) { console.error('Discovery failed:', e); }
Expand Down
Loading
Loading