fix(cliproxy): address code review findings for remote routing

- Add runtime API response validation in remote-auth-fetcher
- Add AbortController cleanup to prevent state updates after unmount
- Validate postMessage origin matches iframe src before sending creds
- Mask auth token display (show ***last4) for security
- Reset error/connected state on refresh
- Fix port default to use protocol-based (443 for https, 80 for http)
- Normalize path in buildProxyUrl to ensure leading slash
- Add fallback for undefined email (default to 'Unknown')
- Extract timeout constant (REMOTE_FETCH_TIMEOUT_MS)
- Remove unused RemoteAuthFilesResponse interface
This commit is contained in:
kaitranntt
2025-12-21 00:42:54 -05:00
parent bfa55e041c
commit cdb465342e
3 changed files with 69 additions and 25 deletions
+10 -3
View File
@@ -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}`;
}
/**
+23 -15
View File
@@ -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<Remot
const url = buildProxyUrl(proxyTarget, '/v0/management/auth-files');
const controller = new AbortController();
const timeoutId = setTimeout(() => 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<Remot
throw new Error(`Remote returned ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as RemoteAuthFilesResponse;
return transformRemoteAuthFiles(data.files);
const data: unknown = await response.json();
// Validate response structure
if (!data || typeof data !== 'object' || !('files' in data) || !Array.isArray(data.files)) {
throw new Error('Invalid response format from remote auth endpoint');
}
return transformRemoteAuthFiles(data.files as RemoteAuthFile[]);
} catch (error) {
clearTimeout(timeoutId);
@@ -107,13 +110,19 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise<Remot
}
}
/** Transform CLIProxyAPI auth files to CCS AuthStatus format */
/**
* Transform CLIProxyAPI auth files to CCS AuthStatus format
* @param files Array of auth files from remote API
*/
function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] {
const byProvider = new Map<string, RemoteAuthFile[]>();
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;
}
@@ -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<CliproxyServerConfig>({
const { data: cliproxyConfig, error: configError } = useQuery<CliproxyServerConfig>({
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
<span>
Key:{' '}
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
{authToken || 'ccs'}
{authToken && authToken.length > 4
? `***${authToken.slice(-4)}`
: authToken || 'ccs'}
</code>
</span>
<button