|
1 | 1 | import type { createContext as createMainEventaContext } from '@moeru/eventa/adapters/electron/main' |
2 | 2 |
|
| 3 | +import { isIP } from 'node:net' |
| 4 | + |
3 | 5 | import { defineStreamInvokeHandler } from '@moeru/eventa' |
4 | 6 | import { openAICompatibleFetch } from '@proj-airi/stage-shared' |
5 | 7 |
|
| 8 | +function normalizeRequestMethod(method: string | undefined): string { |
| 9 | + return (method ?? 'GET').toUpperCase() |
| 10 | +} |
| 11 | + |
| 12 | +function normalizePathname(pathname: string): string { |
| 13 | + return pathname.replace(/\/+$/, '') |
| 14 | +} |
| 15 | + |
| 16 | +function isAllowedOpenAICompatiblePath(url: URL, method: string): boolean { |
| 17 | + const pathname = normalizePathname(url.pathname) |
| 18 | + if ((method === 'GET' || method === 'HEAD') && pathname.endsWith('/models')) |
| 19 | + return true |
| 20 | + |
| 21 | + return method === 'POST' && pathname.endsWith('/chat/completions') |
| 22 | +} |
| 23 | + |
| 24 | +function normalizeHostname(hostname: string): string { |
| 25 | + return hostname.toLowerCase().replace(/^\[(.*)\]$/, '$1') |
| 26 | +} |
| 27 | + |
| 28 | +function isBlockedHostname(hostname: string): boolean { |
| 29 | + return hostname === 'localhost' |
| 30 | + || hostname.endsWith('.localhost') |
| 31 | + || hostname.endsWith('.local') |
| 32 | + || hostname === 'host.docker.internal' |
| 33 | +} |
| 34 | + |
| 35 | +function isBlockedIPv4Address(address: string): boolean { |
| 36 | + const parts = address.split('.').map(part => Number.parseInt(part, 10)) |
| 37 | + if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) |
| 38 | + return true |
| 39 | + |
| 40 | + const [first, second] = parts |
| 41 | + return first === 0 |
| 42 | + || first === 10 |
| 43 | + || first === 127 |
| 44 | + || first >= 224 |
| 45 | + || (first === 100 && second >= 64 && second <= 127) |
| 46 | + || (first === 169 && second === 254) |
| 47 | + || (first === 172 && second >= 16 && second <= 31) |
| 48 | + || (first === 192 && second === 168) |
| 49 | + || (first === 198 && (second === 18 || second === 19)) |
| 50 | +} |
| 51 | + |
| 52 | +function isBlockedIPAddress(address: string): boolean { |
| 53 | + const ipVersion = isIP(address) |
| 54 | + if (ipVersion === 4) |
| 55 | + return isBlockedIPv4Address(address) |
| 56 | + if (ipVersion !== 6) |
| 57 | + return true |
| 58 | + |
| 59 | + const normalized = address.toLowerCase() |
| 60 | + if (normalized.startsWith('::ffff:')) |
| 61 | + return isBlockedIPv4Address(normalized.slice('::ffff:'.length)) |
| 62 | + |
| 63 | + return normalized === '::' |
| 64 | + || normalized === '::1' |
| 65 | + || normalized.startsWith('fc') |
| 66 | + || normalized.startsWith('fd') |
| 67 | + || normalized.startsWith('fe80:') |
| 68 | +} |
| 69 | + |
| 70 | +function assertAllowedBridgeTarget(url: URL, method: string) { |
| 71 | + if (url.protocol !== 'http:' && url.protocol !== 'https:') { |
| 72 | + throw new Error('OpenAI-compatible fetch only supports http and https URLs.') |
| 73 | + } |
| 74 | + if (!isAllowedOpenAICompatiblePath(url, method)) { |
| 75 | + throw new Error('OpenAI-compatible fetch bridge only supports model listing and chat completions requests.') |
| 76 | + } |
| 77 | + |
| 78 | + const hostname = normalizeHostname(url.hostname) |
| 79 | + if (isBlockedHostname(hostname) || (isIP(hostname) !== 0 && isBlockedIPAddress(hostname))) { |
| 80 | + throw new Error('OpenAI-compatible fetch bridge does not allow local or private network targets.') |
| 81 | + } |
| 82 | +} |
| 83 | + |
6 | 84 | export function setupOpenAICompatibleFetchBridge(params: { |
7 | 85 | context: ReturnType<typeof createMainEventaContext>['context'] |
8 | 86 | }) { |
9 | 87 | defineStreamInvokeHandler(params.context, openAICompatibleFetch, async function* (payload, options) { |
10 | 88 | const url = new URL(payload.url) |
11 | | - if (url.protocol !== 'http:' && url.protocol !== 'https:') { |
12 | | - throw new Error('OpenAI-compatible fetch only supports http and https URLs.') |
13 | | - } |
| 89 | + const method = normalizeRequestMethod(payload.method) |
| 90 | + assertAllowedBridgeTarget(url, method) |
14 | 91 |
|
15 | 92 | const response = await fetch(url, { |
16 | | - method: payload.method, |
| 93 | + method, |
17 | 94 | headers: payload.headers, |
18 | 95 | body: payload.body, |
19 | 96 | signal: options?.abortController?.signal, |
|
0 commit comments