feat: enhance local provider URL validation to include private IPv4 and IPv6 addresses
This commit is contained in:
@@ -194,7 +194,7 @@ bun run hardening:strict
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `doctor:runtime` fails fast if `CLAUDE_CODE_USE_OPENAI=1` with a placeholder key or a missing key for non-local providers.
|
- `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`.
|
- Codex profiles validate `CODEX_API_KEY` or the Codex CLI auth file and probe `POST /responses` instead of `GET /models`.
|
||||||
|
|
||||||
## Provider Launch Profiles
|
## Provider Launch Profiles
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { feature } from 'bun:bundle';
|
import { feature } from 'bun:bundle';
|
||||||
import {
|
import {
|
||||||
|
isLocalProviderUrl,
|
||||||
resolveCodexApiCredentials,
|
resolveCodexApiCredentials,
|
||||||
resolveProviderRequest,
|
resolveProviderRequest,
|
||||||
} from '../services/api/providerConfig.js'
|
} from '../services/api/providerConfig.js'
|
||||||
@@ -40,16 +41,6 @@ function isEnvTruthy(value: string | undefined): boolean {
|
|||||||
return normalized !== '' && normalized !== '0' && normalized !== 'false' && normalized !== 'no'
|
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(
|
function getProviderValidationError(
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
): string | null {
|
): string | null {
|
||||||
|
|||||||
24
src/services/api/providerConfig.local.test.ts
Normal file
24
src/services/api/providerConfig.local.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 public hosts as remote', () => {
|
||||||
|
expect(isLocalProviderUrl('http://203.0.113.1:11434/v1')).toBe(false)
|
||||||
|
expect(isLocalProviderUrl('https://example.com/v1')).toBe(false)
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
|
import { isIP } from 'node:net'
|
||||||
import { homedir } from 'node:os'
|
import { homedir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
|
||||||
@@ -87,6 +88,29 @@ type ModelDescriptor = {
|
|||||||
|
|
||||||
const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1'])
|
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 {
|
function asTrimmedString(value: unknown): string | undefined {
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||||
}
|
}
|
||||||
@@ -186,7 +210,23 @@ function isCodexAlias(model: string): boolean {
|
|||||||
export function isLocalProviderUrl(baseUrl: string | undefined): boolean {
|
export function isLocalProviderUrl(baseUrl: string | undefined): boolean {
|
||||||
if (!baseUrl) return false
|
if (!baseUrl) return false
|
||||||
try {
|
try {
|
||||||
return LOCALHOST_HOSTNAMES.has(new URL(baseUrl).hostname)
|
const hostname = new URL(baseUrl).hostname.toLowerCase()
|
||||||
|
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) {
|
||||||
|
return isPrivateIpv4Address(hostname)
|
||||||
|
}
|
||||||
|
if (ipVersion === 6) {
|
||||||
|
return isPrivateIpv6Address(hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user