diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 6ec39e00..9aa1ae0d 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -63,6 +63,10 @@ export async function startServer(options: ServerOptions): Promise http://127.0.0.1:{port}/* + */ + +import http from 'http'; +import { Request, Response, Router } from 'express'; +import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; + +export interface CliproxyLocalProxyDeps { + enforceAccess?: (req: Request, res: Response) => boolean; + request?: typeof http.request; + resolveTargetPort?: () => number; +} + +/** Proxy request timeout in milliseconds (30 seconds) */ +const PROXY_TIMEOUT_MS = 30_000; + +function resolveLocalCliproxyPort(): number { + try { + const config = loadOrCreateUnifiedConfig(); + return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); + } catch { + return CLIPROXY_DEFAULT_PORT; + } +} + +function isJsonContentType(contentType: string | string[] | undefined): boolean { + const values = Array.isArray(contentType) ? contentType : [contentType]; + return values.some((value) => value?.toLowerCase().includes('application/json') === true); +} + +function buildProxyBody(req: Request): Buffer | undefined { + // If express.json() parsed the body (content-type is JSON and req.body is populated), + // re-serialize it since the original request stream was consumed by the middleware. + if ( + !isJsonContentType(req.headers['content-type']) || + req.body === undefined || + req.body === null + ) { + return undefined; + } + + // express.json() sets req.body to the parsed value — re-serialize for the proxy target + return Buffer.from(JSON.stringify(req.body)); +} + +function buildProxyHeaders( + headers: http.IncomingHttpHeaders, + port: number, + bodyBuffer?: Buffer +): http.IncomingHttpHeaders { + const proxyHeaders: http.IncomingHttpHeaders = { + ...headers, + host: `127.0.0.1:${port}`, + }; + + delete proxyHeaders.connection; + + if (bodyBuffer) { + delete proxyHeaders['transfer-encoding']; + proxyHeaders['content-length'] = String(bodyBuffer.length); + } + + return proxyHeaders; +} + +export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {}): Router { + const router = Router(); + const enforceAccess = + deps.enforceAccess ?? + ((req: Request, res: Response) => + requireLocalAccessWhenAuthDisabled( + req, + res, + 'CLIProxy local proxy requires localhost access when dashboard auth is disabled.' + )); + const createRequest = deps.request ?? http.request; + const resolveTargetPort = deps.resolveTargetPort ?? resolveLocalCliproxyPort; + + router.use((req: Request, res: Response, next) => { + if (enforceAccess(req, res)) { + next(); + } + }); + + router.all('/*', (req: Request, res: Response) => { + const targetPort = resolveTargetPort(); + const targetPath = req.url || '/'; + const bodyBuffer = buildProxyBody(req); + + const proxyReq = createRequest( + { + hostname: '127.0.0.1', + port: targetPort, + path: targetPath, + method: req.method, + headers: buildProxyHeaders(req.headers, targetPort, bodyBuffer), + timeout: PROXY_TIMEOUT_MS, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); + // Manual streaming instead of pipe() for Bun runtime compatibility + proxyRes.on('data', (chunk: Buffer) => res.write(chunk)); + proxyRes.on('end', () => res.end()); + } + ); + + proxyReq.on('timeout', () => proxyReq.destroy()); + + proxyReq.on('error', () => { + if (!res.headersSent) { + res.status(502).json({ error: 'CLIProxy is not reachable' }); + } + }); + + // Clean up proxy connection when client disconnects. + // Only use res.on('close') — req.on('close') fires with req.destroyed=true + // in Bun after body consumption, which would prematurely kill the proxy. + res.on('close', () => { + if (!res.writableEnded) { + proxyReq.destroy(); + } + }); + + if (bodyBuffer) { + proxyReq.end(bodyBuffer); + return; + } + + // For methods without a body (GET, HEAD, etc.) or when express.json() + // has already consumed the stream, end the request immediately. + const hasBody = req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS'; + if (!hasBody) { + proxyReq.end(); + return; + } + + req.pipe(proxyReq, { end: true }); + }); + + return router; +} + +export default createCliproxyLocalProxyRouter(); diff --git a/tests/unit/web-server/cliproxy-local-proxy.test.ts b/tests/unit/web-server/cliproxy-local-proxy.test.ts new file mode 100644 index 00000000..d665731b --- /dev/null +++ b/tests/unit/web-server/cliproxy-local-proxy.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import http from 'http'; +import type { AddressInfo } from 'net'; + +import { + createCliproxyLocalProxyRouter, + type CliproxyLocalProxyDeps, +} from '../../../src/web-server/routes/cliproxy-local-proxy'; + +const servers: http.Server[] = []; + +async function listen(server: http.Server): Promise { + servers.push(server); + + return await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve((server.address() as AddressInfo).port); + }); + }); +} + +async function createBackendServer( + handler: http.RequestListener +): Promise<{ port: number; server: http.Server }> { + const server = http.createServer(handler); + const port = await listen(server); + return { port, server }; +} + +async function createProxyServer(options: { + enforceAccess?: CliproxyLocalProxyDeps['enforceAccess']; + resolveTargetPort: () => number; +}): Promise<{ baseUrl: string; server: http.Server }> { + const app = express(); + app.use(express.json()); + app.use( + '/api/cliproxy-local', + createCliproxyLocalProxyRouter({ + enforceAccess: options.enforceAccess, + resolveTargetPort: options.resolveTargetPort, + }) + ); + + const server = http.createServer(app); + const port = await listen(server); + return { baseUrl: `http://127.0.0.1:${port}`, server }; +} + +afterEach(async () => { + while (servers.length > 0) { + const server = servers.pop(); + if (!server) { + continue; + } + + // Force-close keep-alive connections so server.close() doesn't hang + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +describe('cliproxy local proxy route', () => { + it('blocks requests when local-access enforcement fails', async () => { + let backendHit = false; + const backend = await createBackendServer((_req, res) => { + backendHit = true; + res.writeHead(200).end('ok'); + }); + const proxy = await createProxyServer({ + resolveTargetPort: () => backend.port, + enforceAccess: (_req, res) => { + res.status(403).json({ error: 'blocked' }); + return false; + }, + }); + + const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/management.html`); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'blocked' }); + expect(backendHit).toBe(false); + }); + + it('forwards JSON request bodies after express.json has parsed them', async () => { + const backend = await createBackendServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + body: JSON.parse(body), + method: req.method, + path: req.url, + }) + ); + }); + }); + const proxy = await createProxyServer({ + resolveTargetPort: () => backend.port, + enforceAccess: () => true, + }); + + const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/v0/management/test`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, ids: ['a', 'b'] }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + body: { enabled: true, ids: ['a', 'b'] }, + method: 'PATCH', + path: '/v0/management/test', + }); + }); + + it('forwards GET requests and returns backend response', async () => { + const backend = await createBackendServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('management panel'); + }); + const proxy = await createProxyServer({ + resolveTargetPort: () => backend.port, + enforceAccess: () => true, + }); + + const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/management.html`); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('management panel'); + }); + + it('returns 502 when CLIProxy is not reachable', async () => { + // Use a port with nothing listening + const proxy = await createProxyServer({ + resolveTargetPort: () => 19999, + enforceAccess: () => true, + }); + + const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/`); + + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ error: 'CLIProxy is not reachable' }); + }); +}); diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index d256c1f0..67abc74e 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -57,6 +57,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel // Calculate URLs and settings based on remote or local mode const { managementUrl, checkUrl, authToken, isRemote, displayHost } = useMemo(() => { const remote = cliproxyConfig?.remote; + const localPort = cliproxyConfig?.local?.port ?? port; if (remote?.enabled && remote?.host) { const protocol = remote.protocol || 'http'; @@ -78,14 +79,15 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel }; } - // Local mode - use effective management secret from auth tokens API + // Local mode - proxy through dashboard server to avoid cross-origin/port issues + // (e.g., in Docker the browser cannot reach the internal CLIProxy port directly) const effectiveSecret = authTokens?.managementSecret?.value || 'ccs'; return { - managementUrl: `http://localhost:${port}/management.html`, - checkUrl: `http://localhost:${port}/`, + managementUrl: withApiBase('/cliproxy-local/management.html'), + checkUrl: withApiBase('/cliproxy-local/'), authToken: effectiveSecret, isRemote: false, - displayHost: `localhost:${port}`, + displayHost: `localhost:${localPort}`, }; }, [cliproxyConfig, authTokens, port]); @@ -95,29 +97,50 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel // Check if CLIProxy is running useEffect(() => { const controller = new AbortController(); + let cancelled = false; + + const updateConnectionState = (connected: boolean, nextError: string | null) => { + if (cancelled) return; + setIsConnected(connected); + setError(nextError); + }; const checkConnection = async () => { try { - const response = await fetch(checkUrl, { - signal: controller.signal, - }); - if (response.ok) { - setIsConnected(true); - setError(null); + if (isRemote) { + // Remote mode: use the test endpoint via same-origin API to avoid CORS + const remote = cliproxyConfig?.remote; + const result = await api.cliproxyServer.test({ + host: remote?.host ?? '', + port: remote?.port, + protocol: remote?.protocol ?? 'http', + authToken: remote?.auth_token, + }); + if (result?.reachable) { + updateConnectionState(true, null); + } else { + updateConnectionState( + false, + result?.error + ? `Remote CLIProxy at ${displayHost}: ${result.error}` + : `Remote CLIProxy at ${displayHost} returned an error` + ); + } } else { - setIsConnected(false); - setError( - isRemote - ? `Remote CLIProxy at ${displayHost} returned an error` - : 'CLIProxy returned an error' - ); + // Local mode: probe the proxied control panel root directly. + const response = await fetch(checkUrl, { signal: controller.signal }); + if (response.ok) { + updateConnectionState(true, null); + } else { + updateConnectionState(false, 'CLIProxy returned an error'); + } } } catch (e) { // Ignore abort errors (component unmounting) if (e instanceof Error && e.name === 'AbortError') return; - setIsConnected(false); - setError( + updateConnectionState( + false, isRemote ? `Remote CLIProxy at ${displayHost} is not reachable` : 'CLIProxy is not running' @@ -130,8 +153,11 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel checkConnection().finally(() => clearTimeout(timeoutId)); // Cleanup: abort fetch on unmount - return () => controller.abort(); - }, [checkUrl, isRemote, displayHost]); + return () => { + cancelled = true; + controller.abort(); + }; + }, [checkUrl, isRemote, displayHost, cliproxyConfig]); const postAutoLoginCredentials = useCallback(() => { // Auto-login can only run when iframe has loaded and authToken is available. @@ -140,12 +166,20 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel } try { - // Derive apiBase from checkUrl (remove trailing slash) - const apiBase = checkUrl.replace(/\/$/, ''); + // Derive apiBase and targetOrigin from checkUrl. + // Local mode uses the same-origin dashboard proxy; remote mode stays absolute. + const apiBase = checkUrl.startsWith('/') + ? new URL(checkUrl.replace(/\/$/, ''), window.location.origin).href + : checkUrl.replace(/\/$/, ''); + const apiBaseUrl = new URL(`${apiBase}/`); + const targetOrigin = apiBaseUrl.origin; - // Security: Validate iframe src matches target origin before sending credentials - const iframeSrc = iframeRef.current.src; - if (!iframeSrc.startsWith(apiBase)) { + // Security: Validate iframe src matches the expected origin/path before sending credentials. + const iframeUrl = new URL(iframeRef.current.src, window.location.origin); + if ( + iframeUrl.origin !== apiBaseUrl.origin || + !iframeUrl.pathname.startsWith(apiBaseUrl.pathname) + ) { console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage'); return; } @@ -157,7 +191,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel apiBase, managementKey: authToken, }, - apiBase + targetOrigin ); } catch (e) { // Cross-origin restriction - expected if not same origin