From a5cc7ff0c18a2da39b453a749846104d60668560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 12 May 2026 14:26:00 +0800 Subject: [PATCH] Tighten H5 policy trusted routes The classifier should not treat every /sdk path as trusted by pathname, because LAN-exposed servers already have a narrower local SDK trust boundary. Constraint: Desktop local chat, Tauri WebView, loopback adapters, and local SDK routes must remain tokenless. Rejected: Path-only /sdk trust | a non-local caller could reach the SDK route through a LAN-bound server. Confidence: high Scope-risk: narrow Directive: Keep remote /sdk and hostile-origin loopback requests in the H5/browser bucket unless a dedicated SDK auth layer is verified. Tested: bun test src/server/__tests__/h5-access-policy.test.ts --- src/server/__tests__/h5-access-policy.test.ts | 26 +++++++++++++++++-- src/server/h5AccessPolicy.ts | 6 +++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/server/__tests__/h5-access-policy.test.ts b/src/server/__tests__/h5-access-policy.test.ts index a8c88e83..d2723bd5 100644 --- a/src/server/__tests__/h5-access-policy.test.ts +++ b/src/server/__tests__/h5-access-policy.test.ts @@ -25,18 +25,40 @@ describe('h5AccessPolicy', () => { expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, explicitAuthRequired: false })).toBe(false) }) - test('keeps internal SDK websocket routes tokenless', () => { - const request = req('http://192.168.0.20:3456/sdk/session-1') + test('keeps local internal SDK websocket routes tokenless', () => { + const request = req('http://127.0.0.1:3456/sdk/session-1') expect(classifyH5Request(request, new URL(request.url))).toBe('internal-sdk') expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, explicitAuthRequired: false })).toBe(false) }) + test('does not trust remote SDK websocket routes by path alone', () => { + const request = req('http://192.168.0.20:3456/sdk/session-1') + expect(classifyH5Request(request, new URL(request.url))).toBe('h5-browser') + expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, explicitAuthRequired: false })).toBe(true) + }) + test('keeps adapter API routes tokenless for local integrations', () => { const request = req('http://127.0.0.1:3456/api/adapters') expect(classifyH5Request(request, new URL(request.url))).toBe('local-trusted') expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, explicitAuthRequired: false })).toBe(false) }) + test('does not trust loopback adapter requests from non-local browser origins', () => { + const request = req('http://127.0.0.1:3456/api/adapters', { + headers: { Origin: 'https://blocked.example.com' }, + }) + expect(classifyH5Request(request, new URL(request.url))).toBe('h5-browser') + expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, explicitAuthRequired: false })).toBe(true) + }) + + test('keeps local desktop chat websocket routes tokenless', () => { + for (const init of [{}, { headers: { Origin: 'http://tauri.localhost' } }]) { + const request = req('http://127.0.0.1:3456/ws/session-1', init) + expect(classifyH5Request(request, new URL(request.url))).toBe('local-trusted') + expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, explicitAuthRequired: false })).toBe(false) + } + }) + test('requires H5 token for LAN browser API, proxy, and chat websocket routes when enabled', () => { for (const pathname of ['/api/status', '/proxy/openai/v1/chat/completions', '/ws/session-1']) { const request = req(`http://192.168.0.20:3456${pathname}`, { diff --git a/src/server/h5AccessPolicy.ts b/src/server/h5AccessPolicy.ts index 4c077030..d14c65ea 100644 --- a/src/server/h5AccessPolicy.ts +++ b/src/server/h5AccessPolicy.ts @@ -27,11 +27,13 @@ function isLocalOrigin(origin: string | null): boolean { } export function classifyH5Request(request: Request, url: URL): H5RequestKind { - if (url.pathname.startsWith('/sdk/')) { + const localTrusted = isLoopbackHost(url.hostname) && isLocalOrigin(request.headers.get('Origin')) + + if (url.pathname.startsWith('/sdk/') && localTrusted) { return 'internal-sdk' } - if (isLoopbackHost(url.hostname) && isLocalOrigin(request.headers.get('Origin'))) { + if (localTrusted) { return 'local-trusted' }