diff --git a/dashboard/backend/app.py b/dashboard/backend/app.py index 543167863..bfb41459a 100644 --- a/dashboard/backend/app.py +++ b/dashboard/backend/app.py @@ -291,6 +291,10 @@ def unauthorized(): "/api/version", "/api/version/check", "/api/agents/active", + # Traefik forwardauth probe — validates the forwarded session cookie and + # returns 200 (allow) or 401 (deny). Must be PUBLIC_PATHS so this + # middleware doesn't reject it before the handler resolves the session. + "/api/auth/check", } def _try_api_token_auth(): diff --git a/dashboard/backend/routes/auth_routes.py b/dashboard/backend/routes/auth_routes.py index db84cf7eb..b4cf36038 100644 --- a/dashboard/backend/routes/auth_routes.py +++ b/dashboard/backend/routes/auth_routes.py @@ -47,6 +47,18 @@ def _as_text(value) -> str: return "" if value is None else str(value) +# ── ForwardAuth check — used by Traefik forwardauth middleware ─────────────── +# Returns 200 if the current session is authenticated, 401 otherwise. +# Traefik forwardauth: any 2xx = allow, any other status = deny (returns the +# response body + status directly to the client). +# This endpoint must be in PUBLIC_PATHS so auth_middleware doesn't block it. +@bp.route("/api/auth/check") +def auth_check(): + if current_user.is_authenticated: + return "", 200 + return jsonify({"error": "Authentication required"}), 401 + + # ── Setup (first run only) ─────────────────────────── @bp.route("/api/auth/needs-setup") diff --git a/dashboard/backend/routes/terminal_proxy.py b/dashboard/backend/routes/terminal_proxy.py index 17b5a6af9..9aef030f8 100644 --- a/dashboard/backend/routes/terminal_proxy.py +++ b/dashboard/backend/routes/terminal_proxy.py @@ -1,11 +1,11 @@ """Proxy HTTP and WebSocket traffic to the local terminal-server. The terminal-server (Node, dashboard/terminal-server/bin/server.js) binds to -a random port (commonly 32352) on 0.0.0.0. Browsers connecting to the -dashboard from a different host than `localhost` historically had to hit +a random port (commonly 32352) on 127.0.0.1. Browsers connecting to the +dashboard from a different host than ``localhost`` historically had to hit that port directly, which fails in three common scenarios: -1. Browsing via SSH tunnel (`ssh -L 8080:localhost:8080`) — only port 8080 +1. Browsing via SSH tunnel (``ssh -L 8080:localhost:8080``) — only port 8080 is forwarded, the random terminal-server port is not. 2. Browsing via a public tunnel (Tailscale Funnel, Cloudflare Tunnel, an nginx reverse proxy on a VPS) — only the dashboard port is exposed. @@ -19,6 +19,12 @@ This module is intentionally minimal — it forwards bytes both ways for HTTP and WebSocket; it does not inspect or rewrite payloads. + +Security: the WebSocket proxy appends the shared ``TERMINAL_WS_TOKEN`` as a +``?token=`` query param when connecting to the upstream terminal-server. This +lets the terminal-server's ``verifyClient`` hook reject unauthenticated +WebSocket upgrades even when coming through the Traefik route (defence-in- +depth layer 2). The token never reaches the browser. """ from __future__ import annotations @@ -26,6 +32,7 @@ import logging import os import threading +from urllib.parse import quote import requests from flask import Blueprint, Response, request, stream_with_context @@ -42,6 +49,11 @@ TERMINAL_HTTP_BASE = f"http://{TERMINAL_HOST}:{TERMINAL_PORT}" TERMINAL_WS_BASE = f"ws://{TERMINAL_HOST}:{TERMINAL_PORT}" +# Shared secret for terminal-server WebSocket auth (defence-in-depth layer 2). +# The proxy appends this as ?token= when connecting upstream; the +# terminal-server's verifyClient rejects connections without a valid token. +_TERMINAL_WS_TOKEN = os.environ.get("TERMINAL_WS_TOKEN", "").strip() + # Hop-by-hop headers that must not be forwarded (RFC 7230 §6.1). _HOP_BY_HOP = frozenset( { @@ -153,7 +165,11 @@ def proxy_ws(client_ws): pass return - target = f"{TERMINAL_WS_BASE}/ws" + # Append the shared token so the terminal-server's verifyClient + # accepts this connection (defence-in-depth — auth already enforced + # above via current_user.is_authenticated). + token_qs = f"?token={quote(_TERMINAL_WS_TOKEN, safe='')}" if _TERMINAL_WS_TOKEN else "" + target = f"{TERMINAL_WS_BASE}/ws{token_qs}" try: upstream = create_connection(target, timeout=10) except Exception as exc: diff --git a/dashboard/terminal-server/src/server.js b/dashboard/terminal-server/src/server.js index f5f8605ad..f5028003a 100644 --- a/dashboard/terminal-server/src/server.js +++ b/dashboard/terminal-server/src/server.js @@ -1,5 +1,6 @@ const express = require('express'); const http = require('http'); +const crypto = require('crypto'); const path = require('path'); const fs = require('fs'); const WebSocket = require('ws'); @@ -11,6 +12,37 @@ const SessionStore = require('./utils/session-store'); const ChatLogger = require('./utils/chat-logger'); const { loadProviderConfig, getProviderMode } = require('./provider-config'); +// --------------------------------------------------------------------------- +// WS token auth — shared secret between dashboard (Flask) and terminal-server. +// Set TERMINAL_WS_TOKEN in env (generated by entrypoint on first boot, same +// as EVONEXUS_SECRET_KEY derivation pattern). Connections without a valid +// ?token= query param are rejected at the WS upgrade step. +// --------------------------------------------------------------------------- +const _WS_TOKEN = (process.env.TERMINAL_WS_TOKEN || '').trim(); + +/** + * Constant-time comparison of two strings to prevent timing attacks. + * Returns true only when both token and expected are non-empty and equal. + */ +function _verifyWsToken(provided) { + if (!_WS_TOKEN) { + // No token configured — running in dev mode without auth enforcement. + // Log a warning so operators notice. + console.warn('[security] TERMINAL_WS_TOKEN not set — WebSocket auth disabled'); + return true; + } + if (!provided) return false; + try { + // Use constant-time comparison; pad to same length before comparing. + const a = Buffer.from(provided.padEnd(128), 'utf8'); + const b = Buffer.from(_WS_TOKEN.padEnd(128), 'utf8'); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); + } catch { + return false; + } +} + class TerminalServer { constructor(options = {}) { this.port = options.port || 32352; @@ -253,7 +285,10 @@ class TerminalServer { } setupExpress() { - this.app.use(cors()); + // CORS: restrict to the dashboard origin when EVONEXUS_DASHBOARD_ORIGIN + // is set (production). Falls back to wide-open for local dev. + const dashboardOrigin = (process.env.EVONEXUS_DASHBOARD_ORIGIN || '').trim(); + this.app.use(cors(dashboardOrigin ? { origin: dashboardOrigin, credentials: true } : {})); this.app.use(express.json()); this.app.get('/api/health', (req, res) => { @@ -531,11 +566,28 @@ class TerminalServer { await this.ready; const server = http.createServer(this.app); - this.wss = new WebSocket.Server({ server }); + // verifyClient runs during the HTTP→WebSocket upgrade, before the + // connection is accepted. Reject any upgrade without a valid token. + this.wss = new WebSocket.Server({ + server, + verifyClient: ({ req }, cb) => { + const url = new URL(req.url, 'ws://localhost'); + const token = url.searchParams.get('token'); + if (_verifyWsToken(token)) { + cb(true); + } else { + // 401 Unauthorized — browser sees this as a failed WS handshake + cb(false, 401, 'Unauthorized'); + } + }, + }); this.wss.on('connection', (ws, req) => this.handleWebSocketConnection(ws, req)); + // Bind to loopback only — external callers must go through the Flask + // proxy (which enforces session auth) or the Traefik forwardauth chain. + const bindHost = process.env.TERMINAL_BIND_HOST || '127.0.0.1'; return new Promise((resolve, reject) => { - server.listen(this.port, '0.0.0.0', (err) => { + server.listen(this.port, bindHost, (err) => { if (err) return reject(err); this.server = server; resolve(server); diff --git a/dashboard/terminal-server/test/server.test.js b/dashboard/terminal-server/test/server.test.js index e74363f16..89a7e1807 100644 --- a/dashboard/terminal-server/test/server.test.js +++ b/dashboard/terminal-server/test/server.test.js @@ -3,6 +3,8 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const test = require('node:test'); +const http = require('http'); +const WebSocket = require('ws'); const SessionStore = require('../src/utils/session-store'); const { TerminalServer } = require('../src/server'); @@ -114,3 +116,111 @@ test('TerminalServer purges stale sessions and reports health', async () => { server.close(); }); + +// --------------------------------------------------------------------------- +// WebSocket token auth tests +// --------------------------------------------------------------------------- + +/** + * Start a real TerminalServer on a random port and return { server, addr }. + * The caller must call server.close() in cleanup. + */ +async function startTestServer(token) { + const orig = process.env.TERMINAL_WS_TOKEN; + if (token !== undefined) { + process.env.TERMINAL_WS_TOKEN = token; + } else { + delete process.env.TERMINAL_WS_TOKEN; + } + + // Re-require server so it picks up the updated env (module is cached, so + // we need to invalidate the cache for the token to take effect). + Object.keys(require.cache).forEach((k) => { + if (k.includes('terminal-server') && !k.includes('node_modules')) { + delete require.cache[k]; + } + }); + const { TerminalServer: TS } = require('../src/server'); + + const srv = new TS({ port: 0, dev: false, sessionGcIntervalMs: 0, autoSaveIntervalMs: 0 }); + const httpServer = await srv.start(); + + // Restore env + if (orig !== undefined) { + process.env.TERMINAL_WS_TOKEN = orig; + } else { + delete process.env.TERMINAL_WS_TOKEN; + } + + const addr = httpServer.address(); + return { server: srv, addr }; +} + +function wsConnect(addr, tokenQuery) { + return new Promise((resolve, reject) => { + const url = `ws://127.0.0.1:${addr.port}/ws${tokenQuery ? `?token=${tokenQuery}` : ''}`; + const ws = new WebSocket(url); + ws.on('open', () => resolve({ ws, opened: true })); + ws.on('unexpected-response', (req, res) => { + resolve({ ws: null, opened: false, statusCode: res.statusCode }); + }); + ws.on('error', (err) => { + // Connection refused or similar — treat as rejection + resolve({ ws: null, opened: false, error: err.message }); + }); + // Timeout safety + setTimeout(() => reject(new Error('WS connect timeout')), 3000); + }); +} + +test('WS token auth — accepts connection with correct token', async () => { + const TOKEN = 'test-secret-abc123'; + const { server, addr } = await startTestServer(TOKEN); + + try { + const result = await wsConnect(addr, TOKEN); + assert.equal(result.opened, true, 'WebSocket should open with correct token'); + if (result.ws) result.ws.close(); + } finally { + server.close(); + } +}); + +test('WS token auth — rejects connection without token (401)', async () => { + const TOKEN = 'test-secret-abc123'; + const { server, addr } = await startTestServer(TOKEN); + + try { + const result = await wsConnect(addr, null); + assert.equal(result.opened, false, 'WebSocket should be rejected without token'); + assert.equal(result.statusCode, 401, 'Expected HTTP 401 on upgrade rejection'); + } finally { + server.close(); + } +}); + +test('WS token auth — rejects connection with wrong token (401)', async () => { + const TOKEN = 'test-secret-abc123'; + const { server, addr } = await startTestServer(TOKEN); + + try { + const result = await wsConnect(addr, 'wrong-token'); + assert.equal(result.opened, false, 'WebSocket should be rejected with wrong token'); + assert.equal(result.statusCode, 401, 'Expected HTTP 401 on upgrade rejection'); + } finally { + server.close(); + } +}); + +test('WS token auth — allows all connections when TERMINAL_WS_TOKEN is unset (dev mode)', async () => { + const { server, addr } = await startTestServer(undefined); + + try { + // No token in query — should still succeed in dev mode + const result = await wsConnect(addr, null); + assert.equal(result.opened, true, 'WebSocket should open in dev mode without token'); + if (result.ws) result.ws.close(); + } finally { + server.close(); + } +}); diff --git a/evonexus.stack.yml b/evonexus.stack.yml index 45d5931f7..0567e98f0 100644 --- a/evonexus.stack.yml +++ b/evonexus.stack.yml @@ -47,6 +47,11 @@ services: - EVONEXUS_PORT=8080 - TERMINAL_SERVER_PORT=32352 - FORWARDED_ALLOW_IPS=* + ## Shared secret for terminal-server WebSocket auth (defence-in-depth). + ## Set this to a random 64-char hex string (e.g. openssl rand -hex 32). + ## Both the dashboard proxy and the terminal-server must use the same value. + ## If unset, token verification is disabled (dev mode only). + - TERMINAL_WS_TOKEN=${TERMINAL_WS_TOKEN} deploy: placement: @@ -71,14 +76,21 @@ services: ## Terminal-server router — Node PTY (HTTP + WebSocket) on port 32352. ## Higher priority (10) so /terminal/* wins over the primary router. - ## The stripprefix middleware removes /terminal before forwarding - ## because the terminal-server exposes /api/... at its root. + ## + ## Auth chain: forwardauth → /api/auth/check (Flask, port 8080). + ## Traefik forwards the original request headers (including the session + ## cookie) to Flask. Flask returns 200 (authenticated) or 401 (denied). + ## On 401, Traefik returns the Flask response directly to the client + ## instead of forwarding to the terminal-server. + ## The stripprefix middleware then removes /terminal before forwarding. - traefik.http.routers.evonexus_terminal.rule=Host(`evonexus.example.com`) && PathPrefix(`/terminal`) - traefik.http.routers.evonexus_terminal.entrypoints=websecure - traefik.http.routers.evonexus_terminal.priority=10 - traefik.http.routers.evonexus_terminal.tls.certresolver=letsencryptresolver - traefik.http.routers.evonexus_terminal.service=evonexus_terminal - - traefik.http.routers.evonexus_terminal.middlewares=evonexus_terminal_strip + - traefik.http.routers.evonexus_terminal.middlewares=evonexus_terminal_auth,evonexus_terminal_strip + - traefik.http.middlewares.evonexus_terminal_auth.forwardauth.address=http://127.0.0.1:8080/api/auth/check + - traefik.http.middlewares.evonexus_terminal_auth.forwardauth.authResponseHeaders=X-User-Id,X-User-Role - traefik.http.middlewares.evonexus_terminal_strip.stripprefix.prefixes=/terminal - traefik.http.services.evonexus_terminal.loadbalancer.server.port=32352 - traefik.http.services.evonexus_terminal.loadbalancer.passHostHeader=true