mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
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:
@@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user