Enforce H5 opt-in before exposing LAN capabilities

The desktop sidecar can bind on LAN addresses for phone access, so the H5 settings switch must be an authorization boundary for remote capability routes, not only a token-mode toggle. Remote browser API, proxy, websocket, and SDK routes now fail closed while H5 is disabled; local desktop, Tauri, WebUI, adapter, and internal SDK paths remain tokenless. When H5 is enabled, remote API, proxy, and websocket requests must use the H5 token carried by the QR link, and the server API key cannot substitute for that H5 token.

Constraint: Desktop sidecar binds 0.0.0.0 while reporting loopback to local UI.

Constraint: Client-controlled Host and Origin headers cannot prove a local request; the boundary uses Bun requestIP instead.

Constraint: Static H5 shell and /health must still load so browser bootstrap can show a recovery flow.

Rejected: Trust loopback Host headers | LAN clients can spoof Host and Origin.

Rejected: Use ANTHROPIC_API_KEY as a remote H5 credential | it is not the phone pairing token and would weaken the QR-token boundary.

Confidence: high

Scope-risk: moderate

Directive: Do not make h5Enabled=false an open remote state for /api, /proxy, /ws, or /sdk routes.

Tested: bun test src/server/__tests__/h5-access-policy.test.ts src/server/__tests__/h5-access-auth.test.ts src/server/middleware/cors.test.ts

Tested: bun run check:server
This commit is contained in:
程序员阿江(Relakkes) 2026-05-12 16:25:54 +08:00
parent 20c32ff1cf
commit 8e184c1edd
6 changed files with 553 additions and 31 deletions

View File

@ -89,6 +89,13 @@ function makeUpgradeHeaders(origin?: string): HeadersInit {
}
}
function spoofedLoopbackHeaders(port: string): Record<string, string> {
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<void> {
})
}
function expectWebSocketUpgradeThenClose(url: string): Promise<void> {
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()

View File

@ -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)
})
})

View File

@ -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/')
}

View File

@ -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)

View File

@ -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<Response | null> {
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
}

View File

@ -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,