From 115e6f3b36bc68436fcc9c278637eab5429fbe61 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:20:32 +0800 Subject: [PATCH] Prepare H5 auth to distinguish browser traffic from trusted local callers Add a small standalone request classifier and focused server tests so later H5 token enforcement can target LAN/browser requests without changing the current desktop, SDK, or adapter paths. Constraint: Task scope is limited to a standalone classifier plus tests in two server files Constraint: Local desktop WebView, loopback integrations, and /sdk routes must remain tokenless for now Rejected: Integrate the classifier into auth middleware now | this task only introduces the reusable classification seam Confidence: high Scope-risk: narrow Directive: Keep local-trusted and internal-sdk classifications tokenless unless the server auth integration changes with matching regression coverage Tested: bun test src/server/__tests__/h5-access-policy.test.ts Tested: per-file TypeScript diagnostics for src/server/h5AccessPolicy.ts and src/server/__tests__/h5-access-policy.test.ts Not-tested: Full server auth middleware integration Not-tested: bun run check:server remains red from pre-existing repo dependency and unrelated test failures --- src/server/__tests__/h5-access-policy.test.ts | 54 ++++++++++++++++ src/server/h5AccessPolicy.ts | 61 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/server/__tests__/h5-access-policy.test.ts create mode 100644 src/server/h5AccessPolicy.ts diff --git a/src/server/__tests__/h5-access-policy.test.ts b/src/server/__tests__/h5-access-policy.test.ts new file mode 100644 index 00000000..a8c88e83 --- /dev/null +++ b/src/server/__tests__/h5-access-policy.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'bun:test' +import { + classifyH5Request, + isLoopbackHost, + shouldRequireH5Token, +} from '../h5AccessPolicy.js' + +function req(url: string, init: RequestInit = {}) { + return new Request(url, init) +} + +describe('h5AccessPolicy', () => { + test('recognizes loopback hosts as local trusted requests', () => { + expect(isLoopbackHost('localhost')).toBe(true) + expect(isLoopbackHost('127.0.0.1')).toBe(true) + expect(isLoopbackHost('[::1]')).toBe(true) + expect(isLoopbackHost('192.168.0.20')).toBe(false) + }) + + test('keeps Tauri WebView requests to loopback tokenless', () => { + const request = req('http://127.0.0.1:3456/api/status', { + headers: { Origin: 'http://tauri.localhost' }, + }) + 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('keeps internal SDK websocket routes tokenless', () => { + const request = req('http://192.168.0.20: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('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('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}`, { + headers: { Origin: 'http://192.168.0.20:3456' }, + }) + 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('explicit deployment auth still requires auth even when H5 is disabled', () => { + const request = req('http://127.0.0.1:3456/api/status') + expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: false, explicitAuthRequired: true })).toBe(true) + }) +}) diff --git a/src/server/h5AccessPolicy.ts b/src/server/h5AccessPolicy.ts new file mode 100644 index 00000000..4c077030 --- /dev/null +++ b/src/server/h5AccessPolicy.ts @@ -0,0 +1,61 @@ +export type H5RequestKind = 'local-trusted' | 'internal-sdk' | 'h5-browser' + +const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1']) +const LOCAL_ORIGINS = new Set([ + 'http://tauri.localhost', + 'https://tauri.localhost', + 'tauri://localhost', +]) + +export function normalizeHostname(hostname: string): string { + return hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase() +} + +export function isLoopbackHost(hostname: string): boolean { + return LOCAL_HOSTS.has(normalizeHostname(hostname)) +} + +function isLocalOrigin(origin: string | null): boolean { + if (!origin) return true + if (LOCAL_ORIGINS.has(origin)) return true + + try { + return isLoopbackHost(new URL(origin).hostname) + } catch { + return false + } +} + +export function classifyH5Request(request: Request, url: URL): H5RequestKind { + if (url.pathname.startsWith('/sdk/')) { + return 'internal-sdk' + } + + if (isLoopbackHost(url.hostname) && isLocalOrigin(request.headers.get('Origin'))) { + return 'local-trusted' + } + + return 'h5-browser' +} + +export function shouldRequireH5Token({ + request, + url, + h5Enabled, + explicitAuthRequired, +}: { + request: Request + url: URL + h5Enabled: boolean + explicitAuthRequired: boolean +}): boolean { + if (explicitAuthRequired) { + return true + } + + if (!h5Enabled) { + return false + } + + return classifyH5Request(request, url) === 'h5-browser' +}