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' +}