fix(docker): harden cliproxy local proxy with auth guard, dynamic port, and body handling

Move reverse proxy under /api/cliproxy-local so it sits behind auth
middleware. Enforce localhost-only access when dashboard auth is disabled.
Resolve the target port from unified config instead of hardcoding 8317.
Re-serialize parsed JSON bodies before forwarding so writes still work
behind express.json(). Clean up proxy connections on client abort.

On the frontend, point the iframe and health check at the new same-origin
API path, probe the proxy directly, and tighten iframe origin/path
validation before sending the auto-login secret.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
UserandClaude Opus 4.6 committed 2026-03-30 16:33:32 -07:00
1 parent 881b061dfe
commit 6471cc55d7
4 files changed
+279 -66

No files matched your search

+4 -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);
@@ -79,10 +83,6 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const { usageRoutes } = await import('./usage-routes');
app.use('/api/usage', usageRoutes);
// CLIProxy local reverse proxy (avoids cross-origin issues in Docker)
const cliproxyLocalProxy = (await import('./routes/cliproxy-local-proxy')).default;
app.use('/cliproxy-local', cliproxyLocalProxy);
// Dev mode: use Vite middleware for HMR
if (options.dev) {
const { createServer: createViteServer } = await import('vite');
+116 -31
View File
@@ -2,48 +2,133 @@
* CLIProxy Local Reverse Proxy
*
* Proxies requests from the dashboard to the local CLIProxy service
* running on 127.0.0.1:8317 inside the same host/container.
* running on 127.0.0.1 inside the same host/container.
*
* This eliminates cross-origin issues when the dashboard and CLIProxy
* run on different ports (e.g., inside Docker containers where the
* browser cannot reach the internal CLIProxy port directly).
*
* Mounted at: /cliproxy-local/* → http://127.0.0.1:{port}/*
* Mounted at: /api/cliproxy-local/* -> http://127.0.0.1:{port}/*
*/
import { Router, Request, Response } from 'express';
import http from 'http';
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
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';
const router = Router();
export interface CliproxyLocalProxyDeps {
enforceAccess?: (req: Request, res: Response) => boolean;
request?: typeof http.request;
resolveTargetPort?: () => number;
}
router.all('/*', (req: Request, res: Response) => {
// Strip the mount prefix — req.url already has it removed by Express
const targetPath = req.url || '/';
function resolveLocalCliproxyPort(): number {
const config = loadOrCreateUnifiedConfig();
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
}
const options: http.RequestOptions = {
hostname: '127.0.0.1',
port: CLIPROXY_DEFAULT_PORT,
path: targetPath,
method: req.method,
headers: {
...req.headers,
host: `127.0.0.1:${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 (!isJsonContentType(req.headers['content-type']) || req.body === undefined) {
return undefined;
}
const contentLengthHeader = req.headers['content-length'];
const contentLength = Array.isArray(contentLengthHeader)
? contentLengthHeader[0]
: contentLengthHeader;
const hasTransferEncoding = req.headers['transfer-encoding'] !== undefined;
const parsedContentLength =
typeof contentLength === 'string' ? Number.parseInt(contentLength, 10) : NaN;
if (!hasTransferEncoding && (!Number.isFinite(parsedContentLength) || parsedContentLength <= 0)) {
return undefined;
}
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}`,
};
const proxyReq = http.request(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
proxyRes.pipe(res, { end: true });
});
delete proxyHeaders.connection;
proxyReq.on('error', () => {
if (!res.headersSent) {
res.status(502).json({ error: 'CLIProxy is not reachable' });
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();
}
});
req.pipe(proxyReq, { end: true });
});
router.all('/*', (req: Request, res: Response) => {
const targetPort = resolveTargetPort();
const targetPath = req.url || '/';
const bodyBuffer = buildProxyBody(req);
export default router;
const proxyReq = createRequest(
{
hostname: '127.0.0.1',
port: targetPort,
path: targetPath,
method: req.method,
headers: buildProxyHeaders(req.headers, targetPort, bodyBuffer),
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
proxyRes.pipe(res, { end: true });
}
);
proxyReq.on('error', () => {
if (!res.headersSent) {
res.status(502).json({ error: 'CLIProxy is not reachable' });
}
});
req.on('aborted', () => proxyReq.destroy());
res.on('close', () => {
if (!res.writableEnded) {
proxyReq.destroy();
}
});
if (bodyBuffer) {
proxyReq.end(bodyBuffer);
return;
}
req.pipe(proxyReq, { end: true });
});
return router;
}
export default createCliproxyLocalProxyRouter();