From 79f897f8acb097c9f6023a30cd13e3e255770b71 Mon Sep 17 00:00:00 2001 From: MrSphay Date: Sun, 17 May 2026 14:14:45 +0200 Subject: [PATCH] fix: harden terminal action endpoints --- .env.example | 3 + README.md | 18 +++++ crucix.config.mjs | 3 + dashboard/public/jarvis.html | 46 ++++++++++++ server.mjs | 141 +++++++++++++++++++++++++++++++++-- test/fetch-utils.test.mjs | 20 +++++ 6 files changed, 223 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 3ea7f45..9147f47 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,9 @@ REFRESH_INTERVAL_MINUTES=15 AUTO_OPEN_BROWSER=false STALE_DATA_MAX_AGE_MINUTES=60 SWEEP_TOKEN= +TERMINAL_ACTIONS_ENABLED=true +TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000 +TERMINAL_ACTION_RATE_LIMIT_MAX=10 BRIEF_VERBOSITY=standard # LLM layer diff --git a/README.md b/README.md index e476fd8..4d52481 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,9 @@ REFRESH_INTERVAL_MINUTES=15 AUTO_OPEN_BROWSER=false STALE_DATA_MAX_AGE_MINUTES=60 SWEEP_TOKEN= +TERMINAL_ACTIONS_ENABLED=true +TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000 +TERMINAL_ACTION_RATE_LIMIT_MAX=10 BRIEF_VERBOSITY=standard LLM_PROVIDER=openrouter @@ -187,6 +190,21 @@ LLM_MODEL=your-model For Pangolin or another reverse proxy, forward HTTP traffic to `intelligence-terminal:3117` (or the `PORT` you set). Missing API keys do not crash sweeps; affected sources are reported as degraded in `/api/health`. +#### Terminal Action Exposure + +`POST /api/action` and `POST /api/sweep` can trigger operational actions such as manual sweeps. The dashboard has a **SET TOKEN** control that stores your `SWEEP_TOKEN` in browser local storage and sends it as the `x-crucix-token` header; do not put action tokens in URLs. + +Recommended settings: + +| Deployment | Settings | +| --- | --- | +| Private local machine | `NODE_ENV=development`, optional `SWEEP_TOKEN`, optional `TERMINAL_ACTIONS_ENABLED=true`. Localhost can run actions without a token for development. | +| Private LAN / Dockge | Set a strong `SWEEP_TOKEN`, keep `TERMINAL_ACTIONS_ENABLED=true`, expose only to trusted clients. | +| Pangolin-authenticated reverse proxy | Set a strong `SWEEP_TOKEN`, keep Pangolin auth in front, use the dashboard **SET TOKEN** flow once per browser. | +| Public internet | Do not expose Terminal Actions directly. If exposure is unavoidable, require `SWEEP_TOKEN`, keep proxy authentication enabled, lower `TERMINAL_ACTION_RATE_LIMIT_MAX`, and monitor server audit logs. | + +Action endpoints reject cross-origin POST origins, apply a small in-memory per-IP rate limit, and write sanitized audit lines without logging the token. + #### Build And Publish Your Gitea Image ```bash diff --git a/crucix.config.mjs b/crucix.config.mjs index 8792df3..3c15e48 100644 --- a/crucix.config.mjs +++ b/crucix.config.mjs @@ -24,6 +24,9 @@ export default { autoOpenBrowser: boolEnv('AUTO_OPEN_BROWSER', false), staleDataMaxAgeMinutes: intEnv('STALE_DATA_MAX_AGE_MINUTES', 60), sweepToken: process.env.SWEEP_TOKEN || null, + terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', !!process.env.SWEEP_TOKEN || process.env.NODE_ENV !== 'production'), + terminalActionRateLimitWindowMs: intEnv('TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS', 60_000), + terminalActionRateLimitMax: intEnv('TERMINAL_ACTION_RATE_LIMIT_MAX', 10), llm: { provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 921b3fd..5f03888 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -406,6 +406,7 @@ let layerModes = JSON.parse(localStorage.getItem('crucix_layer_modes') || '{}'); let spaceDisplayMode = localStorage.getItem('crucix_space_display') || 'icons'; let currentRegion = 'world'; let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH; +const terminalActionTokenKey = 'crucix_terminal_action_token'; const layerTypeMap = { air: ['air'], @@ -606,6 +607,7 @@ function renderTopbar(){ const ts = new Date(D.meta.timestamp); const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase(); const timeStr = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}); + const hasActionToken = !!getTerminalActionToken(); document.getElementById('topbar').innerHTML=`
CRUCIX MONITOR @@ -618,12 +620,56 @@ function renderTopbar(){ ${d} ${timeStr} ${t('dashboard.sources','SOURCES')} ${D.meta.sourcesOk}/${D.meta.sourcesQueried} ${D.delta?.summary ? `${t('dashboard.delta','DELTA')} ${D.delta.summary.direction==='risk-off'?'▲ '+t('dashboard.riskOff','RISK-OFF'):D.delta.summary.direction==='risk-on'?'▼ '+t('dashboard.riskOn','RISK-ON'):'◆ '+t('dashboard.mixed','MIXED')}` : ''} + + + ${t('dashboard.highAlert','HIGH ALERT')}
`; renderRegionControls(); } +function getTerminalActionToken(){ + return localStorage.getItem(terminalActionTokenKey) || ''; +} + +function configureTerminalActionToken(){ + const next = window.prompt('Terminal action token (SWEEP_TOKEN). Leave empty to clear.', getTerminalActionToken()); + if(next === null) return; + const clean = next.trim(); + if(clean) localStorage.setItem(terminalActionTokenKey, clean); + else localStorage.removeItem(terminalActionTokenKey); + renderTopbar(); +} + +async function runTerminalAction(action){ + let token = getTerminalActionToken(); + if(!token && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1'){ + configureTerminalActionToken(); + token = getTerminalActionToken(); + if(!token) return; + } + + const headers = {'Content-Type':'application/json'}; + if(token) headers['x-crucix-token'] = token; + try{ + const response = await fetch('/api/action', { + method:'POST', + headers, + body:JSON.stringify({action}) + }); + const payload = await response.json().catch(() => ({})); + if(!response.ok) throw new Error(payload.error || `Action failed (${response.status})`); + if(action === 'status'){ + window.alert(`Terminal actions OK. Health: ${payload.health?.status || 'unknown'}`); + } else if(action === 'sweep'){ + window.alert(payload.status === 'accepted' ? 'Sweep accepted.' : `Sweep status: ${payload.status || 'unknown'}`); + } + }catch(err){ + window.alert(`Terminal action failed: ${err.message}`); + } +} + // === LEFT RAIL === function layerMode(key){ return layerModes[key] || 'normal'; } function layerModeLabel(key){ return layerMode(key) === 'focus' ? 'focused' : layerMode(key) === 'hidden' ? 'hidden' : 'normal'; } diff --git a/server.mjs b/server.mjs index 8f46df8..995ea10 100644 --- a/server.mjs +++ b/server.mjs @@ -39,6 +39,7 @@ let sweepStartedAt = null; // Timestamp when current/last sweep started let sweepInProgress = false; const startTime = Date.now(); const sseClients = new Set(); +const terminalActionBuckets = new Map(); // === Delta/Memory === const memory = new MemoryManager(RUNS_DIR); @@ -289,14 +290,34 @@ app.get('/api/metrics', (req, res) => { }); app.post('/api/sweep', express.json(), (req, res) => { - const remote = req.ip || ''; - const local = remote.includes('127.0.0.1') || remote === '::1' || remote === '::ffff:127.0.0.1'; - const token = req.get('x-crucix-token') || req.query.token || req.body?.token; - if (config.sweepToken && token !== config.sweepToken) return res.status(401).json({ error: 'Invalid sweep token' }); - if (!config.sweepToken && !local) return res.status(403).json({ error: 'Manual sweep is local-only unless SWEEP_TOKEN is set' }); - if (sweepInProgress) return res.status(409).json({ status: 'already_running', sweepStartedAt }); - runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message)); - res.status(202).json({ status: 'accepted' }); + const guard = authorizeTerminalAction(req, res, 'sweep'); + if (!guard.ok) return; + triggerSweepAction(req, res, 'sweep'); +}); + +app.post('/api/action', express.json(), (req, res) => { + const action = String(req.body?.action || req.body?.command || '').trim().toLowerCase(); + const guard = authorizeTerminalAction(req, res, action || 'unknown'); + if (!guard.ok) return; + + if (action === 'status') { + auditTerminalAction(req, 'status', 'ok'); + return res.json({ status: 'ok', health: buildHealth() }); + } + + if (action === 'brief') { + if (!currentData) { + auditTerminalAction(req, 'brief', 'rejected', 'no_data'); + return res.status(503).json({ error: 'No data yet - first sweep in progress' }); + } + auditTerminalAction(req, 'brief', 'ok'); + return res.json({ status: 'ok', brief: buildBrief(currentData) }); + } + + if (action === 'sweep') return triggerSweepAction(req, res, 'action:sweep'); + + auditTerminalAction(req, action || 'unknown', 'rejected', 'unknown_action'); + return res.status(400).json({ error: 'Unknown action', allowed: ['status', 'brief', 'sweep'] }); }); // API: available locales @@ -327,6 +348,108 @@ function broadcast(data) { } } +function requestIp(req) { + return req.ip || req.socket?.remoteAddress || 'unknown'; +} + +function isLocalRequest(req) { + const remote = requestIp(req); + return remote === '::1' + || remote === '127.0.0.1' + || remote === '::ffff:127.0.0.1' + || remote.startsWith('127.') + || remote === 'localhost'; +} + +function sameOriginPost(req) { + const origin = req.get('origin'); + if (!origin) return true; + try { + const originUrl = new URL(origin); + const host = req.get('host'); + return host && originUrl.host === host; + } catch { + return false; + } +} + +function actionToken(req) { + return req.get('x-crucix-token') || req.body?.token || null; +} + +function auditTerminalAction(req, action, outcome, detail = null) { + const suffix = detail ? ` detail=${detail}` : ''; + console.log(`[Crucix][audit] terminal_action action=${action || 'unknown'} outcome=${outcome} ip=${requestIp(req)}${suffix}`); +} + +function rateLimitTerminalAction(req, action) { + const now = Date.now(); + const windowMs = Math.max(1000, config.terminalActionRateLimitWindowMs || 60_000); + const max = Math.max(1, config.terminalActionRateLimitMax || 10); + const key = `${requestIp(req)}:${action}`; + const bucket = terminalActionBuckets.get(key); + if (!bucket || now > bucket.resetAt) { + terminalActionBuckets.set(key, { count: 1, resetAt: now + windowMs }); + return { ok: true }; + } + bucket.count += 1; + if (bucket.count > max) { + return { ok: false, retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000) }; + } + return { ok: true }; +} + +function authorizeTerminalAction(req, res, action) { + const rate = rateLimitTerminalAction(req, action); + if (!rate.ok) { + auditTerminalAction(req, action, 'rejected', 'rate_limited'); + res.set('Retry-After', String(rate.retryAfterSeconds)); + res.status(429).json({ error: 'Too many terminal actions', retryAfterSeconds: rate.retryAfterSeconds }); + return { ok: false }; + } + + if (!sameOriginPost(req)) { + auditTerminalAction(req, action, 'rejected', 'csrf_origin'); + res.status(403).json({ error: 'Origin mismatch' }); + return { ok: false }; + } + + const local = isLocalRequest(req); + const token = actionToken(req); + if (!config.terminalActionsEnabled) { + auditTerminalAction(req, action, 'rejected', 'disabled'); + res.status(403).json({ error: 'Terminal actions are disabled' }); + return { ok: false }; + } + + if (config.sweepToken) { + if (token !== config.sweepToken) { + auditTerminalAction(req, action, 'rejected', 'invalid_token'); + res.status(401).json({ error: 'Invalid terminal action token' }); + return { ok: false }; + } + return { ok: true }; + } + + if (!local) { + auditTerminalAction(req, action, 'rejected', 'missing_token'); + res.status(403).json({ error: 'Terminal actions are local-only unless SWEEP_TOKEN is set' }); + return { ok: false }; + } + + return { ok: true }; +} + +function triggerSweepAction(req, res, auditAction) { + if (sweepInProgress) { + auditTerminalAction(req, auditAction, 'rejected', 'already_running'); + return res.status(409).json({ status: 'already_running', sweepStartedAt }); + } + auditTerminalAction(req, auditAction, 'accepted'); + runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message)); + return res.status(202).json({ status: 'accepted' }); +} + function dataAgeMs() { const ts = currentData?.meta?.timestamp || lastSuccessfulSweepTime || lastSweepTime; const ms = ts ? Date.now() - new Date(ts).getTime() : null; @@ -376,6 +499,8 @@ function buildHealth() { llm: getLLMStatus(), telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId), discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl), + terminalActionsEnabled: config.terminalActionsEnabled, + terminalActionsTokenRequired: !!config.sweepToken, refreshIntervalMinutes: config.refreshIntervalMinutes, language: currentLanguage, memory: intelligenceStore.status(), diff --git a/test/fetch-utils.test.mjs b/test/fetch-utils.test.mjs index 2dcee45..89c5a79 100644 --- a/test/fetch-utils.test.mjs +++ b/test/fetch-utils.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs'; test('safeFetch reports HTML as degraded JSON response', async () => { @@ -34,3 +35,22 @@ test('safeFetchText returns text and byte count', async () => { globalThis.fetch = originalFetch; } }); + +test('terminal action endpoints avoid URL tokens and include hardening gates', () => { + const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8'); + assert.match(server, /app\.post\('\/api\/action'/); + assert.match(server, /app\.post\('\/api\/sweep'/); + assert.match(server, /x-crucix-token/); + assert.match(server, /sameOriginPost/); + assert.match(server, /rateLimitTerminalAction/); + assert.match(server, /auditTerminalAction/); + assert.doesNotMatch(server, /req\.query\.token/); +}); + +test('dashboard exposes token configuration flow without devtools edits', () => { + const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8'); + assert.match(html, /configureTerminalActionToken/); + assert.match(html, /crucix_terminal_action_token/); + assert.match(html, /x-crucix-token/); + assert.match(html, /SET TOKEN/); +});