fix(security): restrict local connector endpoints

This commit is contained in:
ZacharyZhang-NY committed 2026-05-15 00:43:45 -04:00
1 parent fa19e95f19
commit 84b92c859e
12 files changed
+158 -18

No files matched your search

+2 -2
View File
@@ -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.",
},
];
@@ -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
+9
View File
@@ -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<Array<{ id: string; name: string }>> {
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" });
@@ -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;
}
@@ -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;
}
+1 -1
View File
@@ -191,7 +191,7 @@ export const en: Record<string, string> = {
"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.",
+1 -1
View File
@@ -191,7 +191,7 @@ export const zh: Record<string, string> = {
"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": "已保存。",
+2 -2
View File
@@ -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.",
},
];
+58 -1
View File
@@ -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", [