Protect remote H5 capability endpoints without breaking local desktop flows

Wire request-scoped H5 auth and CORS decisions through the server so LAN browser API, proxy, and websocket access require the H5 token while loopback desktop, Tauri WebView, local adapter, SDK, and static shell paths keep their existing bootstrap behavior.

Constraint: Task 2 scope is limited to server auth/CORS wiring and the four requested files
Rejected: Global auth toggle for all requests | would incorrectly block loopback desktop and bootstrap asset flows
Directive: Keep static H5 shell and assets tokenless unless the browser-side bootstrap model changes
Confidence: high
Scope-risk: narrow
Tested: bun test src/server/__tests__/h5-access-policy.test.ts src/server/middleware/cors.test.ts
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 | h5-access-auth blocked by missing zod dependency in src/server/api/haha-oauth.ts
Tested: LSP diagnostics clean for src/server/index.ts, src/server/middleware/cors.ts, src/server/__tests__/h5-access-auth.test.ts, src/server/middleware/cors.test.ts
Not-tested: h5-access-auth runtime assertions because this checkout is missing zod for haha-oauth imports
This commit is contained in:
程序员阿江(Relakkes) 2026-05-12 14:33:58 +08:00
parent a5cc7ff0c1
commit e2f299dcb3
4 changed files with 215 additions and 21 deletions

View File

@ -9,6 +9,8 @@ import { ProviderService } from '../services/providerService.js'
let server: ReturnType<typeof Bun.serve> | 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<void> {
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()

View File

@ -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<WebSocketData>({
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

View File

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

View File

@ -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<string, string> {
const allowedOrigin = origin || 'http://localhost:3000'
return {
@ -28,9 +30,37 @@ export type CorsResolution = {
headers: Record<string, string>
}
export type CorsResolutionOptions = {
h5Enabled?: boolean
isOriginAllowed?: (origin: string) => Promise<boolean>
}
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<CorsResolution> {
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(),
}
}