diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 42495fe3..ec8f2f42 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -194,7 +194,7 @@ bun run hardening:strict Notes: - `doctor:runtime` fails fast if `CLAUDE_CODE_USE_OPENAI=1` with a placeholder key or a missing key for non-local providers. -- Local providers such as `http://localhost:11434/v1` and `http://127.0.0.1:1337/v1` can run without `OPENAI_API_KEY`. +- Local providers such as `http://localhost:11434/v1`, `http://10.0.0.1:11434/v1`, and `http://127.0.0.1:1337/v1` can run without `OPENAI_API_KEY`. - Codex profiles validate `CODEX_API_KEY` or the Codex CLI auth file and probe `POST /responses` instead of `GET /models`. ## Provider Launch Profiles diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index a119aa30..58c071d5 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -1,5 +1,6 @@ import { feature } from 'bun:bundle'; import { + isLocalProviderUrl, resolveCodexApiCredentials, resolveProviderRequest, } from '../services/api/providerConfig.js' @@ -40,16 +41,6 @@ function isEnvTruthy(value: string | undefined): boolean { return normalized !== '' && normalized !== '0' && normalized !== 'false' && normalized !== 'no' } -function isLocalProviderUrl(baseUrl: string | undefined): boolean { - if (!baseUrl) return false - try { - const parsed = new URL(baseUrl) - return parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '::1' - } catch { - return false - } -} - function getProviderValidationError( env: NodeJS.ProcessEnv = process.env, ): string | null { diff --git a/src/services/api/providerConfig.local.test.ts b/src/services/api/providerConfig.local.test.ts new file mode 100644 index 00000000..9adf0d57 --- /dev/null +++ b/src/services/api/providerConfig.local.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from 'bun:test' + +import { isLocalProviderUrl } from './providerConfig.js' + +test('treats localhost endpoints as local', () => { + expect(isLocalProviderUrl('http://localhost:11434/v1')).toBe(true) + expect(isLocalProviderUrl('http://127.0.0.1:11434/v1')).toBe(true) + expect(isLocalProviderUrl('http://0.0.0.0:11434/v1')).toBe(true) + // Full 127.0.0.0/8 loopback range should be treated as local + expect(isLocalProviderUrl('http://127.0.0.2:11434/v1')).toBe(true) + expect(isLocalProviderUrl('http://127.1.2.3:11434/v1')).toBe(true) + expect(isLocalProviderUrl('http://127.255.255.255:11434/v1')).toBe(true) +}) + +test('treats private IPv4 endpoints as local', () => { + expect(isLocalProviderUrl('http://10.0.0.1:11434/v1')).toBe(true) + expect(isLocalProviderUrl('http://172.16.0.1:11434/v1')).toBe(true) + expect(isLocalProviderUrl('http://192.168.0.1:11434/v1')).toBe(true) +}) + +test('treats .local hostnames as local', () => { + expect(isLocalProviderUrl('http://ollama.local:11434/v1')).toBe(true) +}) + +test('treats private IPv6 endpoints as local', () => { + expect(isLocalProviderUrl('http://[fd00::1]:11434/v1')).toBe(true) + expect(isLocalProviderUrl('http://[fe80::1]:11434/v1')).toBe(true) + expect(isLocalProviderUrl('http://[::1]:11434/v1')).toBe(true) +}) + +test('treats public hosts as remote', () => { + expect(isLocalProviderUrl('http://203.0.113.1:11434/v1')).toBe(false) + expect(isLocalProviderUrl('https://example.com/v1')).toBe(false) + expect(isLocalProviderUrl('http://[2001:4860:4860::8888]:11434/v1')).toBe(false) +}) diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index 1c3097db..5097f142 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync } from 'node:fs' +import { isIP } from 'node:net' import { homedir } from 'node:os' import { join } from 'node:path' @@ -87,6 +88,29 @@ type ModelDescriptor = { const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']) +function isPrivateIpv4Address(hostname: string): boolean { + const octets = hostname.split('.').map(part => Number.parseInt(part, 10)) + if (octets.length !== 4 || octets.some(octet => Number.isNaN(octet))) { + return false + } + + return ( + octets[0] === 10 || + (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || + (octets[0] === 192 && octets[1] === 168) + ) +} + +function isPrivateIpv6Address(hostname: string): boolean { + const firstHextet = hostname.split(':', 1)[0] + if (!firstHextet) return false + + const prefix = Number.parseInt(firstHextet, 16) + if (Number.isNaN(prefix)) return false + + return (prefix & 0xfe00) === 0xfc00 || (prefix & 0xffc0) === 0xfe80 +} + function asTrimmedString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined } @@ -186,7 +210,37 @@ function isCodexAlias(model: string): boolean { export function isLocalProviderUrl(baseUrl: string | undefined): boolean { if (!baseUrl) return false try { - return LOCALHOST_HOSTNAMES.has(new URL(baseUrl).hostname) + let hostname = new URL(baseUrl).hostname.toLowerCase() + + // Strip IPv6 brackets added by the URL parser (e.g. "[::1]" -> "::1") + if (hostname.startsWith('[') && hostname.endsWith(']')) { + hostname = hostname.slice(1, -1) + } + + // Strip RFC6874 IPv6 zone identifiers (e.g. "fe80::1%25en0" -> "fe80::1") + const zoneIdIndex = hostname.indexOf('%25') + if (zoneIdIndex !== -1) { + hostname = hostname.slice(0, zoneIdIndex) + } + + if (LOCALHOST_HOSTNAMES.has(hostname) || hostname === '0.0.0.0') { + return true + } + if (hostname.endsWith('.local')) { + return true + } + + const ipVersion = isIP(hostname) + if (ipVersion === 4) { + // Treat the full 127.0.0.0/8 loopback range as local + const firstOctet = Number.parseInt(hostname.split('.', 1)[0] ?? '', 10) + return firstOctet === 127 || isPrivateIpv4Address(hostname) + } + if (ipVersion === 6) { + return isPrivateIpv6Address(hostname) + } + + return false } catch { return false }