From 84b92c859ed5f3ff56c1027e0dcb64ade3d5433f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 15 May 2026 00:43:45 -0400 Subject: [PATCH] fix(security): restrict local connector endpoints --- src/main/connector-definitions/local.ts | 4 +- src/main/credentials/credential-resolver.ts | 8 +++ src/main/model-resolver.ts | 9 +++ .../provider-cards/local-card.tsx | 10 ++-- .../components/settings/forms/LocalForm.tsx | 5 +- src/renderer/i18n/en.ts | 2 +- src/renderer/i18n/zh.ts | 2 +- src/shared/connector-catalog.ts | 4 +- src/shared/contracts/schemas-credentials.ts | 59 ++++++++++++++++++- tests/unit/connector-provider-guard.test.ts | 16 ++++- tests/unit/credential-resolver.test.ts | 22 +++++++ tests/unit/credentials-schemas.test.ts | 35 +++++++++++ 12 files changed, 158 insertions(+), 18 deletions(-) diff --git a/src/main/connector-definitions/local.ts b/src/main/connector-definitions/local.ts index c538647a..ad4ef44c 100644 --- a/src/main/connector-definitions/local.ts +++ b/src/main/connector-definitions/local.ts @@ -25,7 +25,7 @@ export const LOCAL_CONNECTORS: ConnectorDefinition[] = [ defaultModel: "llama3.2", auth: { type: "local", defaultBaseUrl: "http://localhost:11434/v1" }, notes: - "Ollama exposes /v1/chat/completions and /v1/models. The default port is 11434; override if you launched `ollama serve` on a different address.", + "Ollama exposes /v1/chat/completions and /v1/models. The default port is 11434; override with a loopback or private-network address.", }, { id: "lm-studio", @@ -36,6 +36,6 @@ export const LOCAL_CONNECTORS: ConnectorDefinition[] = [ defaultModel: "local-model", auth: { type: "local", defaultBaseUrl: "http://localhost:1234/v1" }, notes: - "Enable LM Studio's local server from the Developer tab; the default port is 1234. Model ids match whatever you have loaded.", + "Enable LM Studio's local server from the Developer tab; the default port is 1234. Use a loopback or private-network address.", }, ]; diff --git a/src/main/credentials/credential-resolver.ts b/src/main/credentials/credential-resolver.ts index 5fa617da..65e465a4 100644 --- a/src/main/credentials/credential-resolver.ts +++ b/src/main/credentials/credential-resolver.ts @@ -1,5 +1,6 @@ import { getOAuthProvider } from "@mariozechner/pi-ai/oauth"; import { getEnvApiKey } from "@mariozechner/pi-ai"; +import { isLocalConnectorBaseUrl } from "@shared/contracts/schemas-credentials"; import { getConnectorDefinition, isPiKnownProvider } from "@main/connectors"; import { UpstreamError } from "../errors/index.js"; import { CredentialStore } from "./credential-store"; @@ -150,6 +151,13 @@ export class CredentialResolver { if (stored?.kind !== "local") { throw new CredentialError("CREDENTIAL_MISSING", connectorId); } + if (!isLocalConnectorBaseUrl(stored.value.baseUrl)) { + throw new CredentialError( + "CREDENTIAL_INVALID", + connectorId, + "Local connector baseUrl must use localhost, loopback, or a private IP host.", + ); + } // Ollama and LM Studio ignore the bearer value but the OpenAI SDK // refuses an empty string, so we hand it the connector id as a // stable non-empty placeholder. The actual transport is keyed off diff --git a/src/main/model-resolver.ts b/src/main/model-resolver.ts index 5895330d..07b10cb6 100644 --- a/src/main/model-resolver.ts +++ b/src/main/model-resolver.ts @@ -18,6 +18,7 @@ import log from "electron-log/main.js"; import { getModels, type Api, type Model } from "@mariozechner/pi-ai"; +import { isLocalConnectorBaseUrl } from "@shared/contracts/schemas-credentials"; import { isPiKnownProvider } from "./connectors"; // Fireworks' OpenAI-compatible root. The OpenAI SDK appends @@ -125,6 +126,10 @@ export function resolveModel( log.warn(`[model-resolver] ${provider} requires a baseUrl from credentials; none provided.`); return null; } + if (!isLocalConnectorBaseUrl(options.baseUrl)) { + log.warn(`[model-resolver] ${provider} rejected non-local baseUrl.`); + return null; + } return buildLocalModel(provider, modelId, options.baseUrl); } if (!isPiKnownProvider(provider)) { @@ -221,6 +226,10 @@ export function resolveModels(provider: string): Array<{ id: string; name: strin export async function fetchLocalConnectorModels( baseUrl: string, ): Promise> { + if (!isLocalConnectorBaseUrl(baseUrl)) { + log.warn("[model-resolver] rejected non-local model catalog baseUrl."); + return []; + } const url = `${baseUrl.replace(/\/$/, "")}/models`; try { const response = await fetch(url, { method: "GET" }); diff --git a/src/renderer/components/company-onboarding/provider-cards/local-card.tsx b/src/renderer/components/company-onboarding/provider-cards/local-card.tsx index fbab8f41..30f2f2be 100644 --- a/src/renderer/components/company-onboarding/provider-cards/local-card.tsx +++ b/src/renderer/components/company-onboarding/provider-cards/local-card.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { FloppyDisk } from "../../ui/icons"; import type { ConnectorCatalogEntry } from "@shared/connector-catalog"; -import type { CredentialSummary } from "@shared/contracts/schemas-credentials"; +import { isLocalConnectorBaseUrl, type CredentialSummary } from "@shared/contracts/schemas-credentials"; import type { TranslationFn } from "../../../i18n"; import { InlineFeedback, @@ -23,8 +23,8 @@ interface LocalProviderCardProps { * Onboarding card for local OpenAI-compatible servers (Ollama, LM Studio). * * The catalog supplies a sensible default URL per entry; the user can - * override it to point at a non-default port, a remote machine, or a - * reverse proxy. The card never asks for an API key — local servers + * override it to point at a private LAN server or a non-default port. + * The card never asks for an API key — local servers * accept any bearer value, so the credential resolver supplies the * connector id as a non-empty placeholder for the OpenAI SDK. */ @@ -65,9 +65,7 @@ export function LocalProviderCard({ setFeedback(t("onboarding.providers.baseUrlRequired")); return; } - try { - new URL(trimmed); - } catch { + if (!isLocalConnectorBaseUrl(trimmed)) { setFeedback(t("onboarding.providers.baseUrlInvalid")); return; } diff --git a/src/renderer/components/settings/forms/LocalForm.tsx b/src/renderer/components/settings/forms/LocalForm.tsx index caf28f90..cbbe4043 100644 --- a/src/renderer/components/settings/forms/LocalForm.tsx +++ b/src/renderer/components/settings/forms/LocalForm.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { isLocalConnectorBaseUrl } from "@shared/contracts/schemas-credentials"; import { ActionButton, Input } from "../../ui"; import { useT } from "../../../i18n"; @@ -22,9 +23,7 @@ export function LocalForm({ connectorId, defaultBaseUrl, onSaved }: Props) { const handleSubmit = async () => { if (!desktop || !canSave) return; - try { - new URL(trimmed); - } catch { + if (!isLocalConnectorBaseUrl(trimmed)) { setError(t("onboarding.providers.baseUrlInvalid")); return; } diff --git a/src/renderer/i18n/en.ts b/src/renderer/i18n/en.ts index 43578420..28dd700b 100644 --- a/src/renderer/i18n/en.ts +++ b/src/renderer/i18n/en.ts @@ -191,7 +191,7 @@ export const en: Record = { "onboarding.providers.apiKeyReplacePlaceholder": "Leave empty to keep the current key", "onboarding.providers.baseUrlLabel": "Base URL", "onboarding.providers.baseUrlRequired": "Enter the local server URL before saving.", - "onboarding.providers.baseUrlInvalid": "Enter a valid HTTP or HTTPS URL.", + "onboarding.providers.baseUrlInvalid": "Enter a loopback or private-network HTTP(S) URL.", "onboarding.providers.save": "Save", "onboarding.providers.saving": "Saving…", "onboarding.providers.saved": "Saved.", diff --git a/src/renderer/i18n/zh.ts b/src/renderer/i18n/zh.ts index 692b9272..5a588861 100644 --- a/src/renderer/i18n/zh.ts +++ b/src/renderer/i18n/zh.ts @@ -191,7 +191,7 @@ export const zh: Record = { "onboarding.providers.apiKeyReplacePlaceholder": "保持现有 Key 请留空", "onboarding.providers.baseUrlLabel": "服务地址", "onboarding.providers.baseUrlRequired": "请填入本地服务地址后再保存。", - "onboarding.providers.baseUrlInvalid": "请输入合法的 HTTP 或 HTTPS 地址。", + "onboarding.providers.baseUrlInvalid": "请输入本机或私有网络 HTTP(S) 地址。", "onboarding.providers.save": "保存", "onboarding.providers.saving": "保存中…", "onboarding.providers.saved": "已保存。", diff --git a/src/shared/connector-catalog.ts b/src/shared/connector-catalog.ts index cb7b68ae..5311d712 100644 --- a/src/shared/connector-catalog.ts +++ b/src/shared/connector-catalog.ts @@ -252,7 +252,7 @@ const LOCAL_ENTRIES: ConnectorCatalogEntry[] = [ description: "Local Ollama server — open-weights models on your own machine via the OpenAI-compatible endpoint.", defaultModel: "llama3.2", auth: { type: "local", defaultBaseUrl: "http://localhost:11434/v1" }, - notes: "Default port 11434. Override if you launched `ollama serve` elsewhere.", + notes: "Default port 11434. Override with a loopback or private-network address.", }, { id: "lm-studio", @@ -261,7 +261,7 @@ const LOCAL_ENTRIES: ConnectorCatalogEntry[] = [ description: "Local LM Studio server — host any loaded model via the OpenAI-compatible endpoint.", defaultModel: "local-model", auth: { type: "local", defaultBaseUrl: "http://localhost:1234/v1" }, - notes: "Enable LM Studio's local server from the Developer tab. Default port 1234.", + notes: "Enable LM Studio's local server from the Developer tab. Use a loopback or private-network address.", }, ]; diff --git a/src/shared/contracts/schemas-credentials.ts b/src/shared/contracts/schemas-credentials.ts index 4d58c5b4..9958e938 100644 --- a/src/shared/contracts/schemas-credentials.ts +++ b/src/shared/contracts/schemas-credentials.ts @@ -50,8 +50,65 @@ export const gcpAdcCredentialSchema = z.object({ credentialsPath: z.string().optional(), }); +function normalizeUrlHostname(hostname: string): string { + const trimmed = hostname.trim().toLowerCase(); + return trimmed.startsWith("[") && trimmed.endsWith("]") + ? trimmed.slice(1, -1) + : trimmed; +} + +function parseIpv4(hostname: string): [number, number, number, number] | null { + const parts = hostname.split("."); + if (parts.length !== 4) return null; + const octets = parts.map((part) => { + if (!/^\d+$/.test(part)) return null; + const value = Number(part); + return Number.isInteger(value) && value >= 0 && value <= 255 ? value : null; + }); + if (octets.some((part) => part === null)) return null; + return octets as [number, number, number, number]; +} + +function isPrivateIpv4(hostname: string): boolean { + const octets = parseIpv4(hostname); + if (!octets) return false; + const [first, second] = octets; + return first === 10 + || first === 127 + || (first === 172 && second >= 16 && second <= 31) + || (first === 192 && second === 168) + || (first === 169 && second === 254); +} + +function isPrivateIpv6(hostname: string): boolean { + if (hostname === "::1") return true; + return /^f[cd][0-9a-f]{2}:/i.test(hostname) || /^fe80:/i.test(hostname); +} + +export function isLocalConnectorBaseUrl(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + if (url.username || url.password) return false; + const hostname = normalizeUrlHostname(url.hostname); + if (hostname === "localhost") return true; + return isPrivateIpv4(hostname) || isPrivateIpv6(hostname); +} + +export const localBaseUrlSchema = z + .string() + .url() + .refine( + isLocalConnectorBaseUrl, + "Local connector baseUrl must use HTTP(S) with localhost, loopback, or a private IP host.", + ); + export const localCredentialSchema = z.object({ - baseUrl: z.string().url(), + baseUrl: localBaseUrlSchema, }); export const credentialPayloadSchema = z.discriminatedUnion("kind", [ diff --git a/tests/unit/connector-provider-guard.test.ts b/tests/unit/connector-provider-guard.test.ts index a4aba8cb..e70efe3d 100644 --- a/tests/unit/connector-provider-guard.test.ts +++ b/tests/unit/connector-provider-guard.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { getProviders } from "@mariozechner/pi-ai"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CONNECTORS, getConnectorKnownProvider, @@ -9,7 +9,7 @@ import { isLocalModelResolverProvider, isPiKnownProvider, } from "../../src/main/connectors"; -import { FIREWORKS_FIREPASS_MODEL_ID, resolveModel, resolveModels } from "../../src/main/model-resolver"; +import { FIREWORKS_FIREPASS_MODEL_ID, fetchLocalConnectorModels, resolveModel, resolveModels } from "../../src/main/model-resolver"; describe("connector provider guard", () => { it("classifies catalog providers before they enter pi-ai APIs", () => { @@ -38,6 +38,18 @@ describe("connector provider guard", () => { expect(resolveModels("not-a-pi-provider")).toEqual([]); expect(resolveModel("not-a-pi-provider", "model-id")).toBeNull(); expect(resolveModel("fireworks-firepass", "ignored")?.id).toBe(FIREWORKS_FIREPASS_MODEL_ID); + expect(resolveModel("ollama", "llama3.2", { baseUrl: "http://127.0.0.1:11434/v1" })?.baseUrl).toBe("http://127.0.0.1:11434/v1"); + expect(resolveModel("ollama", "llama3.2", { baseUrl: "https://example.com/v1" })).toBeNull(); + }); + + it("rejects remote URLs before local model catalog fetch", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("fetch should stay unused")); + try { + await expect(fetchLocalConnectorModels("https://example.com/v1")).resolves.toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } }); it("keeps provider calls free of erased never casts", () => { diff --git a/tests/unit/credential-resolver.test.ts b/tests/unit/credential-resolver.test.ts index c5f1f1b5..4cd28473 100644 --- a/tests/unit/credential-resolver.test.ts +++ b/tests/unit/credential-resolver.test.ts @@ -157,4 +157,26 @@ describe("CredentialResolver", () => { GOOGLE_APPLICATION_CREDENTIALS: "/etc/gcp/service-account.json", }); }); + + it("rejects remote baseUrl values for local connectors", async () => { + store.save("ollama", { + kind: "local", + value: { baseUrl: "https://example.com/v1" }, + }); + + let thrown: unknown; + try { + await resolver.resolve("ollama"); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(CredentialError); + expect(thrown).toMatchObject({ + kind: "upstream", + code: "CREDENTIAL_INVALID", + connectorId: "ollama", + context: { connectorId: "ollama" }, + }); + }); }); diff --git a/tests/unit/credentials-schemas.test.ts b/tests/unit/credentials-schemas.test.ts index 2b9bb264..e7d05126 100644 --- a/tests/unit/credentials-schemas.test.ts +++ b/tests/unit/credentials-schemas.test.ts @@ -5,6 +5,8 @@ import { azureCredentialSchema, credentialPayloadSchema, gcpAdcCredentialSchema, + isLocalConnectorBaseUrl, + localCredentialSchema, removeCredentialInputSchema, saveCredentialInputSchema, testCredentialInputSchema, @@ -59,6 +61,39 @@ describe("credentials Zod schemas", () => { expect(gcpAdcCredentialSchema.safeParse({ project: "p", location: "us-central1" }).success).toBe(true); }); + it("localCredentialSchema accepts loopback and private-network baseUrl values", () => { + for (const baseUrl of [ + "http://localhost:11434/v1", + "http://127.0.0.1:11434/v1", + "http://10.0.0.20:11434/v1", + "https://172.16.0.10:11434/v1", + "http://172.31.255.255:11434/v1", + "http://192.168.1.20:11434/v1", + "http://169.254.1.20:11434/v1", + "http://[::1]:11434/v1", + "http://[fc00::1]:11434/v1", + "http://[fd12::1]:11434/v1", + "http://[fe80::1]:11434/v1", + ]) { + expect(localCredentialSchema.safeParse({ baseUrl }).success).toBe(true); + expect(isLocalConnectorBaseUrl(baseUrl)).toBe(true); + } + }); + + it("localCredentialSchema rejects remote OpenAI-compatible endpoints", () => { + for (const baseUrl of [ + "https://api.openai.com/v1", + "https://example.com/v1", + "http://172.32.0.1:11434/v1", + "http://100.64.0.1:11434/v1", + "ftp://localhost:11434/v1", + "http://user:pass@127.0.0.1:11434/v1", + ]) { + expect(localCredentialSchema.safeParse({ baseUrl }).success).toBe(false); + expect(isLocalConnectorBaseUrl(baseUrl)).toBe(false); + } + }); + it("credentialPayloadSchema discriminates on kind", () => { expect( credentialPayloadSchema.safeParse({