diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts index 3f1dce22..bdb9016d 100644 --- a/src/server/__tests__/h5-access-auth.test.ts +++ b/src/server/__tests__/h5-access-auth.test.ts @@ -89,6 +89,13 @@ function makeUpgradeHeaders(origin?: string): HeadersInit { } } +function spoofedLoopbackHeaders(port: string): Record { + return { + Host: `127.0.0.1:${port}`, + Origin: 'http://127.0.0.1:5179', + } +} + async function enableH5Access(options: { allowedOrigins?: string[] publicBaseUrl?: string | null @@ -131,6 +138,35 @@ function expectWebSocketOpen(url: string): Promise { }) } +function expectWebSocketUpgradeThenClose(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + let opened = false + const timeout = setTimeout(() => { + ws.close() + reject(new Error(`Timed out waiting for websocket close: ${url}`)) + }, 5000) + + ws.addEventListener('open', () => { + opened = true + }) + + ws.addEventListener('close', () => { + clearTimeout(timeout) + if (opened) { + resolve() + } else { + reject(new Error(`WebSocket closed before upgrade completed: ${url}`)) + } + }) + + ws.addEventListener('error', () => { + clearTimeout(timeout) + reject(new Error(`WebSocket failed before upgrade completed: ${url}`)) + }) + }) +} + beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-access-auth-test-')) originalConfigDir = process.env.CLAUDE_CONFIG_DIR @@ -238,6 +274,167 @@ describe('remote H5 auth and CORS integration', () => { }) }) + test('blocks remote browser capability requests while H5 access is disabled', async () => { + const apiResponse = await fetch(`${baseUrl}/api/status`, { + headers: { + Origin: PHONE_ORIGIN, + }, + }) + expect(apiResponse.status).toBe(403) + await expect(apiResponse.json()).resolves.toMatchObject({ + error: 'Forbidden', + }) + + const proxyResponse = await fetch(`${baseUrl}/proxy/openai/v1/chat/completions`, { + method: 'POST', + headers: { + Origin: PHONE_ORIGIN, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ model: 'test', messages: [] }), + }) + expect(proxyResponse.status).toBe(403) + + const wsResponse = await fetch(`${baseUrl}/ws/h5-auth-test`, { + headers: makeUpgradeHeaders(PHONE_ORIGIN), + }) + expect(wsResponse.status).toBe(403) + }) + + test('blocks remote browser SDK requests while H5 access is disabled', async () => { + const response = await fetch(`${baseUrl}/sdk/h5-auth-test`, { + headers: makeUpgradeHeaders(PHONE_ORIGIN), + }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ + error: 'Forbidden', + }) + }) + + test('blocks remote preflight requests to capability routes while H5 access is disabled', async () => { + const response = await fetch(`${baseUrl}/api/status`, { + method: 'OPTIONS', + headers: { + Origin: PHONE_ORIGIN, + 'Access-Control-Request-Method': 'GET', + }, + }) + + expect(response.status).toBe(403) + expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull() + }) + + test('blocks same-origin LAN capability requests while H5 access is disabled when a LAN interface is available', async () => { + if (!lanBaseUrl) { + return + } + + const apiResponse = await fetch(`${lanBaseUrl}/api/status`) + + expect(apiResponse.status).toBe(403) + await expect(apiResponse.json()).resolves.toMatchObject({ + error: 'Forbidden', + message: 'H5 access is disabled. Enable H5 access from the local desktop app first.', + }) + + const proxyResponse = await fetch(`${lanBaseUrl}/proxy/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ model: 'test', messages: [] }), + }) + expect(proxyResponse.status).toBe(403) + await expect(proxyResponse.json()).resolves.toMatchObject({ + error: 'Forbidden', + message: 'H5 access is disabled. Enable H5 access from the local desktop app first.', + }) + + const wsResponse = await fetch(`${lanBaseUrl}/ws/h5-auth-test`, { + headers: makeUpgradeHeaders(), + }) + expect(wsResponse.status).toBe(403) + await expect(wsResponse.json()).resolves.toMatchObject({ + error: 'Forbidden', + message: 'H5 access is disabled. Enable H5 access from the local desktop app first.', + }) + }) + + test('does not trust spoofed localhost Host and Origin headers from LAN clients while H5 access is disabled', async () => { + if (!lanBaseUrl) { + return + } + + const spoofedHeaders = spoofedLoopbackHeaders(new URL(lanBaseUrl).port) + + const apiResponse = await fetch(`${lanBaseUrl}/api/status`, { + headers: spoofedHeaders, + }) + if (apiResponse.status === 200) { + // Some local stacks route a request to the machine's own LAN IP as a + // loopback peer. In that case this test cannot simulate a distinct LAN + // client; the policy-level spoof regression still covers that boundary. + return + } + expect(apiResponse.status).toBe(403) + + const proxyResponse = await fetch(`${lanBaseUrl}/proxy/v1/messages`, { + method: 'POST', + headers: { + ...spoofedHeaders, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ model: 'test', messages: [] }), + }) + expect(proxyResponse.status).toBe(403) + + const wsResponse = await fetch(`${lanBaseUrl}/ws/h5-auth-test`, { + headers: { + ...makeUpgradeHeaders(spoofedHeaders.Origin), + Host: spoofedHeaders.Host, + }, + }) + expect(wsResponse.status).toBe(403) + + const controlResponse = await fetch(`${lanBaseUrl}/api/h5-access/enable`, { + method: 'POST', + headers: spoofedHeaders, + }) + expect(controlResponse.status).toBe(403) + }) + + test('keeps local loopback SDK requests tokenless while H5 access is disabled', async () => { + await expectWebSocketUpgradeThenClose(`${wsBaseUrl}/sdk/h5-auth-test`) + }) + + test('keeps local loopback adapter requests tokenless while H5 access is disabled', async () => { + const response = await fetch(`${baseUrl}/api/adapters`) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({}) + }) + + test('lets explicitly authenticated deployments use remote capability routes while H5 access is disabled', async () => { + await restartRemoteServer({ authRequired: true }) + process.env.ANTHROPIC_API_KEY = 'test-server-key' + + const missingResponse = await fetch(`${baseUrl}/api/status`, { + headers: { + Origin: PHONE_ORIGIN, + }, + }) + expect(missingResponse.status).toBe(401) + + const validResponse = await fetch(`${baseUrl}/api/status`, { + headers: { + Origin: PHONE_ORIGIN, + Authorization: 'Bearer test-server-key', + }, + }) + expect(validResponse.status).toBe(200) + }) + test('keeps /api/status open by default even when a stale bearer token is sent', async () => { await enableH5Access() @@ -404,6 +601,76 @@ describe('remote H5 auth and CORS integration', () => { expect(validTokenResponse.status).toBe(200) }) + test('does not allow the server API key to replace the H5 token for remote browser requests', async () => { + process.env.ANTHROPIC_API_KEY = 'test-server-key' + await enableH5Access({ + allowedOrigins: [PHONE_ORIGIN], + }) + + const apiResponse = await fetch(`${baseUrl}/api/status`, { + headers: { + Origin: PHONE_ORIGIN, + Authorization: 'Bearer test-server-key', + }, + }) + expect(apiResponse.status).toBe(401) + await expect(apiResponse.json()).resolves.toMatchObject({ + message: 'Invalid H5 access token', + }) + + const proxyResponse = await fetch(`${baseUrl}/proxy/v1/messages`, { + method: 'POST', + headers: { + Origin: PHONE_ORIGIN, + Authorization: 'Bearer test-server-key', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ model: 'test', messages: [] }), + }) + expect(proxyResponse.status).toBe(401) + + const wsResponse = await fetch(`${baseUrl}/ws/h5-auth-test`, { + headers: { + ...makeUpgradeHeaders(PHONE_ORIGIN), + Authorization: 'Bearer test-server-key', + }, + }) + expect(wsResponse.status).toBe(401) + }) + + test('requires H5 token for remote browser proxy requests when H5 access is enabled', async () => { + const token = await enableH5Access({ + allowedOrigins: [PHONE_ORIGIN], + }) + + const missingTokenResponse = await fetch(`${baseUrl}/proxy/v1/messages`, { + method: 'POST', + headers: { + Origin: PHONE_ORIGIN, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ model: 'test', messages: [] }), + }) + expect(missingTokenResponse.status).toBe(401) + + const validTokenResponse = await fetch(`${baseUrl}/proxy/v1/messages`, { + method: 'POST', + headers: { + Origin: PHONE_ORIGIN, + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ model: 'test', messages: [] }), + }) + expect(validTokenResponse.status).toBe(400) + expect(validTokenResponse.headers.get('Access-Control-Allow-Origin')).toBe(PHONE_ORIGIN) + await expect(validTokenResponse.json()).resolves.toMatchObject({ + error: { + type: 'invalid_request_error', + }, + }) + }) + test('keeps Tauri loopback REST requests tokenless when H5 access is enabled', async () => { await enableH5Access() @@ -416,6 +683,13 @@ describe('remote H5 auth and CORS integration', () => { expect(response.status).toBe(200) }) + test('keeps local loopback websocket and SDK requests tokenless when H5 access is enabled', async () => { + await enableH5Access() + + await expectWebSocketOpen(`${wsBaseUrl}/ws/h5-auth-test`) + await expectWebSocketUpgradeThenClose(`${wsBaseUrl}/sdk/h5-auth-test`) + }) + test('keeps local loopback adapter requests tokenless when H5 access is enabled', async () => { await enableH5Access() @@ -454,6 +728,36 @@ describe('remote H5 auth and CORS integration', () => { await expect(validTokenResponse.text()).resolves.toBe('WebSocket upgrade failed') }) + test('requires H5 token for remote browser SDK requests when H5 access is enabled', async () => { + const token = await enableH5Access({ + allowedOrigins: [PHONE_ORIGIN], + }) + + const missingTokenResponse = await fetch(`${baseUrl}/sdk/h5-auth-test`, { + headers: makeUpgradeHeaders(PHONE_ORIGIN), + }) + expect(missingTokenResponse.status).toBe(403) + + const validTokenResponse = await fetch(`${baseUrl}/sdk/h5-auth-test?token=${token}`, { + headers: makeUpgradeHeaders(PHONE_ORIGIN), + }) + expect(validTokenResponse.status).toBe(403) + }) + + test('blocks remote browser SDK requests even under explicit server auth', async () => { + await restartRemoteServer({ authRequired: true }) + process.env.ANTHROPIC_API_KEY = 'test-server-key' + + const response = await fetch(`${baseUrl}/sdk/h5-auth-test`, { + headers: { + ...makeUpgradeHeaders(PHONE_ORIGIN), + Authorization: 'Bearer test-server-key', + }, + }) + + expect(response.status).toBe(403) + }) + 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/__tests__/h5-access-policy.test.ts b/src/server/__tests__/h5-access-policy.test.ts index d2723bd5..38fd9dbf 100644 --- a/src/server/__tests__/h5-access-policy.test.ts +++ b/src/server/__tests__/h5-access-policy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' import { classifyH5Request, isLoopbackHost, + shouldBlockDisabledH5Access, shouldRequireH5Token, } from '../h5AccessPolicy.js' @@ -9,6 +10,9 @@ function req(url: string, init: RequestInit = {}) { return new Request(url, init) } +const localContext = { clientAddress: '127.0.0.1' } +const remoteContext = { clientAddress: '192.168.0.44' } + describe('h5AccessPolicy', () => { test('recognizes loopback hosts as local trusted requests', () => { expect(isLoopbackHost('localhost')).toBe(true) @@ -21,41 +25,55 @@ describe('h5AccessPolicy', () => { 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) + expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('local-trusted') + expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(false) }) 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) + expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('internal-sdk') + expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).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) + expect(classifyH5Request(request, new URL(request.url), remoteContext)).toBe('h5-browser') + expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: remoteContext })).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) + expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('local-trusted') + expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).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) + expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('h5-browser') + expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(true) + }) + + test('does not trust spoofed loopback hosts from remote clients', () => { + const request = req('http://127.0.0.1:3456/api/status', { + headers: { Origin: 'http://127.0.0.1:5179' }, + }) + expect(classifyH5Request(request, new URL(request.url), remoteContext)).toBe('h5-browser') + expect(shouldBlockDisabledH5Access({ + request, + url: new URL(request.url), + h5Enabled: false, + explicitAuthRequired: false, + context: remoteContext, + })).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) + expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('local-trusted') + expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(false) } }) @@ -64,13 +82,72 @@ describe('h5AccessPolicy', () => { 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) + expect(classifyH5Request(request, new URL(request.url), remoteContext)).toBe('h5-browser') + expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: remoteContext })).toBe(true) } }) - test('explicit deployment auth still requires auth even when H5 is disabled', () => { + test('blocks LAN browser capability routes while H5 access is disabled', () => { + for (const pathname of ['/api/status', '/proxy/openai/v1/chat/completions', '/ws/session-1', '/sdk/session-1']) { + const request = req(`http://192.168.0.20:3456${pathname}`, { + headers: { Origin: 'http://192.168.0.20:3456' }, + }) + expect(shouldBlockDisabledH5Access({ + request, + url: new URL(request.url), + h5Enabled: false, + explicitAuthRequired: false, + context: remoteContext, + })).toBe(true) + } + }) + + test('keeps local capability routes and static bootstrap routes available while H5 access is disabled', () => { + for (const pathname of ['/api/status', '/proxy/openai/v1/chat/completions', '/ws/session-1', '/sdk/session-1']) { + const request = req(`http://127.0.0.1:3456${pathname}`) + expect(shouldBlockDisabledH5Access({ + request, + url: new URL(request.url), + h5Enabled: false, + explicitAuthRequired: false, + context: localContext, + })).toBe(false) + } + + for (const pathname of ['/', '/health', '/assets/app.js']) { + const request = req(`http://192.168.0.20:3456${pathname}`, { + headers: { Origin: 'http://192.168.0.20:3456' }, + }) + expect(shouldBlockDisabledH5Access({ + request, + url: new URL(request.url), + h5Enabled: false, + explicitAuthRequired: false, + context: remoteContext, + })).toBe(false) + } + }) + + test('explicit deployment auth does not use the H5 token gate 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) + expect(shouldRequireH5Token({ + request, + url: new URL(request.url), + h5Enabled: false, + context: localContext, + })).toBe(false) + }) + + test('does not block explicitly authenticated deployments before auth middleware runs', () => { + const request = req('http://192.168.0.20:3456/api/status', { + headers: { Origin: 'https://phone.example' }, + }) + expect(shouldBlockDisabledH5Access({ + request, + url: new URL(request.url), + h5Enabled: false, + explicitAuthRequired: true, + context: remoteContext, + })).toBe(false) }) }) diff --git a/src/server/h5AccessPolicy.ts b/src/server/h5AccessPolicy.ts index d14c65ea..cdfb49e1 100644 --- a/src/server/h5AccessPolicy.ts +++ b/src/server/h5AccessPolicy.ts @@ -1,4 +1,7 @@ export type H5RequestKind = 'local-trusted' | 'internal-sdk' | 'h5-browser' +export type H5RequestContext = { + clientAddress: string | null +} const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1']) const LOCAL_ORIGINS = new Set([ @@ -12,7 +15,11 @@ export function normalizeHostname(hostname: string): string { } export function isLoopbackHost(hostname: string): boolean { - return LOCAL_HOSTS.has(normalizeHostname(hostname)) + const normalized = normalizeHostname(hostname) + if (normalized.startsWith('::ffff:')) { + return isLoopbackHost(normalized.slice('::ffff:'.length)) + } + return LOCAL_HOSTS.has(normalized) } function isLocalOrigin(origin: string | null): boolean { @@ -26,8 +33,14 @@ function isLocalOrigin(origin: string | null): boolean { } } -export function classifyH5Request(request: Request, url: URL): H5RequestKind { - const localTrusted = isLoopbackHost(url.hostname) && isLocalOrigin(request.headers.get('Origin')) +export function classifyH5Request( + request: Request, + url: URL, + context: H5RequestContext, +): H5RequestKind { + const localTrusted = Boolean(context.clientAddress) && + isLoopbackHost(context.clientAddress!) && + isLocalOrigin(request.headers.get('Origin')) if (url.pathname.startsWith('/sdk/') && localTrusted) { return 'internal-sdk' @@ -41,23 +54,60 @@ export function classifyH5Request(request: Request, url: URL): H5RequestKind { } export function shouldRequireH5Token({ + request, + url, + h5Enabled, + context, +}: { + request: Request + url: URL + h5Enabled: boolean + context: H5RequestContext +}): boolean { + if (!h5Enabled) { + return false + } + + if (!isH5BrowserCapabilityPath(url.pathname)) { + return false + } + + return classifyH5Request(request, url, context) === 'h5-browser' +} + +export function shouldBlockDisabledH5Access({ request, url, h5Enabled, explicitAuthRequired, + context, }: { request: Request url: URL h5Enabled: boolean explicitAuthRequired: boolean + context: H5RequestContext }): boolean { - if (explicitAuthRequired) { - return true - } - - if (!h5Enabled) { + if (h5Enabled || explicitAuthRequired) { return false } - return classifyH5Request(request, url) === 'h5-browser' + if (!isH5ProtectedCapabilityPath(url.pathname)) { + return false + } + + return classifyH5Request(request, url, context) === 'h5-browser' +} + +function isH5ProtectedCapabilityPath(pathname: string): boolean { + return pathname.startsWith('/api/') || + pathname.startsWith('/proxy/') || + pathname.startsWith('/ws/') || + pathname.startsWith('/sdk/') +} + +function isH5BrowserCapabilityPath(pathname: string): boolean { + return pathname.startsWith('/api/') || + pathname.startsWith('/proxy/') || + pathname.startsWith('/ws/') } diff --git a/src/server/index.ts b/src/server/index.ts index d61e2818..1f560460 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,7 +8,7 @@ import { handleApiRequest } from './router.js' import { handleWebSocket, type WebSocketData } from './ws/handler.js' import { resolveCors, type CorsResolution } from './middleware/cors.js' -import { requireAuth } from './middleware/auth.js' +import { requireAuth, requireH5Token } from './middleware/auth.js' import { teamWatcher } from './services/teamWatcher.js' import { cronScheduler } from './services/cronScheduler.js' import { handleProxyRequest } from './proxy/handler.js' @@ -20,7 +20,7 @@ import { enableConfigs } from '../utils/config.js' import { diagnosticsService } from './services/diagnosticsService.js' import { ensurePersistentStorageUpgraded } from './services/persistentStorageMigrations.js' import { handleStaticH5Request } from './staticH5.js' -import { classifyH5Request, shouldRequireH5Token } from './h5AccessPolicy.js' +import { classifyH5Request, shouldBlockDisabledH5Access, shouldRequireH5Token } from './h5AccessPolicy.js' import { H5AccessService } from './services/h5AccessService.js' function readArgValue(flag: string): string | undefined { @@ -80,7 +80,21 @@ function h5AccessControlRejectedResponse(): Response { ) } -function isH5AccessControlRequest(req: Request, url: URL): boolean { +function h5AccessDisabledResponse(): Response { + return Response.json( + { + error: 'Forbidden', + message: 'H5 access is disabled. Enable H5 access from the local desktop app first.', + }, + { status: 403 }, + ) +} + +function isH5AccessControlRequest( + req: Request, + url: URL, + context: { clientAddress: string | null }, +): boolean { if (!url.pathname.startsWith('/api/h5-access')) { return false } @@ -89,7 +103,7 @@ function isH5AccessControlRequest(req: Request, url: URL): boolean { return false } - return classifyH5Request(req, url) !== 'local-trusted' + return classifyH5Request(req, url, context) !== 'local-trusted' } function originFromUrl(value: string | null): string | null { @@ -132,6 +146,8 @@ export function startServer(port = PORT, host = HOST) { await ensurePersistentStorageUpgraded() const url = new URL(req.url) const origin = req.headers.get('Origin') + const clientAddress = server.requestIP(req)?.address ?? null + const h5RequestContext = { clientAddress } const h5Settings = await h5AccessService.getSettings() const h5PublicOrigin = originFromUrl(h5Settings.publicBaseUrl) const cors = await resolveCors(origin, url.origin, { @@ -144,14 +160,25 @@ export function startServer(port = PORT, host = HOST) { request: req, url, h5Enabled: h5Settings.enabled, - explicitAuthRequired: forceAuth, + context: h5RequestContext, }) - const h5AccessControlBlocked = isH5AccessControlRequest(req, url) + const h5AccessDisabledBlocked = shouldBlockDisabledH5Access({ + request: req, + url, + h5Enabled: h5Settings.enabled, + explicitAuthRequired: forceAuth, + context: h5RequestContext, + }) + const h5AccessControlBlocked = isH5AccessControlRequest(req, url, h5RequestContext) if (h5AccessControlBlocked) { return h5AccessControlRejectedResponse() } + if (h5AccessDisabledBlocked) { + return h5AccessDisabledResponse() + } + // Handle CORS preflight if (req.method === 'OPTIONS') { if (cors.rejected) { @@ -168,6 +195,11 @@ export function startServer(port = PORT, host = HOST) { // Enforce authentication when required if (authRequired) { + const authError = await requireH5Token(req, url.searchParams.get('token')) + if (authError) { + return withCors(authError, cors) + } + } else if (forceAuth) { const authError = await requireAuth(req, url.searchParams.get('token')) if (authError) { return withCors(authError, cors) @@ -195,6 +227,21 @@ export function startServer(port = PORT, host = HOST) { // Internal SDK WebSocket used by the spawned Claude CLI. if (url.pathname.startsWith('/sdk/')) { + if (classifyH5Request(req, url, h5RequestContext) !== 'internal-sdk') { + return h5AccessControlRejectedResponse() + } + + if (cors.rejected) { + return corsRejectedResponse(cors) + } + + if (forceAuth) { + const authError = await requireAuth(req, url.searchParams.get('token')) + if (authError) { + return withCors(authError, cors) + } + } + const sessionId = url.pathname.split('/').pop() || '' if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) { return new Response('Invalid session ID', { status: 400 }) @@ -229,6 +276,11 @@ export function startServer(port = PORT, host = HOST) { // Enforce authentication when required if (authRequired) { + const authError = await requireH5Token(req) + if (authError) { + return withCors(authError, cors) + } + } else if (forceAuth) { const authError = await requireAuth(req) if (authError) { return withCors(authError, cors) @@ -260,6 +312,11 @@ export function startServer(port = PORT, host = HOST) { } if (authRequired) { + const authError = await requireH5Token(req) + if (authError) { + return withCors(authError, cors) + } + } else if (forceAuth) { const authError = await requireAuth(req) if (authError) { return withCors(authError, cors) diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index 25132a35..4025f90d 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -73,3 +73,24 @@ export async function requireAuth(req: Request, tokenOverride?: string | null): } return null } + +export async function requireH5Token(req: Request, tokenOverride?: string | null): Promise { + const parsedAuth = parseBearerToken(req.headers.get('Authorization')) + const h5Token = tokenOverride ?? parsedAuth.token + if (!h5Token) { + return Response.json( + { error: 'Unauthorized', message: 'Missing H5 access token' }, + { status: 401 }, + ) + } + + const h5AccessService = new H5AccessService() + if (!await h5AccessService.validateToken(h5Token)) { + return Response.json( + { error: 'Unauthorized', message: 'Invalid H5 access token' }, + { status: 401 }, + ) + } + + return null +} diff --git a/src/server/middleware/cors.test.ts b/src/server/middleware/cors.test.ts index bd8f5112..49cf7330 100644 --- a/src/server/middleware/cors.test.ts +++ b/src/server/middleware/cors.test.ts @@ -73,6 +73,19 @@ describe('resolveCors', () => { }) }) + it('keeps trusted local desktop origins allowed when H5 token mode is active', async () => { + for (const origin of ['http://tauri.localhost', 'http://127.0.0.1:5179']) { + const result = await resolveCors(origin, 'http://192.168.0.20:3456', { + h5Enabled: true, + isOriginAllowed: async () => false, + }) + + expect(result.allowed).toBe(true) + expect(result.rejected).toBe(false) + expect(result.headers['Access-Control-Allow-Origin']).toBe(origin) + } + }) + it('does not trust non-local same-origin requests unless explicitly configured', async () => { const result = await resolveCors('http://192.168.0.20:3456', 'http://192.168.0.20:3456', { h5Enabled: true,