From ded12f5558403d3e86412e5f34bb083ddc0344e0 Mon Sep 17 00:00:00 2001 From: Drakonkat Date: Wed, 19 Aug 2026 17:15:26 +0200 Subject: [PATCH] fix(providers): allow baseUrl override for google-antigravity with https enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto dev @ fbc6f26 (single squashed commit; the previous branch history is re-applied in full). - registry: allowBaseUrlOverride: true on the google-antigravity entry; default baseUrl unchanged. - security (CodeRabbit + maintainer review): new providerSecureTransportConfigError in src/lib/destination-policy.ts — OAuth registry entries with allowBaseUrlOverride require https; cleartext http is allowed only for loopback/localhost/private relays behind the existing allowPrivateNetwork opt-in, which keeps the motivating http://127.0.0.1:47821 proxy working. Enforced inside providerDestinationConfigError, so routing, config validation, and the outbound layer share the gate; the #2071 https-rewrite stack can never observe a cleartext public destination. - tests: antigravity-baseurl-override covers override precedence, https rejection on both paths, and the localhost relay escape hatch; parity opted-in list gains google-antigravity. --- src/lib/destination-policy.ts | 41 ++++++++ src/providers/registry.ts | 2 +- tests/antigravity-baseurl-override.test.ts | 117 +++++++++++++++++++++ tests/provider-registry-parity.test.ts | 2 +- 4 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 tests/antigravity-baseurl-override.test.ts diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index 75818af311..f146991bcc 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -130,6 +130,44 @@ function registryAllowsPrivateNetwork(name: string): boolean { return getProviderRegistryEntry(name)?.allowPrivateNetworkByDefault === true; } +/** + * OAuth registry entries that opt into `allowBaseUrlOverride` send bearer credentials to a + * user-configured endpoint (review findings, PR #2109 / PR #2110): a cleartext `http:` + * override would expose the OAuth token on the wire. `https:` is therefore required for + * every non-local destination. Loopback/localhost/private relays keep working over + * `http:` because they already sit behind the explicit `allowPrivateNetwork` opt-in + * enforced by {@link providerDestinationConfigError}. Keyed/local providers (Ollama, + * vLLM, LM Studio, LiteLLM, Moonshot, Qwen, Alibaba) are untouched: they are not + * `authKind: "oauth"`, so this check never fires for them. + */ +function registrySendsOAuthToOverriddenBaseUrl(name: string): boolean { + const entry = getProviderRegistryEntry(name); + return entry?.authKind === "oauth" && entry.allowBaseUrlOverride === true; +} + +export function providerSecureTransportConfigError( + name: string, + provider: Pick, +): string | null { + if (!registrySendsOAuthToOverriddenBaseUrl(name)) return null; + // An explicit allowPrivateNetwork opt-in is the documented "intentionally local/self-hosted" + // contract; a named local relay (http://relay.corp.internal) stays reachable through it. + if (providerAllowsPrivateNetwork(name, provider)) return null; + let parsed: URL; + try { + parsed = new URL(provider.baseUrl.trim()); + } catch { + return null; // invalid URLs are providerBaseUrlConfigError's concern + } + if (parsed.protocol !== "http:") return null; + const assessment = assessDestination(provider.baseUrl); + if (!assessment) return null; + if (assessment.kind === "localhost" || assessment.kind === "loopback" || assessment.kind === "private") { + return null; // local relays; the private-network gate below still applies + } + return "baseUrl must use https: this provider sends OAuth credentials to its endpoint, and http is allowed only for loopback/private relays"; +} + /** * Whether a provider may reach loopback/private addresses. * @@ -150,6 +188,8 @@ export function providerAllowsPrivateNetwork( } export function providerDestinationConfigError(name: string, provider: Pick): string | null { + const secureTransportError = providerSecureTransportConfigError(name, provider); + if (secureTransportError) return secureTransportError; const assessment = assessDestination(provider.baseUrl); if (!assessment) return null; if (assessment.kind === "public" || assessment.kind === "hostname") return null; @@ -331,3 +371,4 @@ export async function resolvePublicAddresses( export async function assertUrlResolvesPublic(url: string): Promise { await resolvePublicAddresses(url); } + diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 53922e75ad..02ac26e4a8 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1496,7 +1496,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API // evidence from ai.google.dev does not establish Vertex publisher availability. { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] }, - { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.7-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] }, + { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.7-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] }, { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, diff --git a/tests/antigravity-baseurl-override.test.ts b/tests/antigravity-baseurl-override.test.ts new file mode 100644 index 0000000000..d409cc7799 --- /dev/null +++ b/tests/antigravity-baseurl-override.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test"; +import { routeModel } from "../src/router"; +import { providerDestinationConfigError } from "../src/lib/destination-policy"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +/** + * Regression coverage for the allowBaseUrlOverride opt-in on the + * google-antigravity registry entry. + * + * Before the opt-in, the pinned registry endpoint silently outranked a saved + * baseUrl and the router emitted the discarded-baseUrl diagnostic (see + * tests/router-discarded-baseurl-warning.test.ts). Users routing Antigravity + * traffic through a local relay or region-specific proxy therefore could not + * redirect the provider at all. These tests pin the new contract: a resolved + * user baseUrl wins, no warning fires, and the registry endpoint remains the + * default when nothing is configured. + */ +const PROVIDER = "google-antigravity"; +const REGISTRY_BASE_URL = "https://daily-cloudcode-pa.googleapis.com"; +const MODEL = PROVIDER + "/gemini-3.7-flash"; + +function configFor(provider: OcxProviderConfig): OcxConfig { + return { + port: 10100, + defaultProvider: PROVIDER, + providers: { [PROVIDER]: provider }, + }; +} + +function routeCapturingWarnings(config: OcxConfig): { baseUrl: string; warnings: string[] } { + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); }; + try { + const route = routeModel(config, MODEL); + return { baseUrl: route.provider.baseUrl, warnings }; + } finally { + console.warn = originalWarn; + } +} + +test("google-antigravity honors a configured baseUrl override", () => { + const { baseUrl, warnings } = routeCapturingWarnings(configFor({ + adapter: "google", + baseUrl: "https://antigravity-relay.example.test", + } as OcxProviderConfig)); + + expect(baseUrl).toBe("https://antigravity-relay.example.test"); + // The override is applied, so the discarded-baseUrl diagnostic must not fire. + expect(warnings).toHaveLength(0); +}); + +test("google-antigravity keeps the registry endpoint when the seeded baseUrl is unchanged", () => { + // providerConfigSeed copies the registry baseUrl into every saved config, so the + // no-override case reaches the router as a config whose baseUrl equals the registry URL. + const { baseUrl, warnings } = routeCapturingWarnings(configFor({ + adapter: "google", + baseUrl: REGISTRY_BASE_URL, + } as OcxProviderConfig)); + + expect(baseUrl).toBe(REGISTRY_BASE_URL); + expect(warnings).toHaveLength(0); +}); + +test("google-antigravity requires a resolved baseUrl once override is enabled", () => { + // allowBaseUrlOverride providers fail closed on a missing baseUrl instead of silently + // re-pinning the registry endpoint; the seed guarantees real configs always carry one. + expect(() => routeModel(configFor({ + adapter: "google", + } as OcxProviderConfig), MODEL)).toThrow(/Invalid baseUrl/); +}); + +test("google-antigravity rejects an unresolved template baseUrl override", () => { + expect(() => routeModel(configFor({ + adapter: "google", + baseUrl: "https://{region}.example.test", + } as OcxProviderConfig), MODEL)).toThrow(/Invalid baseUrl/); +}); + +/** + * Security regression (CodeRabbit, PR #2110): google-antigravity is an OAuth provider, so an + * allowBaseUrlOverride endpoint receives bearer credentials. A cleartext http override to a + * non-local destination must be rejected on BOTH enforcement paths: routing (normal requests, + * via assertProviderDestinationAllowed) and providerDestinationConfigError, the shared gate + * that config validation and the outbound layer (providerGet/providerPost in + * src/lib/provider-outbound.ts) consult before any fetch. + */ +test("google-antigravity rejects a cleartext http override on the routing path", () => { + expect(() => routeModel(configFor({ + adapter: "google", + baseUrl: "http://antigravity-relay.example.test", + } as OcxProviderConfig), MODEL)).toThrow(/https/); +}); + +test("google-antigravity rejects a cleartext http override on the discovery/config gate", () => { + expect(providerDestinationConfigError(PROVIDER, { + baseUrl: "http://antigravity-relay.example.test", + } as OcxProviderConfig)).toMatch(/https/); + // The https form of the same destination stays accepted. + expect(providerDestinationConfigError(PROVIDER, { + baseUrl: "https://antigravity-relay.example.test", + } as OcxProviderConfig)).toBeNull(); +}); + +test("google-antigravity keeps http for an explicitly local relay", () => { + // The local proxy (127.0.0.1) is the motivating use case for this override; the https + // requirement must not break it. allowPrivateNetwork is the documented local opt-in. + const { baseUrl, warnings } = routeCapturingWarnings(configFor({ + adapter: "google", + baseUrl: "http://127.0.0.1:47821", + allowPrivateNetwork: true, + } as OcxProviderConfig)); + + expect(baseUrl).toBe("http://127.0.0.1:47821"); + expect(warnings).toHaveLength(0); +}); + diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index f06a8953c3..4b3c155bb4 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -563,7 +563,7 @@ describe("provider registry parity", () => { test("base URL override permission is registry-only and limited to opted-in providers", () => { const optedIn = PROVIDER_REGISTRY.filter(entry => entry.allowBaseUrlOverride); - expect(optedIn.map(entry => entry.id)).toEqual(["ollama", "vllm", "lm-studio", "moonshot", "qwen-cloud", "alibaba", "alibaba-token-plan-intl", "litellm"]); + expect(optedIn.map(entry => entry.id)).toEqual(["google-antigravity", "ollama", "vllm", "lm-studio", "moonshot", "qwen-cloud", "alibaba", "alibaba-token-plan-intl", "litellm"]); for (const entry of optedIn) { expect(providerConfigSeed(entry)).not.toHaveProperty("allowBaseUrlOverride"); }