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
13 changes: 13 additions & 0 deletions app/src/app/api/settings/assistant/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { githubOAuthConfigured } from "@/lib/githubOAuth";
import { deleteLocalProvider, saveLocalProvider, setAssistantSettings, applyLocalProvider, viewAssistantSettings, ANONYMOUS_USER_ID } from "@/lib/settings";
import { verifyAnthropicApiKey } from "@/lib/anthropicKeyCheck";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -46,6 +47,18 @@ export async function POST(req: NextRequest) {
if (typeof body.anthropicApiKey === "string" || body.anthropicApiKey === null) {
patch.anthropicApiKey = body.anthropicApiKey;
}
// Verify a newly pasted Anthropic key against Anthropic's own API before
// ever persisting it -- previously any string was accepted silently and
// only failed deep inside a real chat turn ("Invalid API key"), with no
// way to tell a CodeGraph bug from a bad paste. A confirmed-invalid key
// (401/403) is rejected here with Anthropic's own error text; a network
// failure verifying it is NOT evidence the key is bad, so it still saves.
if (typeof patch.anthropicApiKey === "string" && patch.anthropicApiKey.trim()) {
const check = await verifyAnthropicApiKey(patch.anthropicApiKey.trim());
if (!check.ok && check.reason === "invalid") {
return NextResponse.json({ error: `Anthropic rejected this API key: ${check.message}` }, { status: 400 });
}
}
if (typeof body.claudeModel === "string" || body.claudeModel === null) {
patch.claudeModel = body.claudeModel;
}
Expand Down
48 changes: 48 additions & 0 deletions app/src/lib/anthropicKeyCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Verifies an Anthropic API key actually works before CodeGraph ever
// persists it, closing the gap that let "Invalid API key" surface deep
// inside a chat turn instead of immediately at save time: the Settings API
// previously accepted any string with zero validation (a truncated paste,
// a revoked key, or another provider's key entirely all "saved
// successfully" and only failed once a real chat session hit Anthropic).
//
// Uses GET /v1/models — the same base URL and `anthropic-version` header
// the official `@anthropic-ai/sdk` client itself sends (see
// `node_modules/@anthropic-ai/sdk/client.js`) — a free, side-effect-free
// metadata endpoint, so verifying costs nothing and can't consume a
// message/token budget.
const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
const ANTHROPIC_VERSION = "2023-06-01";

export type AnthropicKeyCheckResult =
| { ok: true }
| { ok: false; reason: "invalid" | "network"; message: string };

/** Calls Anthropic's own API with `key` and reports whether Anthropic itself
* accepts it. Distinguishes a confirmed-bad key (401/403 — reject the
* save) from a transient network/outage failure (don't block a save on
* our own connectivity issue; the key may well be fine). */
export async function verifyAnthropicApiKey(key: string, signal?: AbortSignal): Promise<AnthropicKeyCheckResult> {
try {
const res = await fetch(`${ANTHROPIC_BASE_URL}/v1/models?limit=1`, {
method: "GET",
headers: { "x-api-key": key, "anthropic-version": ANTHROPIC_VERSION },
signal,
});
if (res.ok) return { ok: true };
if (res.status === 401 || res.status === 403) {
const body = await res.json().catch(() => null) as { error?: { message?: string } } | null;
return {
ok: false,
reason: "invalid",
message: body?.error?.message || "Anthropic rejected this API key (invalid or revoked).",
};
}
// Any other status (429 rate-limited, 5xx) isn't evidence the KEY is
// wrong -- don't block the save on it.
return { ok: true };
} catch {
// Network failure reaching Anthropic (offline, DNS, timeout, egress
// blocked) -- not evidence the key itself is bad, so don't block the save.
return { ok: false, reason: "network", message: "Could not reach Anthropic to verify the key (network error) — saved anyway." };
}
}
130 changes: 130 additions & 0 deletions app/tests/anthropicKeyCheck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Regression tests for verifyAnthropicApiKey (closes the "Invalid API key"
// gap: previously any string was accepted and persisted with zero
// validation, only failing deep inside a real chat turn against Anthropic).
// Also proves the /api/settings/assistant route actually rejects a
// confirmed-bad key BEFORE ever writing it to the settings table.
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { NextRequest } from "next/server";

const dataDir = mkdtempSync(path.join(tmpdir(), "cg-keycheck-"));
process.env.CG_DATA_DIR = dataDir;
process.env.CG_SESSION_SECRET = process.env.CG_SESSION_SECRET || "test-secret-for-keycheck";

import { verifyAnthropicApiKey } from "@/lib/anthropicKeyCheck";
import { getAssistantSettings } from "@/lib/settings";
import { POST as settingsPost } from "@/app/api/settings/assistant/route";

afterAll(() => {
rmSync(dataDir, { recursive: true, force: true });
});

describe("verifyAnthropicApiKey", () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});

it("reports ok:true when Anthropic accepts the key (200)", async () => {
global.fetch = vi.fn(async (url: string, init?: RequestInit) => {
expect(url).toBe("https://api.anthropic.com/v1/models?limit=1");
expect((init?.headers as Record<string, string>)["x-api-key"]).toBe("sk-ant-real-key");
expect((init?.headers as Record<string, string>)["anthropic-version"]).toBe("2023-06-01");
return new Response(JSON.stringify({ data: [] }), { status: 200 });
}) as unknown as typeof fetch;

const result = await verifyAnthropicApiKey("sk-ant-real-key");
expect(result.ok).toBe(true);
});

it("reports ok:false reason:invalid when Anthropic returns 401 (this IS the 'Invalid API key' case)", async () => {
global.fetch = vi.fn(async () =>
new Response(JSON.stringify({ error: { message: "invalid x-api-key" } }), { status: 401 })
) as unknown as typeof fetch;

const result = await verifyAnthropicApiKey("sk-ant-totally-wrong");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("invalid");
expect(result.message).toBe("invalid x-api-key");
}
});

it("reports ok:false reason:invalid on 403 too", async () => {
global.fetch = vi.fn(async () => new Response(JSON.stringify({}), { status: 403 })) as unknown as typeof fetch;
const result = await verifyAnthropicApiKey("sk-ant-forbidden");
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("invalid");
});

it("does NOT treat a rate-limit (429) or server error (500) as an invalid key", async () => {
global.fetch = vi.fn(async () => new Response("", { status: 429 })) as unknown as typeof fetch;
expect((await verifyAnthropicApiKey("sk-ant-x")).ok).toBe(true);

global.fetch = vi.fn(async () => new Response("", { status: 500 })) as unknown as typeof fetch;
expect((await verifyAnthropicApiKey("sk-ant-x")).ok).toBe(true);
});

it("does NOT block on a network failure (not evidence the key itself is bad)", async () => {
global.fetch = vi.fn(async () => { throw new Error("fetch failed: ENOTFOUND"); }) as unknown as typeof fetch;
const result = await verifyAnthropicApiKey("sk-ant-x");
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("network");
});
});

describe("POST /api/settings/assistant rejects a confirmed-invalid key before persisting it", () => {
const originalFetch = global.fetch;
const USER_ID = 9001;
afterEach(() => {
global.fetch = originalFetch;
});

function postRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/settings/assistant", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}

beforeEach(() => {
// GitHub OAuth isn't configured in this test env, so unauthorized() is a
// no-op and every request lands in the ANONYMOUS_USER_ID bucket via
// userIdFrom(req) -- fine, this suite tests the verification gate, not
// the account-scoping gate (already covered by settings.test.ts).
});

it("rejects a key Anthropic returns 401 for, and never writes it to the DB", async () => {
global.fetch = vi.fn(async () =>
new Response(JSON.stringify({ error: { message: "invalid x-api-key" } }), { status: 401 })
) as unknown as typeof fetch;

const res = await settingsPost(postRequest({ anthropicApiKey: "sk-ant-BAD-KEY" }));
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("invalid x-api-key");

// The anonymous bucket must not have picked up the rejected key.
expect(getAssistantSettings(0).anthropicApiKey).toBeNull();
});

it("accepts and persists a key Anthropic returns 200 for", async () => {
global.fetch = vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200 })) as unknown as typeof fetch;

const res = await settingsPost(postRequest({ anthropicApiKey: "sk-ant-GOOD-KEY" }));
expect(res.status).toBe(200);
expect(getAssistantSettings(0).anthropicApiKey).toBe("sk-ant-GOOD-KEY");
});

it("still allows clearing a key (null) without calling Anthropic at all", async () => {
const fetchSpy = vi.fn();
global.fetch = fetchSpy as unknown as typeof fetch;

const res = await settingsPost(postRequest({ anthropicApiKey: null }));
expect(res.status).toBe(200);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
Loading