Skip to content

Commit 044e06e

Browse files
committed
fix(stage-tamagotchi): restrict openai-compatible fetch bridge
1 parent bcb4331 commit 044e06e

1 file changed

Lines changed: 81 additions & 4 deletions

File tree

apps/stage-tamagotchi/src/main/services/airi/openai-compatible-fetch.ts

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,96 @@
11
import type { createContext as createMainEventaContext } from '@moeru/eventa/adapters/electron/main'
22

3+
import { isIP } from 'node:net'
4+
35
import { defineStreamInvokeHandler } from '@moeru/eventa'
46
import { openAICompatibleFetch } from '@proj-airi/stage-shared'
57

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+
684
export function setupOpenAICompatibleFetchBridge(params: {
785
context: ReturnType<typeof createMainEventaContext>['context']
886
}) {
987
defineStreamInvokeHandler(params.context, openAICompatibleFetch, async function* (payload, options) {
1088
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)
1491

1592
const response = await fetch(url, {
16-
method: payload.method,
93+
method,
1794
headers: payload.headers,
1895
body: payload.body,
1996
signal: options?.abortController?.signal,

0 commit comments

Comments
 (0)