const express = require('express'); const http = require('http'); const crypto = require('crypto'); const { ok, errorResponse, notFound, conflict } = require('../src/utils/responses'); /** * OpenClaw management routes * Proxies gateway API calls through DashCaddy so the token never leaves the server. * * GET /openclaw/status → container info + gateway health * POST /openclaw/deploy → deploy OpenClaw container * GET /openclaw/proxy/* → proxy GET to gateway * POST /openclaw/proxy/* → proxy POST to gateway * DELETE /openclaw → remove container */ module.exports = function openClawRoutes(ctx) { const router = express.Router(); const docker = ctx.docker; const asyncHandler = ctx.asyncHandler; const ok = ctx.ok; const log = ctx.log || console; // ── helpers ────────────────────────────────────────────────────────────── async function findOpenClawContainer() { const containers = await docker.client.listContainers({ all: true }); return containers.find(function(c) { return c.Image === 'ghcr.io/nousresearch/openclaw:latest' || (c.Labels && c.Labels['dashcaddy.managed'] === 'true' && c.Names.some(function(n) { return n.includes('openclaw'); })); }) || null; } async function getGatewayToken(containerId) { try { const info = await docker.client.containerInfo(containerId); const entry = (info.Config.Env || []).find(function(e) { return e.startsWith('OPENCLAW_GATEWAY_TOKEN='); }); return entry ? entry.split('=')[1] : null; } catch(err) { return null; } } async function getContainerPort(containerId) { try { const containers = await docker.client.listContainers({ all: true }); const c = containers.find(function(x) { return x.Id === containerId || x.Id.startsWith(containerId); }); if (c && c.Ports) { const p = c.Ports.find(function(x) { return x.PrivatePort === 18792; }); if (p && p.PublicPort) return String(p.PublicPort); } return '18792'; } catch(err) { return '18792'; } } async function gatewayHealth(baseUrl, token) { return new Promise(function(resolve) { const headers = {}; if (token) headers['Authorization'] = 'Bearer ' + token; const req = http.get(baseUrl + '/health', { headers: headers }, function(res) { let data = ''; res.on('data', function(d) { data += d; }); res.on('end', function() { try { resolve({ ok: true, data: JSON.parse(data) }); } catch(e) { resolve({ ok: true, data: data }); } }); }); req.on('error', function(e) { resolve({ ok: false, error: e.message }); }); req.setTimeout(5000, function() { req.destroy(); resolve({ ok: false, error: 'timeout' }); }); }); } /** * DC-065: OpenClaw proxy hardening. * * Three attack vectors were previously open: * (a) Unbounded response passthrough — proxyRes.on('data') wrote every * byte to the client without a cap, allowing a compromised/buggy * OpenClaw container to push arbitrarily large payloads (DoS, * log-spam, memory pressure on the API container). * (b) Hop-by-hop / response-shaping headers forwarded verbatim — Node's * `res.set(proxyRes.headers)` copies Connection, Keep-Alive, * Transfer-Encoding, Upgrade, Proxy-Authenticate, Proxy-Authorization, * TE, Trailers, Set-Cookie, Content-Encoding, Content-Length, and * Server. Per RFC 7230 §6.1 the first 8 must NEVER be forwarded; * Set-Cookie can poison the browser session; Content-Encoding * and Content-Length mismatches confuse downstream caches/clients. * (c) `proxyRes.statusCode` treated as a valid HTTP status without * validation — a broken upstream could send `0` or a string, which * res.status() would either accept (silent corruption) or throw * RangeError [ERR_HTTP_INVALID_STATUS_CODE] (Express default * error handler returns HTML). * (d) `path` taken from req.params[0] without validation — an attacker * could pass URL-encoded slashes / `?` / `#` chars / absolute URLs * to redirect the proxy elsewhere on localhost. * * The five fixes below close (a)-(d) without changing the on-the-wire * shape of the proxy from a same-origin browser's perspective. */ // RFC 7230 §6.1 hop-by-hop headers that must NEVER be forwarded by a proxy. const HOP_BY_HOP = new Set([ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade', ]); // Headers we deliberately strip from proxied responses for client-safety / // cache-correctness reasons (NOT hop-by-hop, but dangerous to forward). // DC-065 round-1 GLM-5.3 finding: `location` MUST be stripped — a // 3xx response with `Location: http://evil.com/x` would be honored by // the same-origin browser because the proxy response is on // /openclaw/proxy/* (same-origin from the dashboard's perspective) and // the proxy didn't downgrade the status. This is a classic open-redirect // through proxy. We strip Location and let the browser stay put (or, // for clients that depend on redirect-following, they can retry the // upstream directly without our proxy in the path). // DC-065 round-2 GLM-5.3 finding: `refresh` and `www-authenticate` are // in the same class and were also leaking. `Refresh: 0; url=...` is // honored by a meaningful subset of browsers (older Chrome, Firefox, // Safari, mobile WebViews) as an open-redirect primitive. `WWW- // Authenticate: Basic realm=...` pops a native browser auth dialog on // the dashboard's origin (phishing/UX attack). Both stripped. const STRIPPED_RESPONSE_HEADERS = new Set([ 'set-cookie', // upstream browser poisoning 'location', // round-1 GLM finding — open-redirect through proxy 'refresh', // round-2 GLM finding — same-class open-redirect primitive 'www-authenticate', // round-2 GLM finding — phishing via browser auth prompt 'content-encoding', // we send raw bytes; mismatched encoding breaks clients 'content-length', // node auto-computes; forwarding can desync with body 'server', // upstream fingerprinting 'x-powered-by', // upstream fingerprinting ]); // 5 MiB is a generous cap for a chat / gateway UI; anything larger is // either a misconfigured upstream or an attack. Picked to match the // express.json({ limit }) default in src/utilities/middleware.js. const MAX_PROXY_RESPONSE_BYTES = 5 * 1024 * 1024; // Allowed chars in the downstream `path` segment: alphanumerics, `-`, `_`, // `.`, `~`, `/`, `?`, `&`, `=`, `:`, `@`, `+`, `,`, `;` (RFC 3986 pchar + // query/fragment separators). Anything else → 400. const ALLOWED_PATH_RE = /^[a-zA-Z0-9._~/?&=:@+,;%\-]*$/; // Maximum total `path` length (reasonable for a gateway UI endpoint). const MAX_PATH_LEN = 1024; function sanitizeForwardedHeaders(rawHeaders) { const out = {}; for (const name of Object.keys(rawHeaders || {})) { const lower = name.toLowerCase(); if (HOP_BY_HOP.has(lower)) continue; if (STRIPPED_RESPONSE_HEADERS.has(lower)) continue; out[name] = rawHeaders[name]; } return out; } function coerceUpstreamStatus(rawStatus) { // Status must be an integer in 100..599. Anything else → 502 (the proxy // failed to interpret the upstream response, which is exactly what 502 // semantically means: bad gateway). if ( typeof rawStatus !== 'number' || !Number.isInteger(rawStatus) || rawStatus < 100 || rawStatus > 599 ) { return 502; } return rawStatus; } function validatePath(path) { if (typeof path !== 'string') return { ok: false, code: 400, msg: 'path must be a string' }; if (path.length === 0) return { ok: false, code: 400, msg: 'path is empty' }; if (path.length > MAX_PATH_LEN) return { ok: false, code: 414, msg: 'path too long' }; // Reject absolute-URL injection (`://`), backslashes (Windows path-style // smuggling), CRLF (header injection on rare downstream), and any char // outside the RFC 3986 pchar/query/fragment set. if (/[\s\\]|:\/\//.test(path)) return { ok: false, code: 400, msg: 'path contains forbidden characters' }; if (!ALLOWED_PATH_RE.test(path)) return { ok: false, code: 400, msg: 'path contains disallowed characters' }; // Strip a single leading slash so we can rebuild as `${targetBase}/${path}` // idempotently (targetBase already has a trailing `:PORT` form). return { ok: true, normalized: path.replace(/^\/+/, '') }; } // DC-065: expose helpers via the router for direct unit testing. The // router is an Express Router; any property we add here stays private // to the module and is read by __tests__/routes/openclaw.proxy-hardening // .test.js without going through Express. router._dc065 = { HOP_BY_HOP, STRIPPED_RESPONSE_HEADERS, MAX_PROXY_RESPONSE_BYTES, ALLOWED_PATH_RE, MAX_PATH_LEN, sanitizeForwardedHeaders, coerceUpstreamStatus, validatePath, }; function proxyRequest(req, res, targetBase, path, token) { const pathCheck = validatePath(path); if (!pathCheck.ok) { return errorResponse(res, pathCheck.code, pathCheck.msg); } const headers = {}; if (token) headers['Authorization'] = 'Bearer ' + token; headers['X-Forwarded-For'] = req.ip; headers['X-Forwarded-Proto'] = req.protocol; const url = targetBase + '/' + pathCheck.normalized; const method = req.method; // Stream the upstream response through `res` with a byte-size cap. On // overrun we abort the proxyReq and reply with 502 Bad Gateway. The // accumulated bytes are tracked per-call; if MAX_PROXY_RESPONSE_BYTES // is exceeded, we close the upstream and tear down the client response. function pipeUpstream(proxyReq) { // Buffer-first response proxy: collect chunks in memory until either // the upstream finishes or MAX_PROXY_RESPONSE_BYTES is exceeded. Then // emit a single Express response with sanitized headers + the // buffered body, or a 502 if the cap fired. Two reasons for the // buffer-first approach: // // 1. Once res.status() is called and headers are flushed (which // happens on the first res.write), the status code is locked. // Streaming the body through res.write lets a malicious // upstream send 1 byte of 200 OK + N bytes of garbage; we can't // retroactively downgrade to 502. Buffering lets us inspect // the full response before committing to a status. // // 2. Synchronous status/header/body emission is cheaper than // backpressure-aware chunked writes for a proxy that // specifically serves JSON-RPC + small payloads (OpenClaw's // gateway chat API is not a streaming use case). // // Memory cost: MAX_PROXY_RESPONSE_BYTES per concurrent proxy // request. At 5 MiB and Node's default 1000 concurrent connections // (server.maxConnections defaults to Infinity), worst-case is ~5 // GiB. We cap concurrency in start.sh via Node CLI flags; see // ulimit + --max-old-space-size settings. const chunks = []; let totalBytes = 0; let capped = false; let finishedEarly = false; proxyReq.on('response', function(proxyRes) { // Pre-check: if upstream claimed a Content-Length above the cap, // reject before consuming any body bytes. This is the common case // — most well-behaved upstreams declare length up-front. const declaredLength = parseInt(proxyRes.headers['content-length'], 10); if (Number.isFinite(declaredLength) && declaredLength > MAX_PROXY_RESPONSE_BYTES) { capped = true; proxyReq.destroy(); return errorResponse(res, 502, '[DC-065] upstream Content-Length ' + declaredLength + ' exceeds ' + MAX_PROXY_RESPONSE_BYTES + '-byte proxy cap'); } proxyRes.on('data', function(chunk) { if (capped || finishedEarly) return; totalBytes += chunk.length; if (totalBytes > MAX_PROXY_RESPONSE_BYTES) { capped = true; proxyReq.destroy(); if (!finishedEarly) { finishedEarly = true; if (!res.headersSent && !res.writableEnded) { errorResponse(res, 502, '[DC-065] upstream response exceeded ' + MAX_PROXY_RESPONSE_BYTES + '-byte proxy cap'); } } return; } chunks.push(chunk); }); proxyRes.on('end', function() { if (capped) return; finishedEarly = true; const body = Buffer.concat(chunks); const safeHeaders = sanitizeForwardedHeaders(proxyRes.headers); try { res.set(safeHeaders); } catch (_) { /* noop if socket closed */ } const safeStatus = coerceUpstreamStatus(proxyRes.statusCode); try { res.status(safeStatus); res.end(body); } catch (_) { /* socket may be closed */ } }); proxyRes.on('error', function() { if (!finishedEarly) { finishedEarly = true; try { if (!res.headersSent) res.status(502).end(); else res.end(); } catch (_) { /* socket may be closed */ } } }); }); proxyReq.on('error', function(e) { if (!finishedEarly) { finishedEarly = true; if (!res.headersSent && !res.writableEnded) { errorResponse(res, 502, e.message); } } }); proxyReq.setTimeout(15000, function() { proxyReq.destroy(); if (!finishedEarly && !res.headersSent && !res.writableEnded) { finishedEarly = true; errorResponse(res, 504, 'gateway timeout'); } }); } if (['POST', 'PUT', 'PATCH'].includes(method)) { const body = JSON.stringify(req.body); headers['Content-Type'] = 'application/json'; headers['Content-Length'] = Buffer.byteLength(body); const proxyReq = http.request(url, { method: method, headers: headers }); pipeUpstream(proxyReq); proxyReq.on('error', function() { /* surface handled in pipeUpstream */ }); proxyReq.write(body); proxyReq.end(); } else { const proxyReq = http.get(url, { headers: headers }); pipeUpstream(proxyReq); proxyReq.on('error', function() { /* surface handled in pipeUpstream */ }); } } // ── GET /openclaw/status ──────────────────────────────────────────────── router.get('/status', asyncHandler(async function(req, res) { const container = await findOpenClawContainer(); if (!container) { return ok(res, { deployed: false }); } const token = await getGatewayToken(container.Id); const port = await getContainerPort(container.Id); const baseUrl = 'http://localhost:' + port; const health = await gatewayHealth(baseUrl, token); ok(res, { deployed: true, container: { id: container.Id.slice(0, 12), name: container.Name, state: container.State, status: container.Status, created: container.Created, image: container.Image }, gateway: { url: baseUrl, port: port, healthy: health.ok, healthData: health.data || null, tokenSet: !!token } }); })); // ── POST /openclaw/deploy ─────────────────────────────────────────────── router.post('/deploy', asyncHandler(async function(req, res) { const existing = await findOpenClawContainer(); if (existing) { return conflict(res, 'OpenClaw is already deployed'); } const image = 'ghcr.io/nousresearch/openclaw:latest'; const name = 'openclaw-' + Date.now(); const gatewayToken = generateToken(); // Pull image log.info('Pulling ' + image + '...'); try { await new Promise(function(resolve, reject) { docker.client.pull(image, function(err, stream) { if (err) return reject(err); docker.client.modem.followProgress(stream, function(err2) { if (err2) return reject(err2); resolve(); }); }); }); } catch(e) { log.error('OpenClaw pull failed: ' + e.message); return errorResponse(res, 500, 'Failed to pull image: ' + e.message); } // Create + start container try { const container = await docker.client.createContainer({ name: name, Image: image, Env: [ 'OPENCLAW_GATEWAY_MODE=local', 'OPENCLAW_GATEWAY_TOKEN=' + gatewayToken ], HostConfig: { PortBindings: { '18792/tcp': [{ HostPort: '18792' }] }, RestartPolicy: { Name: 'unless-stopped' }, Labels: { 'dashcaddy.managed': 'true', 'dashcaddy.app': 'openclaw' } }, ExposedPorts: { '18792/tcp': {} } }); await container.start(); log.info('OpenClaw deployed: ' + container.id.slice(0, 12)); ok(res, { deployed: true, container: { id: container.id.slice(0, 12), name: name }, gateway: { url: 'http://localhost:18792', token: gatewayToken } }); } catch(e) { log.error('OpenClaw deploy failed: ' + e.message); errorResponse(res, 500, 'Deploy failed: ' + e.message); } })); // ── GET /openclaw/proxy/* ─────────────────────────────────────────────── router.get('/proxy/*', asyncHandler(async function(req, res) { const container = await findOpenClawContainer(); if (!container) return notFound(res, 'OpenClaw not deployed'); const token = await getGatewayToken(container.Id); const port = await getContainerPort(container.Id); const baseUrl = 'http://localhost:' + port; const path = req.params[0]; proxyRequest(req, res, baseUrl, path, token); })); // ── POST /openclaw/proxy/* ────────────────────────────────────────────── router.post('/proxy/*', asyncHandler(async function(req, res) { const container = await findOpenClawContainer(); if (!container) return notFound(res, 'OpenClaw not deployed'); const token = await getGatewayToken(container.Id); const port = await getContainerPort(container.Id); const baseUrl = 'http://localhost:' + port; const path = req.params[0]; proxyRequest(req, res, baseUrl, path, token); })); // ── DELETE /openclaw ─────────────────────────────────────────────────── router.delete('/', asyncHandler(async function(req, res) { const container = await findOpenClawContainer(); if (!container) return notFound(res, 'OpenClaw not deployed'); try { const c = docker.client.container(container.Id); await c.stop().catch(function() {}); await c.remove({ force: true }); log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed'); ok(res, { message: 'OpenClaw removed' }); } catch(e) { log.error('Failed to remove OpenClaw: ' + e.message); errorResponse(res, 500, e.message); } })); return router; }; // ── token generator ────────────────────────────────────────────────────────── function generateToken() { return crypto.randomBytes(24).toString('base64url'); }