diff --git a/ccw/src/commands/view.ts b/ccw/src/commands/view.ts index 9e3eec33..7a855db3 100644 --- a/ccw/src/commands/view.ts +++ b/ccw/src/commands/view.ts @@ -17,6 +17,49 @@ interface SwitchWorkspaceResult { error?: string; } +/** + * Safely parse JSON response with content-type validation + * Provides better error messages when response is not JSON (e.g., proxy errors) + */ +async function safeParseJson(response: Response, endpoint: string): Promise { + const contentType = response.headers.get('content-type'); + + // Check if response is JSON + if (!contentType?.includes('application/json')) { + // Get response text for error message (truncated to avoid huge output) + const text = await response.text(); + const preview = text.substring(0, 200); + + // Detect common proxy errors + if (text.includes('APIKEY') || text.includes('api key') || text.includes('apiKey')) { + throw new Error( + `Request to ${endpoint} was intercepted by a proxy requiring API key. ` + + `Check HTTP_PROXY/HTTPS_PROXY environment variables. ` + + `Response: ${preview}` + ); + } + + throw new Error( + `Unexpected response from ${endpoint} (expected JSON, got: ${contentType || 'unknown'}). ` + + `This may indicate a proxy or network issue. Response: ${preview}` + ); + } + + // Check for HTTP errors + if (!response.ok) { + let errorMessage = response.statusText; + try { + const body = await response.json() as { error?: string; message?: string }; + errorMessage = body.error || body.message || response.statusText; + } catch { + // Ignore JSON parse errors for error response + } + throw new Error(`HTTP ${response.status}: ${errorMessage}`); + } + + return response.json() as Promise; +} + /** * Check if server is already running on the specified port * @param {number} port - Port to check @@ -47,15 +90,20 @@ async function isServerRunning(port: number): Promise { */ async function switchWorkspace(port: number, path: string): Promise { try { + // Get auth token with content-type validation const tokenResponse = await fetch(`http://localhost:${port}/api/auth/token`); - const tokenData = await tokenResponse.json() as { token?: string }; + const tokenData = await safeParseJson<{ token?: string; expiresAt?: string }>( + tokenResponse, + '/api/auth/token' + ); const token = tokenData.token; + // Switch workspace with content-type validation const response = await fetch( `http://localhost:${port}/api/switch-path?path=${encodeURIComponent(path)}`, token ? { headers: { Authorization: `Bearer ${token}` } } : undefined ); - return await response.json() as SwitchWorkspaceResult; + return safeParseJson(response, '/api/switch-path'); } catch (err) { const error = err as Error; return { success: false, error: error.message }; diff --git a/ccw/src/core/auth/middleware.ts b/ccw/src/core/auth/middleware.ts index 1a4132d2..529a3149 100644 --- a/ccw/src/core/auth/middleware.ts +++ b/ccw/src/core/auth/middleware.ts @@ -47,11 +47,31 @@ export function extractAuthToken(req: IncomingMessage): string | null { return null; } -export function isLocalhostRequest(req: IncomingMessage): boolean { +/** + * Check if request is from localhost + * @param req - The incoming request + * @param allowAllInterfaces - When true (server bound to 0.0.0.0), allows requests from any interface + */ +export function isLocalhostRequest(req: IncomingMessage, allowAllInterfaces: boolean = false): boolean { + // When server is bound to 0.0.0.0, allow token acquisition from any interface + // This is safe because the token endpoint only provides auth for the dashboard + if (allowAllInterfaces) { + return true; + } + const remote = req.socket?.remoteAddress ?? ''; return remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1'; } +/** + * Check if host binding allows all network interfaces + * @param host - The host address the server is bound to + */ +export function isWildcardHost(host: string | undefined): boolean { + if (!host) return false; + return host === '0.0.0.0' || host === '::' || host === ''; +} + export function setAuthCookie(res: ServerResponse, token: string, expiresAt: Date): void { const maxAgeSeconds = Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000)); diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index 72bb963c..da5b8161 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -51,7 +51,7 @@ import { handleSpecRoutes } from './routes/spec-routes.js'; import { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js'; import { getTokenManager } from './auth/token-manager.js'; -import { authMiddleware, isLocalhostRequest, setAuthCookie } from './auth/middleware.js'; +import { authMiddleware, isLocalhostRequest, isWildcardHost, setAuthCookie } from './auth/middleware.js'; import { getCorsOrigin } from './cors.js'; import { csrfValidation } from './auth/csrf-middleware.js'; import { getCsrfTokenManager } from './auth/csrf-manager.js'; @@ -418,9 +418,11 @@ export async function startServer(options: ServerOptions = {}): Promise