Keep H5 control local while tightening remote CORS

Remote browser access now needs the H5 token once LAN mode is enabled, but changing that mode remains a local desktop control-plane operation even when deployment auth is explicitly enabled. CORS also no longer treats a non-local same-origin alias as trusted unless it is the configured H5 public origin or allowlisted origin.

Constraint: H5 exposes desktop control surfaces on the LAN and must not let remote browsers enable their own access.

Constraint: Desktop WebView, localhost WebUI, SDK websocket, and IM adapter loopback calls must remain tokenless.

Rejected: Allow authenticated remote control-plane writes under SERVER_AUTH_REQUIRED | violates the local opt-in boundary for H5 exposure.

Confidence: high

Scope-risk: moderate

Directive: Do not relax /api/h5-access mutating endpoints for browser origins without adding a reviewed desktop-only trust signal.

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

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

Tested: bun run check:server
This commit is contained in:
程序员阿江(Relakkes) 2026-05-12 14:52:02 +08:00
parent e2f299dcb3
commit 4ef9729ad6
4 changed files with 178 additions and 30 deletions

View File

@ -18,6 +18,7 @@ let originalH5DistDir: string | undefined
let originalClaudeAppRoot: string | undefined
let originalServerAuthRequired: string | undefined
let originalServerPort = 3456
const PHONE_ORIGIN = 'https://phone.example'
async function waitForServer(url: string): Promise<void> {
for (let attempt = 0; attempt < 50; attempt += 1) {
@ -90,14 +91,21 @@ function makeUpgradeHeaders(origin?: string): HeadersInit {
async function enableH5Access(options: {
allowedOrigins?: string[]
publicBaseUrl?: string | null
} = {}): Promise<string> {
const service = new H5AccessService()
if (options.allowedOrigins) {
await service.updateSettings({ allowedOrigins: options.allowedOrigins })
if (options.allowedOrigins || options.publicBaseUrl !== undefined) {
await service.updateSettings({
allowedOrigins: options.allowedOrigins,
publicBaseUrl: options.publicBaseUrl,
})
}
const { token } = await service.enable()
if (options.allowedOrigins) {
await service.updateSettings({ allowedOrigins: options.allowedOrigins })
if (options.allowedOrigins || options.publicBaseUrl !== undefined) {
await service.updateSettings({
allowedOrigins: options.allowedOrigins,
publicBaseUrl: options.publicBaseUrl,
})
}
return token
}
@ -276,19 +284,78 @@ describe('remote H5 auth and CORS integration', () => {
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(`${requestBaseUrl}/api/status`, {
test('blocks remote browsers from enabling H5 access before the local desktop opts in', async () => {
const response = await fetch(`${baseUrl}/api/h5-access/enable`, {
method: 'POST',
headers: {
Origin: requestBaseUrl,
Origin: PHONE_ORIGIN,
},
})
expect(response.status).toBe(403)
await expect(response.json()).resolves.toMatchObject({
error: 'Forbidden',
})
})
test('blocks remote preflight requests to the local H5 access control plane', async () => {
const response = await fetch(`${baseUrl}/api/h5-access/enable`, {
method: 'OPTIONS',
headers: {
Origin: PHONE_ORIGIN,
'Access-Control-Request-Method': 'POST',
},
})
expect(response.status).toBe(403)
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull()
})
test('blocks authenticated remote browsers from changing H5 access settings under explicit server auth', async () => {
await restartRemoteServer({ authRequired: true })
process.env.ANTHROPIC_API_KEY = 'test-server-key'
const response = await fetch(`${baseUrl}/api/h5-access/enable`, {
method: 'POST',
headers: {
Origin: PHONE_ORIGIN,
Authorization: 'Bearer test-server-key',
},
})
expect(response.status).toBe(403)
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull()
})
test('allows local desktop H5 access settings under explicit server auth with a valid bearer', async () => {
await restartRemoteServer({ authRequired: true })
process.env.ANTHROPIC_API_KEY = 'test-server-key'
const response = await fetch(`${baseUrl}/api/h5-access/enable`, {
method: 'POST',
headers: {
Authorization: 'Bearer test-server-key',
},
})
expect(response.status).toBe(200)
await expect(response.json()).resolves.toHaveProperty('token')
})
test('allows H5 browser requests from the configured public base URL origin', async () => {
const token = await enableH5Access({
publicBaseUrl: `${PHONE_ORIGIN}/h5`,
})
const response = await fetch(`${baseUrl}/api/status`, {
headers: {
Origin: PHONE_ORIGIN,
Authorization: `Bearer ${token}`,
},
})
expect(response.status).toBe(200)
expect(response.headers.get('Access-Control-Allow-Origin')).toBe(requestBaseUrl)
expect(response.headers.get('Access-Control-Allow-Origin')).toBe(PHONE_ORIGIN)
})
test('allows configured CORS origins and includes Vary: Origin', async () => {
@ -316,21 +383,21 @@ 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()
test('requires H5 token for remote browser REST requests when H5 access is enabled', async () => {
const token = await enableH5Access({
allowedOrigins: [PHONE_ORIGIN],
})
expect(lanBaseUrl).toBeTruthy()
const missingTokenResponse = await fetch(`${lanBaseUrl}/api/status`, {
const missingTokenResponse = await fetch(`${baseUrl}/api/status`, {
headers: {
Origin: lanBaseUrl,
Origin: PHONE_ORIGIN,
},
})
expect(missingTokenResponse.status).toBe(401)
const validTokenResponse = await fetch(`${lanBaseUrl}/api/status`, {
const validTokenResponse = await fetch(`${baseUrl}/api/status`, {
headers: {
Origin: lanBaseUrl,
Origin: PHONE_ORIGIN,
Authorization: `Bearer ${token}`,
},
})
@ -354,20 +421,37 @@ describe('remote H5 auth and CORS integration', () => {
const response = await fetch(`${baseUrl}/api/adapters`)
expect(response.status).not.toBe(401)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({})
})
test('requires H5 token for LAN websocket requests when H5 access is enabled', async () => {
const token = await enableH5Access()
test('blocks adapter requests from non-local browser origins when H5 access is enabled', async () => {
await enableH5Access()
expect(lanBaseUrl).toBeTruthy()
const response = await fetch(`${baseUrl}/api/adapters`, {
headers: {
Origin: PHONE_ORIGIN,
},
})
const missingTokenResponse = await fetch(`${lanBaseUrl}/ws/h5-auth-test`, {
headers: makeUpgradeHeaders(lanBaseUrl),
expect(response.status).toBe(403)
})
test('requires H5 token for remote browser websocket requests when H5 access is enabled', async () => {
const token = await enableH5Access({
allowedOrigins: [PHONE_ORIGIN],
})
const missingTokenResponse = await fetch(`${baseUrl}/ws/h5-auth-test`, {
headers: makeUpgradeHeaders(PHONE_ORIGIN),
})
expect(missingTokenResponse.status).toBe(401)
await expectWebSocketOpen(`${lanWsBaseUrl}/ws/h5-auth-test?token=${token}`)
const validTokenResponse = await fetch(`${baseUrl}/ws/h5-auth-test?token=${token}`, {
headers: makeUpgradeHeaders(PHONE_ORIGIN),
})
expect(validTokenResponse.status).toBe(400)
await expect(validTokenResponse.text()).resolves.toBe('WebSocket upgrade failed')
})
test('honors explicit auth opt-in for REST and websocket requests', async () => {

View File

@ -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 { shouldRequireH5Token } from './h5AccessPolicy.js'
import { classifyH5Request, shouldRequireH5Token } from './h5AccessPolicy.js'
import { H5AccessService } from './services/h5AccessService.js'
function readArgValue(flag: string): string | undefined {
@ -70,6 +70,40 @@ function corsRejectedResponse(cors: CorsResolution): Response {
)
}
function h5AccessControlRejectedResponse(): Response {
return Response.json(
{
error: 'Forbidden',
message: 'H5 access settings can only be changed from the local desktop app.',
},
{ status: 403 },
)
}
function isH5AccessControlRequest(req: Request, url: URL): boolean {
if (!url.pathname.startsWith('/api/h5-access')) {
return false
}
if (url.pathname === '/api/h5-access/verify') {
return false
}
return classifyH5Request(req, url) !== 'local-trusted'
}
function originFromUrl(value: string | null): string | null {
if (!value) {
return null
}
try {
return new URL(value).origin
} catch {
return null
}
}
export function startServer(port = PORT, host = HOST) {
enableConfigs()
diagnosticsService.installConsoleCapture()
@ -99,9 +133,12 @@ export function startServer(port = PORT, host = HOST) {
const url = new URL(req.url)
const origin = req.headers.get('Origin')
const h5Settings = await h5AccessService.getSettings()
const h5PublicOrigin = originFromUrl(h5Settings.publicBaseUrl)
const cors = await resolveCors(origin, url.origin, {
h5Enabled: h5Settings.enabled,
isOriginAllowed: (candidateOrigin) => h5AccessService.isOriginAllowed(candidateOrigin),
isOriginAllowed: async (candidateOrigin) =>
candidateOrigin === h5PublicOrigin ||
await h5AccessService.isOriginAllowed(candidateOrigin),
})
const authRequired = shouldRequireH5Token({
request: req,
@ -109,6 +146,11 @@ export function startServer(port = PORT, host = HOST) {
h5Enabled: h5Settings.enabled,
explicitAuthRequired: forceAuth,
})
const h5AccessControlBlocked = isH5AccessControlRequest(req, url)
if (h5AccessControlBlocked) {
return h5AccessControlRejectedResponse()
}
// Handle CORS preflight
if (req.method === 'OPTIONS') {

View File

@ -72,4 +72,26 @@ describe('resolveCors', () => {
},
})
})
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,
isOriginAllowed: async () => false,
})
expect(result.allowed).toBe(false)
expect(result.rejected).toBe(true)
expect(result.headers['Access-Control-Allow-Origin']).toBeUndefined()
})
it('allows same-origin H5 browser requests only through the configured origin callback', async () => {
const result = await resolveCors('http://192.168.0.20:3456', 'http://192.168.0.20:3456', {
h5Enabled: true,
isOriginAllowed: async (origin) => origin === 'http://192.168.0.20:3456',
})
expect(result.allowed).toBe(true)
expect(result.rejected).toBe(false)
expect(result.headers['Access-Control-Allow-Origin']).toBe('http://192.168.0.20:3456')
})
})

View File

@ -59,7 +59,7 @@ function isLocalOrigin(origin?: string | null): boolean {
export async function resolveCors(
origin?: string | null,
requestOrigin?: string | null,
_requestOrigin?: string | null,
options: CorsResolutionOptions = {},
): Promise<CorsResolution> {
if (!origin) {
@ -70,7 +70,7 @@ export async function resolveCors(
}
}
if (!options.h5Enabled || isLocalOrigin(origin) || origin === requestOrigin) {
if (!options.h5Enabled || isLocalOrigin(origin)) {
return {
allowed: true,
rejected: false,