cc-haha/src/server/h5AccessPolicy.ts
程序员阿江(Relakkes) 8e184c1edd 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
2026-05-12 16:25:54 +08:00

114 lines
2.5 KiB
TypeScript

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([
'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 {
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 {
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,
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'
}
if (localTrusted) {
return 'local-trusted'
}
return 'h5-browser'
}
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 (h5Enabled || explicitAuthRequired) {
return false
}
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/')
}