diff --git a/src/cliproxy/proxy-target-resolver.ts b/src/cliproxy/proxy-target-resolver.ts index e87d53ff..64778f44 100644 --- a/src/cliproxy/proxy-target-resolver.ts +++ b/src/cliproxy/proxy-target-resolver.ts @@ -42,10 +42,15 @@ export function getProxyTarget(): ProxyTarget { const config = loadCliproxyServerConfig(); if (config?.remote?.enabled && config.remote?.host) { + const protocol = config.remote.protocol ?? 'http'; + // Default port based on protocol if not specified + const defaultPort = protocol === 'https' ? 443 : 80; + const port = config.remote.port ?? defaultPort; + return { host: config.remote.host, - port: config.remote.port ?? DEFAULT_CLIPROXY_PORT, - protocol: config.remote.protocol ?? 'http', + port, + protocol, authToken: config.remote.auth_token || undefined, // Empty string -> undefined isRemote: true, }; @@ -65,7 +70,9 @@ export function getProxyTarget(): ProxyTarget { * @param path Endpoint path (e.g., '/v0/management/usage') */ export function buildProxyUrl(target: ProxyTarget, path: string): string { - return `${target.protocol}://${target.host}:${target.port}${path}`; + // Normalize path to ensure leading slash + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${target.protocol}://${target.host}:${target.port}${normalizedPath}`; } /** diff --git a/src/cliproxy/remote-auth-fetcher.ts b/src/cliproxy/remote-auth-fetcher.ts index 1c5cb01a..3f11d410 100644 --- a/src/cliproxy/remote-auth-fetcher.ts +++ b/src/cliproxy/remote-auth-fetcher.ts @@ -10,6 +10,9 @@ import { ProxyTarget, } from './proxy-target-resolver'; +/** Timeout for remote fetch requests (ms) */ +const REMOTE_FETCH_TIMEOUT_MS = 5000; + /** Remote auth file from CLIProxyAPI /v0/management/auth-files */ interface RemoteAuthFile { id: string; @@ -21,11 +24,6 @@ interface RemoteAuthFile { source: 'file' | 'memory'; } -/** Response from CLIProxyAPI auth-files endpoint */ -interface RemoteAuthFilesResponse { - files: RemoteAuthFile[]; -} - /** Account info for UI display */ export interface RemoteAccountInfo { id: string; @@ -39,7 +37,6 @@ export interface RemoteAuthStatus { provider: string; displayName: string; authenticated: boolean; - lastAuth: string | null; tokenFiles: number; accounts: RemoteAccountInfo[]; defaultAccount: string | null; @@ -78,7 +75,7 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise controller.abort(), 5000); + const timeoutId = setTimeout(() => controller.abort(), REMOTE_FETCH_TIMEOUT_MS); try { const response = await fetch(url, { @@ -95,8 +92,14 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise(); for (const file of files) { const provider = PROVIDER_MAP[file.provider.toLowerCase()]; - if (!provider) continue; + if (!provider) { + // Unknown provider, skip (could add logging in debug mode) + continue; + } const existing = byProvider.get(provider); if (existing) { @@ -125,11 +134,11 @@ function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] { const result: RemoteAuthStatus[] = []; - Array.from(byProvider.entries()).forEach(([provider, providerFiles]) => { + for (const [provider, providerFiles] of byProvider) { const activeFiles = providerFiles.filter((f) => f.status === 'active'); const accounts: RemoteAccountInfo[] = providerFiles.map((f, idx) => ({ id: f.id, - email: f.email || f.name, + email: f.email || f.name || 'Unknown', isDefault: idx === 0, status: f.status, })); @@ -138,13 +147,12 @@ function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] { provider, displayName: PROVIDER_DISPLAY_NAMES[provider] || provider, authenticated: activeFiles.length > 0, - lastAuth: null, tokenFiles: providerFiles.length, accounts, defaultAccount: accounts.find((a) => a.isDefault)?.id || null, source: 'remote', }); - }); + } return result; } diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index 90636e7a..2abe97f2 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -30,12 +30,19 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel const [showLoginHint, setShowLoginHint] = useState(true); // Fetch cliproxy_server config for remote/local mode detection - const { data: cliproxyConfig } = useQuery({ + const { data: cliproxyConfig, error: configError } = useQuery({ queryKey: ['cliproxy-server-config'], queryFn: () => api.cliproxyServer.get(), staleTime: 30000, // 30 seconds }); + // Log config fetch errors (fallback to local mode on error) + useEffect(() => { + if (configError) { + console.warn('[ControlPanelEmbed] Config fetch failed, using local mode:', configError); + } + }, [configError]); + // Calculate URLs and settings based on remote or local mode const { managementUrl, checkUrl, authToken, isRemote, displayHost } = useMemo(() => { const remote = cliproxyConfig?.remote; @@ -72,10 +79,12 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel // Check if CLIProxy is running useEffect(() => { + const controller = new AbortController(); + const checkConnection = async () => { try { const response = await fetch(checkUrl, { - signal: AbortSignal.timeout(2000), + signal: controller.signal, }); if (response.ok) { setIsConnected(true); @@ -88,7 +97,10 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel : 'CLIProxy returned an error' ); } - } catch { + } catch (e) { + // Ignore abort errors (component unmounting) + if (e instanceof Error && e.name === 'AbortError') return; + setIsConnected(false); setError( isRemote @@ -98,7 +110,12 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel } }; - checkConnection(); + // Start connection check with timeout + const timeoutId = setTimeout(() => controller.abort(), 2000); + checkConnection().finally(() => clearTimeout(timeoutId)); + + // Cleanup: abort fetch on unmount + return () => controller.abort(); }, [checkUrl, isRemote, displayHost]); // Handle iframe load - attempt to auto-login via postMessage @@ -112,6 +129,14 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel try { // Derive apiBase from checkUrl (remove trailing slash) const apiBase = checkUrl.replace(/\/$/, ''); + + // Security: Validate iframe src matches target origin before sending credentials + const iframeSrc = iframeRef.current.src; + if (!iframeSrc.startsWith(apiBase)) { + console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage'); + return; + } + // Send credentials to iframe iframeRef.current.contentWindow.postMessage( { @@ -121,15 +146,17 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel }, apiBase ); - } catch { + } catch (e) { // Cross-origin restriction - expected if not same origin - console.debug('[ControlPanelEmbed] postMessage failed - cross-origin'); + console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e); } } }, [checkUrl, authToken]); const handleRefresh = () => { setIsLoading(true); + setError(null); + setIsConnected(false); if (iframeRef.current) { iframeRef.current.src = managementUrl; } @@ -187,7 +214,9 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel Key:{' '} - {authToken || 'ccs'} + {authToken && authToken.length > 4 + ? `***${authToken.slice(-4)}` + : authToken || 'ccs'}