From 37bdc4e6e7f61957bdd0a4f1d13eeeb3a7ee575e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 9 May 2026 23:43:29 +0800 Subject: [PATCH] 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 --- src/server/__tests__/h5-access-auth.test.ts | 202 ++++++++++++++++++++ src/server/index.ts | 106 +++++----- src/server/middleware/auth.ts | 43 ++++- src/server/middleware/cors.ts | 54 ++++++ 4 files changed, 354 insertions(+), 51 deletions(-) create mode 100644 src/server/__tests__/h5-access-auth.test.ts diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts new file mode 100644 index 00000000..282eb424 --- /dev/null +++ b/src/server/__tests__/h5-access-auth.test.ts @@ -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 | undefined +let baseUrl = '' +let wsBaseUrl = '' +let tmpDir = '' +let originalConfigDir: string | undefined +let originalAnthropicApiKey: string | undefined +let originalServerPort = 3456 + +async function waitForServer(url: string): Promise { + 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 { + 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 { + 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 { + 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}`) + }) +}) diff --git a/src/server/index.ts b/src/server/index.ts index 45e1911e..e9a4a192 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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({ 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 }, ) } diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index 970dd116..25132a35 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -5,9 +5,11 @@ * 验证请求头中的 Authorization: Bearer 与 .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 ' } } + 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 { + 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 { + const { valid, error } = await validateRequestAuth(req, tokenOverride) if (!valid) { return Response.json({ error: 'Unauthorized', message: error }, { status: 401 }) } diff --git a/src/server/middleware/cors.ts b/src/server/middleware/cors.ts index 1d3a2bea..8b56a4a0 100644 --- a/src/server/middleware/cors.ts +++ b/src/server/middleware/cors.ts @@ -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 { '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 { + 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 +} + +export async function resolveCors(origin?: string | null): Promise { + 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(), } }