Protect remote server access with H5-aware auth and CORS

Remote browser access needs an explicit path that preserves localhost desktop behavior while allowing H5 tokens for REST and client websocket upgrades. This keeps the existing synchronous Anthropic auth helper intact, adds async request validation for H5 tokens, and centralizes origin handling so preflight, API, proxy, websocket, and health responses behave consistently.

Constraint: Existing validateAuth(req) must stay synchronous for current tests and callers
Constraint: Default localhost and Tauri desktop behavior must remain unchanged
Rejected: Replacing validateAuth with an async-only API | would break existing sync tests and call sites
Rejected: Allowing wildcard or fallback remote origins | violates the H5 explicit-origin security model
Directive: Restore ProviderService serverPort in server integration tests that call startServer with custom ports to avoid leaking global proxy runtime state
Confidence: high
Scope-risk: moderate
Tested: bun test src/server/__tests__/h5-access-auth.test.ts --timeout 30000
Tested: bun test src/server/__tests__/h5-access-service.test.ts src/server/__tests__/h5-access-api.test.ts src/server/__tests__/settings.test.ts --timeout 30000
Tested: bun test src/server/__tests__/h5-access-auth.test.ts src/server/__tests__/conversation-service.test.ts src/server/__tests__/cron-scheduler-launcher.test.ts --timeout 30000
Tested: bun test src/server/__tests__/e2e/full-flow.test.ts --timeout 30000
Not-tested: Full non-live server gate end-to-end; an attempted broader run was interrupted after uncovering and then fixing a test-side ProviderService port leak
This commit is contained in:
程序员阿江(Relakkes) 2026-05-09 23:43:29 +08:00
parent 3276a19da0
commit 37bdc4e6e7
4 changed files with 354 additions and 51 deletions

View File

@ -0,0 +1,202 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { startServer } from '../index.js'
import { H5AccessService } from '../services/h5AccessService.js'
import { ProviderService } from '../services/providerService.js'
let server: ReturnType<typeof Bun.serve> | undefined
let baseUrl = ''
let wsBaseUrl = ''
let tmpDir = ''
let originalConfigDir: string | undefined
let originalAnthropicApiKey: string | undefined
let originalServerPort = 3456
async function waitForServer(url: string): Promise<void> {
for (let attempt = 0; attempt < 50; attempt += 1) {
try {
const response = await fetch(url)
if (response.ok) {
return
}
} catch {}
await Bun.sleep(50)
}
throw new Error(`Timed out waiting for server at ${url}`)
}
function randomPort(): number {
return 18000 + Math.floor(Math.random() * 10000)
}
async function startRemoteServer(): Promise<void> {
const port = randomPort()
server = startServer(port, '0.0.0.0')
baseUrl = `http://127.0.0.1:${port}`
wsBaseUrl = `ws://127.0.0.1:${port}`
await waitForServer(`${baseUrl}/health`)
}
function makeUpgradeHeaders(origin?: string): HeadersInit {
return {
Connection: 'Upgrade',
Upgrade: 'websocket',
...(origin ? { Origin: origin } : {}),
}
}
async function enableH5Access(options: {
allowedOrigins?: string[]
} = {}): Promise<string> {
const service = new H5AccessService()
if (options.allowedOrigins) {
await service.updateSettings({ allowedOrigins: options.allowedOrigins })
}
const { token } = await service.enable()
if (options.allowedOrigins) {
await service.updateSettings({ allowedOrigins: options.allowedOrigins })
}
return token
}
function expectWebSocketOpen(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url)
const timeout = setTimeout(() => {
ws.close()
reject(new Error(`Timed out opening websocket: ${url}`))
}, 5000)
ws.addEventListener('open', () => {
clearTimeout(timeout)
ws.close()
resolve()
})
ws.addEventListener('error', () => {
clearTimeout(timeout)
reject(new Error(`WebSocket failed to open: ${url}`))
})
})
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-access-auth-test-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY
originalServerPort = ProviderService.getServerPort()
process.env.CLAUDE_CONFIG_DIR = tmpDir
delete process.env.ANTHROPIC_API_KEY
await startRemoteServer()
})
afterEach(async () => {
server?.stop(true)
server = undefined
ProviderService.setServerPort(originalServerPort)
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
if (originalAnthropicApiKey === undefined) delete process.env.ANTHROPIC_API_KEY
else process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey
await fs.rm(tmpDir, { recursive: true, force: true })
})
describe('remote H5 auth and CORS integration', () => {
test('rejects /api/status when H5 is disabled and no Anthropic key exists', async () => {
const response = await fetch(`${baseUrl}/api/status`)
expect(response.status).toBe(401)
await expect(response.json()).resolves.toMatchObject({
error: 'Unauthorized',
})
})
test('rejects /api/status when H5 is enabled and bearer token is wrong', async () => {
await enableH5Access()
const response = await fetch(`${baseUrl}/api/status`, {
headers: {
Authorization: 'Bearer wrong-token',
},
})
expect(response.status).toBe(401)
})
test('allows /api/status when H5 is enabled and bearer token is correct', async () => {
const token = await enableH5Access()
const response = await fetch(`${baseUrl}/api/status`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
status: 'ok',
})
})
test('rejects unlisted CORS origins', async () => {
const token = await enableH5Access({
allowedOrigins: ['https://allowed.example.com'],
})
const response = await fetch(`${baseUrl}/api/status`, {
method: 'OPTIONS',
headers: {
...makeUpgradeHeaders('https://blocked.example.com'),
Authorization: `Bearer ${token}`,
'Access-Control-Request-Method': 'GET',
},
})
expect(response.status).toBe(403)
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull()
})
test('allows configured CORS origins and includes Vary: Origin', async () => {
const token = await enableH5Access({
allowedOrigins: ['https://allowed.example.com'],
})
const response = await fetch(`${baseUrl}/api/status`, {
method: 'OPTIONS',
headers: {
Origin: 'https://allowed.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://allowed.example.com',
)
expect(response.headers.get('Vary')).toBe('Origin')
})
test('rejects websocket upgrade without a valid H5 token and accepts the correct token', async () => {
const token = await enableH5Access()
const missingTokenResponse = await fetch(`${baseUrl}/ws/h5-auth-test`, {
headers: makeUpgradeHeaders(),
})
expect(missingTokenResponse.status).toBe(401)
const wrongTokenResponse = await fetch(`${baseUrl}/ws/h5-auth-test?token=wrong-token`, {
headers: makeUpgradeHeaders(),
})
expect(wrongTokenResponse.status).toBe(401)
await expectWebSocketOpen(`${wsBaseUrl}/ws/h5-auth-test?token=${token}`)
})
})

View File

@ -7,7 +7,7 @@
import { handleApiRequest } from './router.js'
import { handleWebSocket, type WebSocketData } from './ws/handler.js'
import { corsHeaders } from './middleware/cors.js'
import { resolveCors, type CorsResolution } from './middleware/cors.js'
import { requireAuth } from './middleware/auth.js'
import { teamWatcher } from './services/teamWatcher.js'
import { cronScheduler } from './services/cronScheduler.js'
@ -49,6 +49,28 @@ const SERVER_OPTIONS = resolveServerOptions()
const PORT = SERVER_OPTIONS.port
const HOST = SERVER_OPTIONS.host
function isLocalServerHost(host: string): boolean {
return host === '127.0.0.1' || host === 'localhost' || host === '::1'
}
function withCors(response: Response, cors: CorsResolution): Response {
const headers = new Headers(response.headers)
for (const [key, value] of Object.entries(cors.headers)) {
headers.set(key, value)
}
return new Response(response.body, {
status: response.status,
headers,
})
}
function corsRejectedResponse(cors: CorsResolution): Response {
return Response.json(
{ error: 'CORS origin not allowed' },
{ status: 403, headers: cors.headers },
)
}
export function startServer(port = PORT, host = HOST) {
enableConfigs()
diagnosticsService.installConsoleCapture()
@ -68,7 +90,7 @@ export function startServer(port = PORT, host = HOST) {
const authRequired =
SERVER_OPTIONS.authRequired ||
process.env.SERVER_AUTH_REQUIRED === '1' ||
host !== '127.0.0.1'
!isLocalServerHost(host)
const server = Bun.serve<WebSocketData>({
port,
@ -78,25 +100,28 @@ export function startServer(port = PORT, host = HOST) {
async fetch(req, server) {
await ensurePersistentStorageUpgraded()
const url = new URL(req.url)
const origin = req.headers.get('Origin')
const cors = await resolveCors(origin)
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders(origin) })
if (cors.rejected) {
return corsRejectedResponse(cors)
}
return new Response(null, { status: 204, headers: cors.headers })
}
// WebSocket upgrade
if (url.pathname.startsWith('/ws/')) {
if (cors.rejected) {
return corsRejectedResponse(cors)
}
// Enforce authentication when required
if (authRequired) {
const authError = requireAuth(req)
const authError = await requireAuth(req, url.searchParams.get('token'))
if (authError) {
const headers = new Headers(authError.headers)
for (const [key, value] of Object.entries(corsHeaders(origin))) {
headers.set(key, value)
}
return new Response(authError.body, { status: authError.status, headers })
return withCors(authError, cors)
}
}
@ -149,29 +174,21 @@ export function startServer(port = PORT, host = HOST) {
// REST API
if (url.pathname.startsWith('/api/')) {
if (cors.rejected) {
return corsRejectedResponse(cors)
}
// Enforce authentication when required
if (authRequired) {
const authError = requireAuth(req)
const authError = await requireAuth(req)
if (authError) {
const headers = new Headers(authError.headers)
for (const [key, value] of Object.entries(corsHeaders(origin))) {
headers.set(key, value)
}
return new Response(authError.body, { status: authError.status, headers })
return withCors(authError, cors)
}
}
try {
const response = await handleApiRequest(req, url)
// Add CORS headers to all responses
const headers = new Headers(response.headers)
for (const [key, value] of Object.entries(corsHeaders(origin))) {
headers.set(key, value)
}
return new Response(response.body, {
status: response.status,
headers,
})
return withCors(response, cors)
} catch (error) {
void diagnosticsService.recordEvent({
type: 'api_request_failed',
@ -180,35 +197,28 @@ export function startServer(port = PORT, host = HOST) {
details: { path: url.pathname, method: req.method, error },
})
console.error('[Server] API error:', error)
return Response.json(
return withCors(Response.json(
{ error: 'Internal server error' },
{ status: 500, headers: corsHeaders() }
)
{ status: 500 },
), cors)
}
}
// Proxy — protocol-translating reverse proxy for OpenAI-compatible APIs
if (url.pathname.startsWith('/proxy/')) {
if (cors.rejected) {
return corsRejectedResponse(cors)
}
if (authRequired) {
const authError = requireAuth(req)
const authError = await requireAuth(req)
if (authError) {
const headers = new Headers(authError.headers)
for (const [key, value] of Object.entries(corsHeaders(origin))) {
headers.set(key, value)
}
return new Response(authError.body, { status: authError.status, headers })
return withCors(authError, cors)
}
}
try {
const response = await handleProxyRequest(req, url)
const headers = new Headers(response.headers)
for (const [key, value] of Object.entries(corsHeaders(origin))) {
headers.set(key, value)
}
return new Response(response.body, {
status: response.status,
headers,
})
return withCors(response, cors)
} catch (error) {
void diagnosticsService.recordEvent({
type: 'proxy_request_failed',
@ -217,18 +227,22 @@ export function startServer(port = PORT, host = HOST) {
details: { path: url.pathname, method: req.method, error },
})
console.error('[Server] Proxy error:', error)
return Response.json(
return withCors(Response.json(
{ type: 'error', error: { type: 'api_error', message: 'Internal proxy error' } },
{ status: 500, headers: corsHeaders() },
)
{ status: 500 },
), cors)
}
}
// Health check
if (url.pathname === '/health') {
if (cors.rejected) {
return corsRejectedResponse(cors)
}
return Response.json(
{ status: 'ok', timestamp: new Date().toISOString() },
{ headers: corsHeaders(origin) },
{ headers: cors.headers },
)
}

View File

@ -5,9 +5,11 @@
* Authorization: Bearer <key> .env ANTHROPIC_API_KEY
*/
export function validateAuth(req: Request): { valid: boolean; error?: string } {
const authHeader = req.headers.get('Authorization')
import { H5AccessService } from '../services/h5AccessService.js'
type AuthResult = { valid: boolean; error?: string }
function parseBearerToken(authHeader: string | null): AuthResult & { token?: string } {
if (!authHeader) {
return { valid: false, error: 'Missing Authorization header' }
}
@ -18,12 +20,21 @@ export function validateAuth(req: Request): { valid: boolean; error?: string } {
return { valid: false, error: 'Invalid Authorization format. Use: Bearer <token>' }
}
return { valid: true, token }
}
export function validateAuth(req: Request): AuthResult {
const parsedAuth = parseBearerToken(req.headers.get('Authorization'))
if (!parsedAuth.valid || !parsedAuth.token) {
return parsedAuth
}
const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey) {
return { valid: false, error: 'Server ANTHROPIC_API_KEY not configured' }
}
if (token !== apiKey) {
if (parsedAuth.token !== apiKey) {
return { valid: false, error: 'Invalid API key' }
}
@ -33,8 +44,30 @@ export function validateAuth(req: Request): { valid: boolean; error?: string } {
/**
* Helper to check auth and return 401 if invalid
*/
export function requireAuth(req: Request): Response | null {
const { valid, error } = validateAuth(req)
export async function validateRequestAuth(
req: Request,
tokenOverride?: string | null,
): Promise<AuthResult> {
const anthropicAuth = validateAuth(req)
if (anthropicAuth.valid) {
return anthropicAuth
}
const parsedAuth = parseBearerToken(req.headers.get('Authorization'))
const h5Token = tokenOverride ?? parsedAuth.token
if (h5Token) {
const h5AccessService = new H5AccessService()
if (await h5AccessService.validateToken(h5Token)) {
return { valid: true }
}
return { valid: false, error: 'Invalid H5 access token' }
}
return anthropicAuth
}
export async function requireAuth(req: Request, tokenOverride?: string | null): Promise<Response | null> {
const { valid, error } = await validateRequestAuth(req, tokenOverride)
if (!valid) {
return Response.json({ error: 'Unauthorized', message: error }, { status: 401 })
}

View File

@ -2,6 +2,8 @@
* CORS middleware for local desktop app communication
*/
import { H5AccessService } from '../services/h5AccessService.js'
const ALLOWED_ORIGIN_RE =
/^(?:https?:\/\/(?:localhost|127\.0\.0\.1|tauri\.localhost)(?::\d+)?|tauri:\/\/localhost|asset:\/\/localhost)$/
@ -14,5 +16,57 @@ export function corsHeaders(origin?: string | null): Record<string, string> {
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
Vary: 'Origin',
}
}
function baseCorsHeaders(): Record<string, string> {
return {
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
Vary: 'Origin',
}
}
export type CorsResolution = {
allowed: boolean
rejected: boolean
headers: Record<string, string>
}
export async function resolveCors(origin?: string | null): Promise<CorsResolution> {
if (!origin) {
return {
allowed: true,
rejected: false,
headers: corsHeaders(origin),
}
}
if (ALLOWED_ORIGIN_RE.test(origin)) {
return {
allowed: true,
rejected: false,
headers: corsHeaders(origin),
}
}
const h5AccessService = new H5AccessService()
if (await h5AccessService.isOriginAllowed(origin)) {
return {
allowed: true,
rejected: false,
headers: {
...baseCorsHeaders(),
'Access-Control-Allow-Origin': origin,
},
}
}
return {
allowed: false,
rejected: true,
headers: baseCorsHeaders(),
}
}