Merge pull request #866 from kapdon/review/docker-cliproxy-cors

fix(docker): proxy CLIProxy management panel through dashboard to avoid cross-origin errors
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-01 11:52:59 -04:00
committed by GitHub
4 changed files with 367 additions and 26 deletions
+4
View File
@@ -63,6 +63,10 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
// Auth middleware (protects API routes when enabled)
app.use(authMiddleware);
// CLIProxy local reverse proxy (avoids cross-origin issues in Docker)
const cliproxyLocalProxy = (await import('./routes/cliproxy-local-proxy')).default;
app.use('/api/cliproxy-local', cliproxyLocalProxy);
// REST API routes (modularized)
const { apiRoutes } = await import('./routes/index');
app.use('/api', apiRoutes);
@@ -0,0 +1,151 @@
/**
* CLIProxy Local Reverse Proxy
*
* Proxies requests from the dashboard to the local CLIProxy service
* running on 127.0.0.1 inside the same host/container.
*
* Mounted at: /api/cliproxy-local/* -> 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();
@@ -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<number> {
servers.push(server);
return await new Promise<number>((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<void>((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('<html>management panel</html>');
});
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('<html>management panel</html>');
});
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' });
});
});
@@ -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