From efe18629b11c097323525290ce22de9e666a7cf6 Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Sat, 15 Aug 2026 12:17:45 +0700 Subject: [PATCH] fix: remove the proxy url --- README.md | 19 +-- ...297c-ae5b-4ac7-8e4d-2422896a7203.omp.cache | Bin 2790 -> 0 bytes src/services/modelService.ts | 51 ++---- vite.config.ts | 50 +----- worker/index.ts | 6 - worker/modelProxy.test.ts | 91 ----------- worker/modelProxy.ts | 147 ------------------ worker/tsconfig.json | 2 +- wrangler.jsonc | 2 +- 9 files changed, 21 insertions(+), 347 deletions(-) delete mode 100644 bash.cb00297c-ae5b-4ac7-8e4d-2422896a7203.omp.cache delete mode 100644 worker/modelProxy.test.ts delete mode 100644 worker/modelProxy.ts diff --git a/README.md b/README.md index 36ed67d..244e7b4 100644 --- a/README.md +++ b/README.md @@ -37,29 +37,24 @@ After the first deployment, add a hostname in **Workers & Pages → 9router-conf Do not protect the app or `/i/*` with Cloudflare Access if users must download install scripts via `curl` or PowerShell without signing in. -## Production model proxy +## Gateway CORS requirement -The browser first requests `/models` directly. If that gateway does not allow CORS, the app falls back to `/api/fetch-models` on the Worker. +Models are requested directly from the Base URL entered by the user at `/models`. This app does not proxy gateway requests through Cloudflare, so it works with any reachable gateway that permits cross-origin requests. -The Worker proxy is disabled by default and only accepts HTTPS gateway origins explicitly allowlisted through `MODEL_PROXY_ALLOWED_ORIGINS`. This prevents the public endpoint from becoming an open proxy. - -Configure it in **Workers & Pages → 9router-config-generate → Settings → Variables and Secrets** as a plaintext variable: +Configure each gateway to allow the deployed app origin and request headers: ```txt -MODEL_PROXY_ALLOWED_ORIGINS=https://router.example.com,https://backup-router.example.com +Access-Control-Allow-Origin: https:// +Access-Control-Allow-Methods: GET, OPTIONS +Access-Control-Allow-Headers: Authorization, Content-Type ``` -Each value must be an HTTPS origin only: no path, query string, credentials, or wildcard. Deploy again after changing the variable. - -> A Cloudflare Worker cannot reach `localhost` on a visitor's computer. For a local 9router gateway, configure that gateway's CORS policy to allow the deployed app origin instead. Use the Worker proxy only for publicly reachable HTTPS gateways. - -The proxy forwards the API key supplied by the user for the single `/models` request. It does not store, log, or cache the key. Do not configure a shared 9router API key as a Worker variable or secret. +For a local 9router gateway, allow the deployed app domain in its CORS configuration. Cloudflare cannot access `localhost` on a visitor's computer, but the visitor's browser can when the gateway permits that origin. ## Cloudflare resources used - Cloudflare Workers - Workers Static Assets (`ASSETS` binding) - Optional custom domain -- Optional plaintext variable: `MODEL_PROXY_ALLOWED_ORIGINS` No KV, D1, R2, Queue, Durable Object, or Worker secret is required. diff --git a/bash.cb00297c-ae5b-4ac7-8e4d-2422896a7203.omp.cache b/bash.cb00297c-ae5b-4ac7-8e4d-2422896a7203.omp.cache deleted file mode 100644 index ad0920a1425027c84cf9bd4d4717b23816712a17..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2790 zcmb_e-ESL35Z|%SA4%NwLsdb%U?f1L$ab7I4K1ONIBAnMX``HzAeE|GpVz)??ryEU zb7??8zkn7>`TlN8Ti$r#f8a0Rfxmzk-dgbvS!Vanu~R7$kt$ib+1c6InfcBB#&5_= z2EYl^@melBy|3wncDq)zxK}zy{W`1jT8+7ki4wO9-fE%61G^Lmt7K8jX2mH#6sxBl zKV+goEtY@#oik@ZUM7$Od6AG8-%COQ1xP|_iMnkDI|%eu9ghVe^;%%yPPICVUqaU4 zWjvS|3>ij3Lv~i*0IzH@q9~DS>_lEk0t{}{fxJp!7i=?G(_?-ZR8%G)KfqX)sECC5 z@*L;&LU^p%ypNT2psOK-IP`+X<)n-aJL<@ z*m2^NsnnSsy;fM^Z9i0nNtx5LOR6+r9Otz_3l&~xN=Po7vyi}@1 zn$xkNzVvB%9nHJ@`lv1{$Qu~cUioq4Ze6F`RoR75S7!$M>jI{uuy^FO?EsL?`|Pc@ z=j2hDJm!Skx)I_^euD0l8stp^PeNvyixtOjg8U3?X@<1fS)>QH#$OYLQvnb@qE}0qMfO6jel;j6@-FOZ^nuc9q1?)7iN`Za)C9}cW{Be z`RJBuc_4p4AjfS+>k78M`F3Ehulm($g4vF*H`86QeZXhYAe0qxb^N z(y^qD07Z@>@LI?Hp{|&MQw%dDL;$ z4Wur=N2_E9=ycChNKQJMHnIz>#XCo{ofc6yZ%2>=*R&C+`aBJ71>9hTdmhqmCu@b* zT4F-pT!Y^bBVs(293%=Y)T4Q(JZ#YhcHrJv7&+J&rmS3VSTC`Wq0ym+HC!IG4h$cx z9VpkRHF5xwe_p=_nFp8eViGK3SFDKaXZ)K9W7 zJX$W74hfDdCJOi-8cws&WIhu})?jFV6?F01CTv_Ti`l_yer$H;S+Gm^8UO+ABrWLd#n^uQE8G}Z9OP9P5z0^ zsxrWZgPc@S#UaE{Fb{SaO)ce*s?Rk3@)jc6(?Z!nhmN8RTJ%zKP8Zzd(X-{gzS^I)quW(MAmKa?p2dcfTMU-dsu^C z<*hX^5uJ1S2ftj25o$OtJG`}WMTl%tvPV!-S3f>D3@{Y1kkSDoe?|yHt~UXs>2<_m zPw!5*69{Yy`4)SJB!HwN-rdpdj7TBxDFW+|@_n3GJ>7wNMSGgV=8!F8IW;8&k1&_N z%lo?#u2lZv#T$7@E8-Z!AA!eL(nP0(o$rp3;{M{Hz2z4Q0;ho*!3GXud&?N6+cl(~ xHe`km70biLAwY5^@XsIeuQB*H@iQU2TKezOSxr&#f5jyJ?=cc2-D?+F{|6XXMV$Zu diff --git a/src/services/modelService.ts b/src/services/modelService.ts index 10f5960..9f8a6b0 100644 --- a/src/services/modelService.ts +++ b/src/services/modelService.ts @@ -59,11 +59,9 @@ function formatModelName(id: string, rawName?: string): string { export async function fetchRemoteModels(connection: ConnectionConfig): Promise { const trimmedBase = connection.baseUrl.replace(/\/+$/, '') - const isHttpUrl = trimmedBase.startsWith('http://') || trimmedBase.startsWith('https://') + let data: RawModelsResponse - let data: RawModelsResponse | null = null - - // 1. Direct fetch with the standard Authorization header. Never put an API key in a URL. + // Fetch the user-provided gateway directly. The gateway must permit this app's origin with CORS. try { const headers: Record = { Accept: 'application/json', @@ -77,43 +75,16 @@ export async function fetchRemoteModels(connection: ConnectionConfig): Promise null) - const errMsg = errJson?.error?.message || errJson?.error || proxyRes.statusText - throw new Error(`Failed to fetch models: ${errMsg}`) - } - } catch (err: unknown) { - if (err instanceof Error) { - throw err - } - } - } - - if (!data) { - throw new Error('Unable to connect to gateway models endpoint. Check your Base URL and API Key.') + data = await directRes.json() + } catch (err: unknown) { + const reason = err instanceof Error ? err.message : 'Unknown network error' + throw new Error( + `Unable to fetch models directly from the gateway. Check the Base URL, API key, and that the gateway allows this app origin with CORS. ${reason}`, + { cause: err } + ) } const rawList = Array.isArray(data) ? data : (data.data || []) diff --git a/vite.config.ts b/vite.config.ts index 96d0da6..58ee036 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ react(), babel({ presets: [reactCompilerPreset()] }), { - name: 'router-proxy-middleware', + name: 'install-script-middleware', configureServer(server) { // Dev endpoint for 1-click install scripts: /i/:token.(sh|ps1) server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => { @@ -32,54 +32,6 @@ export default defineConfig({ next() }) - server.middlewares.use('/api/fetch-models', async (req: IncomingMessage, res: ServerResponse) => { - if (req.method !== 'POST') { - res.statusCode = 405 - res.end(JSON.stringify({ error: 'Method not allowed' })) - return - } - - let body = '' - req.on('data', (chunk: Buffer) => { - body += chunk.toString() - }) - - req.on('end', async () => { - try { - const { baseUrl, apiKey } = JSON.parse(body || '{}') - if (!baseUrl) { - res.statusCode = 400 - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ error: 'baseUrl is required' })) - return - } - - const targetUrl = `${baseUrl.replace(/\/+$/, '')}/models` - const headers: Record = { - 'Accept': 'application/json', - } - if (apiKey) { - headers['Authorization'] = `Bearer ${apiKey.trim()}` - } - - const remoteRes = await fetch(targetUrl, { - method: 'GET', - headers, - }) - - const remoteData = await remoteRes.json().catch(() => null) - - res.statusCode = remoteRes.status - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(remoteData || {})) - } catch (err: unknown) { - res.statusCode = 500 - res.setHeader('Content-Type', 'application/json') - const msg = err instanceof Error ? err.message : 'Internal error' - res.end(JSON.stringify({ error: msg })) - } - }) - }) }, }, ], diff --git a/worker/index.ts b/worker/index.ts index 6ba69b2..bef380b 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,11 +1,9 @@ import { renderInstallScript } from '../src/services/installEndpoint.ts' -import { proxyModelRequest } from './modelProxy.ts' interface WorkerEnv { ASSETS: { fetch(request: Request): Promise } - MODEL_PROXY_ALLOWED_ORIGINS?: string } export default { @@ -26,10 +24,6 @@ export default { }) } - if (url.pathname === '/api/fetch-models') { - return proxyModelRequest(request, env) - } - return env.ASSETS.fetch(request) }, } diff --git a/worker/modelProxy.test.ts b/worker/modelProxy.test.ts deleted file mode 100644 index 6e59b5f..0000000 --- a/worker/modelProxy.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { afterEach, describe, expect, it, mock } from 'bun:test' -import { proxyModelRequest } from './modelProxy.ts' - -const originalFetch = globalThis.fetch - -function createRequest(body: Record, method = 'POST'): Request { - return new Request('https://config.example.com/api/fetch-models', { - method, - headers: { 'content-type': 'application/json' }, - body: method === 'POST' ? JSON.stringify(body) : undefined, - }) -} - -afterEach(() => { - globalThis.fetch = originalFetch -}) - -describe('model proxy', () => { - it('does not enable the proxy without an allowlist', async () => { - const fetchMock = mock() - globalThis.fetch = fetchMock - - const response = await proxyModelRequest( - createRequest({ baseUrl: 'https://router.example.com/v1' }), - {} - ) - - expect(response.status).toBe(503) - expect(fetchMock).not.toHaveBeenCalled() - }) - - it('ignores allowlist entries that are not plain HTTPS origins', async () => { - const fetchMock = mock() - globalThis.fetch = fetchMock - - const response = await proxyModelRequest( - createRequest({ baseUrl: 'https://router.example.com/v1' }), - { MODEL_PROXY_ALLOWED_ORIGINS: 'https://router.example.com/v1' } - ) - - expect(response.status).toBe(503) - expect(fetchMock).not.toHaveBeenCalled() - }) - - it('proxies an allowlisted gateway request with a bearer token', async () => { - const fetchMock = mock(() => - Promise.resolve(Response.json({ data: [{ id: 'example-model' }] })) - ) - globalThis.fetch = fetchMock - - const response = await proxyModelRequest( - createRequest({ - baseUrl: 'https://router.example.com/v1/', - apiKey: 'test-key', - }), - { MODEL_PROXY_ALLOWED_ORIGINS: 'https://router.example.com' } - ) - - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ data: [{ id: 'example-model' }] }) - expect(fetchMock).toHaveBeenCalledWith( - 'https://router.example.com/v1/models', - expect.objectContaining({ - headers: expect.any(Headers), - redirect: 'error', - }) - ) - - const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit] - expect((requestInit.headers as Headers).get('Authorization')).toBe('Bearer test-key') - }) - - it('rejects unallowlisted and non-HTTPS gateways without fetching them', async () => { - const fetchMock = mock() - globalThis.fetch = fetchMock - const env = { MODEL_PROXY_ALLOWED_ORIGINS: 'https://router.example.com' } - - const unallowlisted = await proxyModelRequest( - createRequest({ baseUrl: 'https://other.example.com/v1' }), - env - ) - const nonHttps = await proxyModelRequest( - createRequest({ baseUrl: 'http://router.example.com/v1' }), - env - ) - - expect(unallowlisted.status).toBe(403) - expect(nonHttps.status).toBe(400) - expect(fetchMock).not.toHaveBeenCalled() - }) -}) \ No newline at end of file diff --git a/worker/modelProxy.ts b/worker/modelProxy.ts deleted file mode 100644 index bfe2a42..0000000 --- a/worker/modelProxy.ts +++ /dev/null @@ -1,147 +0,0 @@ -const MAX_REQUEST_BODY_BYTES = 10_240 -const MAX_BASE_URL_LENGTH = 2_048 -const MAX_API_KEY_LENGTH = 4_096 - -interface ModelProxyEnvironment { - MODEL_PROXY_ALLOWED_ORIGINS?: string -} - -interface ModelProxyRequestBody { - baseUrl?: unknown - apiKey?: unknown -} - -function jsonError(status: number, error: string): Response { - return Response.json( - { error }, - { - status, - headers: { 'cache-control': 'no-store' }, - } - ) -} - -function getAllowedOrigins(value: string | undefined): Set { - const origins = new Set() - - for (const candidate of value?.split(',') ?? []) { - try { - const url = new URL(candidate.trim()) - if ( - url.protocol === 'https:' && - url.pathname === '/' && - !url.search && - !url.hash && - !url.username && - !url.password - ) { - origins.add(url.origin) - } - } catch { - // Ignore malformed configuration entries rather than widening access. - } - } - - return origins -} - -export async function proxyModelRequest( - request: Request, - env: ModelProxyEnvironment -): Promise { - if (request.method !== 'POST') { - return jsonError(405, 'Method not allowed') - } - - const contentLength = Number(request.headers.get('content-length')) - if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BODY_BYTES) { - return jsonError(413, 'Request body is too large') - } - - if (!request.headers.get('content-type')?.includes('application/json')) { - return jsonError(415, 'Content-Type must be application/json') - } - - const requestText = await request.text() - if (requestText.length > MAX_REQUEST_BODY_BYTES) { - return jsonError(413, 'Request body is too large') - } - - let body: ModelProxyRequestBody - try { - body = JSON.parse(requestText) as ModelProxyRequestBody - } catch { - return jsonError(400, 'Request body must be valid JSON') - } - - if ( - typeof body.baseUrl !== 'string' || - body.baseUrl.length === 0 || - body.baseUrl.length > MAX_BASE_URL_LENGTH - ) { - return jsonError(400, 'baseUrl must be a valid HTTPS URL') - } - - if (typeof body.apiKey !== 'undefined' && typeof body.apiKey !== 'string') { - return jsonError(400, 'apiKey must be a string') - } - - if (body.apiKey && body.apiKey.length > MAX_API_KEY_LENGTH) { - return jsonError(400, 'apiKey is too long') - } - - const normalizedBaseUrl = body.baseUrl.trim().replace(/\/+$/, '') - let baseUrl: URL - try { - baseUrl = new URL(normalizedBaseUrl) - } catch { - return jsonError(400, 'baseUrl must be a valid HTTPS URL') - } - - if ( - baseUrl.protocol !== 'https:' || - baseUrl.username || - baseUrl.password || - baseUrl.search || - baseUrl.hash - ) { - return jsonError(400, 'baseUrl must be a valid HTTPS URL') - } - - const allowedOrigins = getAllowedOrigins(env.MODEL_PROXY_ALLOWED_ORIGINS) - if (allowedOrigins.size === 0) { - return jsonError( - 503, - 'Model proxy is not configured. Ask the site administrator to configure MODEL_PROXY_ALLOWED_ORIGINS.' - ) - } - - if (!allowedOrigins.has(baseUrl.origin)) { - return jsonError(403, 'This gateway origin is not allowed by the model proxy') - } - - const headers = new Headers({ Accept: 'application/json' }) - if (body.apiKey?.trim()) { - headers.set('Authorization', `Bearer ${body.apiKey.trim()}`) - } - - let upstreamResponse: Response - try { - upstreamResponse = await fetch(`${normalizedBaseUrl}/models`, { - method: 'GET', - headers, - redirect: 'error', - }) - } catch { - return jsonError(502, 'Unable to contact the configured gateway') - } - - const upstreamBody = await upstreamResponse.json().catch(() => ({ - error: 'Gateway returned an invalid JSON response', - })) - - return Response.json(upstreamBody, { - status: upstreamResponse.status, - headers: { 'cache-control': 'no-store' }, - }) -} \ No newline at end of file diff --git a/worker/tsconfig.json b/worker/tsconfig.json index 4b4540c..650da38 100644 --- a/worker/tsconfig.json +++ b/worker/tsconfig.json @@ -10,5 +10,5 @@ "verbatimModuleSyntax": true, "moduleDetection": "force" }, - "include": ["index.ts", "modelProxy.ts"] + "include": ["index.ts"] } diff --git a/wrangler.jsonc b/wrangler.jsonc index d616208..0109bb3 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -7,6 +7,6 @@ "directory": "./dist", "binding": "ASSETS", "not_found_handling": "single-page-application", - "run_worker_first": ["/i/*", "/api/*"] + "run_worker_first": ["/i/*"] } }