mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 20:14:34 +00:00
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:
1 parent
881b061dfe
commit
6471cc55d7
4 files changed
+279
-66
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user