diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts index f007bfcf..b4917101 100644 --- a/src/server/__tests__/h5-access-auth.test.ts +++ b/src/server/__tests__/h5-access-auth.test.ts @@ -9,6 +9,8 @@ import { ProviderService } from '../services/providerService.js' let server: ReturnType | undefined let baseUrl = '' let wsBaseUrl = '' +let lanBaseUrl = '' +let lanWsBaseUrl = '' let tmpDir = '' let originalConfigDir: string | undefined let originalAnthropicApiKey: string | undefined @@ -36,6 +38,26 @@ function randomPort(): number { return 18000 + Math.floor(Math.random() * 10000) } +function resolvePrivateLanBaseUrl(port: number): string | null { + for (const entries of Object.values(os.networkInterfaces())) { + for (const entry of entries ?? []) { + if (entry.family !== 'IPv4' || entry.internal) { + continue + } + + if ( + entry.address.startsWith('10.') || + entry.address.startsWith('192.168.') || + /^172\.(1[6-9]|2\d|3[0-1])\./.test(entry.address) + ) { + return `http://${entry.address}:${port}` + } + } + } + + return null +} + async function startRemoteServer(options: { authRequired?: boolean } = {}): Promise { if (options.authRequired) { process.env.SERVER_AUTH_REQUIRED = '1' @@ -47,6 +69,8 @@ async function startRemoteServer(options: { authRequired?: boolean } = {}): Prom server = startServer(port, '0.0.0.0') baseUrl = `http://127.0.0.1:${port}` wsBaseUrl = `ws://127.0.0.1:${port}` + lanBaseUrl = resolvePrivateLanBaseUrl(port) ?? '' + lanWsBaseUrl = lanBaseUrl.replace(/^http/, 'ws') await waitForServer(`${baseUrl}/health`) } @@ -236,8 +260,8 @@ describe('remote H5 auth and CORS integration', () => { }) }) - test('allows arbitrary CORS origins while default H5 access is open', async () => { - const token = await enableH5Access({ + test('rejects arbitrary CORS origins when H5 access is enabled', async () => { + await enableH5Access({ allowedOrigins: ['https://allowed.example.com'], }) @@ -245,27 +269,26 @@ describe('remote H5 auth and CORS integration', () => { method: 'OPTIONS', headers: { ...makeUpgradeHeaders('https://blocked.example.com'), - Authorization: `Bearer ${token}`, 'Access-Control-Request-Method': 'GET', }, }) - expect(response.status).toBe(204) - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://blocked.example.com') + expect(response.status).toBe(403) }) test('allows same-origin H5 browser requests without a separate origin allowlist entry', async () => { const token = await enableH5Access() + const requestBaseUrl = lanBaseUrl || baseUrl - const response = await fetch(`${baseUrl}/api/status`, { + const response = await fetch(`${requestBaseUrl}/api/status`, { headers: { - Origin: baseUrl, + Origin: requestBaseUrl, Authorization: `Bearer ${token}`, }, }) expect(response.status).toBe(200) - expect(response.headers.get('Access-Control-Allow-Origin')).toBe(baseUrl) + expect(response.headers.get('Access-Control-Allow-Origin')).toBe(requestBaseUrl) }) test('allows configured CORS origins and includes Vary: Origin', async () => { @@ -293,6 +316,60 @@ describe('remote H5 auth and CORS integration', () => { await expectWebSocketOpen(`${wsBaseUrl}/ws/h5-auth-test`) }) + test('requires H5 token for LAN REST requests when H5 access is enabled', async () => { + const token = await enableH5Access() + + expect(lanBaseUrl).toBeTruthy() + + const missingTokenResponse = await fetch(`${lanBaseUrl}/api/status`, { + headers: { + Origin: lanBaseUrl, + }, + }) + expect(missingTokenResponse.status).toBe(401) + + const validTokenResponse = await fetch(`${lanBaseUrl}/api/status`, { + headers: { + Origin: lanBaseUrl, + Authorization: `Bearer ${token}`, + }, + }) + expect(validTokenResponse.status).toBe(200) + }) + + test('keeps Tauri loopback REST requests tokenless when H5 access is enabled', async () => { + await enableH5Access() + + const response = await fetch(`${baseUrl}/api/status`, { + headers: { + Origin: 'http://tauri.localhost', + }, + }) + + expect(response.status).toBe(200) + }) + + test('keeps local loopback adapter requests tokenless when H5 access is enabled', async () => { + await enableH5Access() + + const response = await fetch(`${baseUrl}/api/adapters`) + + expect(response.status).not.toBe(401) + }) + + test('requires H5 token for LAN websocket requests when H5 access is enabled', async () => { + const token = await enableH5Access() + + expect(lanBaseUrl).toBeTruthy() + + const missingTokenResponse = await fetch(`${lanBaseUrl}/ws/h5-auth-test`, { + headers: makeUpgradeHeaders(lanBaseUrl), + }) + expect(missingTokenResponse.status).toBe(401) + + await expectWebSocketOpen(`${lanWsBaseUrl}/ws/h5-auth-test?token=${token}`) + }) + test('honors explicit auth opt-in for REST and websocket requests', async () => { await restartRemoteServer({ authRequired: true }) const token = await enableH5Access() diff --git a/src/server/index.ts b/src/server/index.ts index dde08c83..e9fec7a4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -20,6 +20,8 @@ import { enableConfigs } from '../utils/config.js' import { diagnosticsService } from './services/diagnosticsService.js' import { ensurePersistentStorageUpgraded } from './services/persistentStorageMigrations.js' import { handleStaticH5Request } from './staticH5.js' +import { shouldRequireH5Token } from './h5AccessPolicy.js' +import { H5AccessService } from './services/h5AccessService.js' function readArgValue(flag: string): string | undefined { const args = process.argv.slice(2) @@ -79,13 +81,13 @@ export function startServer(port = PORT, host = HOST) { : host /** - * Auth is opt-in only. H5/LAN access is currently left open by default so - * desktop and browser clients do not get blocked by missing token state. - * Explicit opt-in remains available for private deployments. + * Explicit deployment auth remains a stronger override than H5-scoped + * request gating. */ const forceAuth = SERVER_OPTIONS.authRequired || process.env.SERVER_AUTH_REQUIRED === '1' + const h5AccessService = new H5AccessService() const server = Bun.serve({ port, @@ -96,8 +98,17 @@ export function startServer(port = PORT, host = HOST) { await ensurePersistentStorageUpgraded() const url = new URL(req.url) const origin = req.headers.get('Origin') - const cors = await resolveCors(origin, url.origin) - const authRequired = forceAuth + const h5Settings = await h5AccessService.getSettings() + const cors = await resolveCors(origin, url.origin, { + h5Enabled: h5Settings.enabled, + isOriginAllowed: (candidateOrigin) => h5AccessService.isOriginAllowed(candidateOrigin), + }) + const authRequired = shouldRequireH5Token({ + request: req, + url, + h5Enabled: h5Settings.enabled, + explicitAuthRequired: forceAuth, + }) // Handle CORS preflight if (req.method === 'OPTIONS') { @@ -242,6 +253,8 @@ export function startServer(port = PORT, host = HOST) { ) } + // Static H5 shell/assets are non-secret bootstrap content and must load + // before the browser can read the QR token; API/proxy/ws stay protected above. const staticResponse = await handleStaticH5Request(req, url) if (staticResponse) { return staticResponse diff --git a/src/server/middleware/cors.test.ts b/src/server/middleware/cors.test.ts index f5757b1d..a3499e07 100644 --- a/src/server/middleware/cors.test.ts +++ b/src/server/middleware/cors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test' -import { corsHeaders } from './cors' +import { corsHeaders, resolveCors } from './cors' describe('corsHeaders', () => { it('allows localhost browser origins', () => { @@ -18,3 +18,58 @@ describe('corsHeaders', () => { expect(corsHeaders(null)['Access-Control-Allow-Origin']).toBe('http://localhost:3000') }) }) + +describe('resolveCors', () => { + it('allows arbitrary origins when H5 token mode is inactive', async () => { + const result = await resolveCors('https://example.com', 'http://127.0.0.1:3456') + + expect(result).toEqual({ + allowed: true, + rejected: false, + headers: { + 'Access-Control-Allow-Origin': 'https://example.com', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', + Vary: 'Origin', + }, + }) + }) + + it('rejects blocked browser origins when H5 token mode is active', async () => { + const result = await resolveCors('https://blocked.example.com', 'http://192.168.0.20:3456', { + h5Enabled: true, + isOriginAllowed: async () => false, + }) + + expect(result).toEqual({ + allowed: false, + rejected: true, + headers: { + 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', + Vary: 'Origin', + }, + }) + }) + + it('allows configured origins when H5 token mode is active', async () => { + const result = await resolveCors('https://allowed.example.com', 'http://192.168.0.20:3456', { + h5Enabled: true, + isOriginAllowed: async (origin) => origin === 'https://allowed.example.com', + }) + + expect(result).toEqual({ + allowed: true, + rejected: false, + headers: { + 'Access-Control-Allow-Origin': 'https://allowed.example.com', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', + Vary: 'Origin', + }, + }) + }) +}) diff --git a/src/server/middleware/cors.ts b/src/server/middleware/cors.ts index e6ef0691..5fdecf5c 100644 --- a/src/server/middleware/cors.ts +++ b/src/server/middleware/cors.ts @@ -2,6 +2,8 @@ * CORS middleware for desktop and temporary open H5 access. */ +import { isLoopbackHost } from '../h5AccessPolicy.js' + export function corsHeaders(origin?: string | null): Record { const allowedOrigin = origin || 'http://localhost:3000' return { @@ -28,9 +30,37 @@ export type CorsResolution = { headers: Record } +export type CorsResolutionOptions = { + h5Enabled?: boolean + isOriginAllowed?: (origin: string) => Promise +} + +const LOCAL_ORIGINS = new Set([ + 'http://tauri.localhost', + 'https://tauri.localhost', + 'tauri://localhost', +]) + +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 async function resolveCors( origin?: string | null, - _requestOrigin?: string | null, + requestOrigin?: string | null, + options: CorsResolutionOptions = {}, ): Promise { if (!origin) { return { @@ -40,12 +70,31 @@ export async function resolveCors( } } + if (!options.h5Enabled || isLocalOrigin(origin) || origin === requestOrigin) { + return { + allowed: true, + rejected: false, + headers: { + ...baseCorsHeaders(), + 'Access-Control-Allow-Origin': origin, + }, + } + } + + if (options.isOriginAllowed && await options.isOriginAllowed(origin)) { + return { + allowed: true, + rejected: false, + headers: { + ...baseCorsHeaders(), + 'Access-Control-Allow-Origin': origin, + }, + } + } + return { - allowed: true, - rejected: false, - headers: { - ...baseCorsHeaders(), - 'Access-Control-Allow-Origin': origin, - }, + allowed: false, + rejected: true, + headers: baseCorsHeaders(), } }