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(), } }