From 27560fa1d676474011e3e96248f456445aa8bb44 Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:34:09 -0700 Subject: [PATCH] fix(security): enforce role auth on /terminal SSH namespace (v0.16.8-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Socket.IO /terminal namespace validated the JWT but discarded the role, so a read-only viewer (403'd on every REST write) could connect and open an interactive SSH shell on any managed device using the device's stored router-admin credentials — a full bypass of the role model (reported CVSS 9.6). - Gate the namespace handshake by role: only admin/operator may connect; viewers are rejected with "Console access denied for this role". - Re-check the role at the start handler (defense in depth) and attach the verified payload to socket.data. - Audit-log terminal session opens (and rate-limit hits) to audit_log. - Rate-limit shell starts per user (5 / 60s, in-memory sliding window). - Frontend: gate the Terminal button (DeviceDetailPage) and dashboard "Open terminal" quick action behind canWrite so viewers don't see it. Validated on the local docker stack: viewer rejected from /terminal, admin still connects; viewer remains 403 on POST /api/devices. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- backend/package.json | 2 +- backend/src/index.ts | 52 ++++++++++++++++++++++++- frontend/package.json | 2 +- frontend/src/pages/DashboardPage.tsx | 6 ++- frontend/src/pages/DeviceDetailPage.tsx | 18 +++++---- frontend/src/version.ts | 2 +- 7 files changed, 68 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 9361356..e86ef8d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface. -![Version](https://img.shields.io/badge/version-0.16.7_Beta-blue) +![Version](https://img.shields.io/badge/version-0.16.8_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) diff --git a/backend/package.json b/backend/package.json index 93f0e9f..a19faa0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.16.7-beta", + "version": "0.16.8-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/index.ts b/backend/src/index.ts index 0db9001..43f4f82 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -24,7 +24,7 @@ import { stopBulkAddWorker, } from './services/DeviceBulkAddWorker'; import { netflowCollector } from './services/netflow/NetflowCollector'; -import { verifyToken } from './middleware/auth'; +import { verifyToken, type AuthPayload } from './middleware/auth'; import { decrypt } from './utils/crypto'; import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins'; @@ -81,11 +81,46 @@ io.on('connection', (socket) => { // ─── SSH Terminal namespace ──────────────────────────────────────────────────── const terminalNs = io.of('/terminal'); +// Roles allowed to open an interactive device shell. The stored SSH account is +// the router admin, so console access mirrors requireWrite: viewers are rejected. +const TERMINAL_ROLES = new Set(['admin', 'operator']); + +// Per-user rate limit on shell starts (in-memory; sliding window). +const TERMINAL_START_LIMIT = 5; +const TERMINAL_START_WINDOW_MS = 60_000; +const terminalStartHistory = new Map(); + +function terminalStartAllowed(userId: number): boolean { + const now = Date.now(); + const recent = (terminalStartHistory.get(userId) ?? []).filter( + (t) => now - t < TERMINAL_START_WINDOW_MS + ); + if (recent.length >= TERMINAL_START_LIMIT) { + terminalStartHistory.set(userId, recent); + return false; + } + recent.push(now); + terminalStartHistory.set(userId, recent); + return true; +} + +function auditTerminal(user: AuthPayload, ip: string, summary: string, deviceId?: number): void { + query( + `INSERT INTO audit_log (user_id, username, method, path, entity_type, entity_id, summary, ip_address, status_code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [user.userId, user.username, 'SSH', '/terminal', 'device', deviceId ?? null, summary, ip, 200] + ).catch(() => {}); +} + terminalNs.use((socket, next) => { const token = (socket.handshake.auth as { token?: string })?.token; if (!token) return next(new Error('No token')); try { - verifyToken(token); + const payload = verifyToken(token); + if (!TERMINAL_ROLES.has(payload.role)) { + return next(new Error('Console access denied for this role')); + } + socket.data.user = payload; next(); } catch { next(new Error('Invalid or expired token')); @@ -95,10 +130,22 @@ terminalNs.use((socket, next) => { terminalNs.on('connection', (socket) => { let sshClient: SshClient | null = null; let shellStream: ClientChannel | null = null; + const user = socket.data.user as AuthPayload; + const clientIp = (socket.handshake.address ?? '').replace(/^::ffff:/, ''); socket.on('start', async (payload: { deviceId: number; cols?: number; rows?: number }) => { const { deviceId, cols = 80, rows = 24 } = payload; try { + // Defense in depth: re-check the connection's role at the sink, not just at handshake. + if (!user || !TERMINAL_ROLES.has(user.role)) { + socket.emit('error', 'Console access denied for this role'); + return; + } + if (!terminalStartAllowed(user.userId)) { + auditTerminal(user, clientIp, `terminal start rate-limited for device ${deviceId}`, deviceId); + socket.emit('error', 'Too many terminal sessions started. Please wait a moment and try again.'); + return; + } const device = await queryOne<{ ip_address: string; ssh_port: number | null; @@ -124,6 +171,7 @@ terminalNs.on('connection', (socket) => { (err, stream) => { if (err) { socket.emit('error', err.message); return; } shellStream = stream; + auditTerminal(user, clientIp, `opened SSH shell on device ${deviceId} (${device.ip_address})`, deviceId); socket.emit('ready'); stream.on('data', (data: Buffer) => { diff --git a/frontend/package.json b/frontend/package.json index 12aa617..408a0f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.16.7-beta", + "version": "0.16.8-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 5aa62f6..0e26390 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -15,6 +15,7 @@ import { metricsApi, eventsApi, devicesApi, clientsApi, trafficApi, operationsAp import type { OpsAttentionItem, OpsCapacityRow, OpsActivityItem } from '../services/api'; import TerminalModal from '../components/TerminalModal'; import { useSocket } from '../hooks/useSocket'; +import { useCanWrite } from '../hooks/useCanWrite'; import { useQueryClient } from '@tanstack/react-query'; import { format, formatDistanceToNow } from 'date-fns'; import type { Device, DeviceEvent } from '../types'; @@ -592,6 +593,7 @@ function OperationsView({ [key: string]: any; }) { const qc = useQueryClient(); + const canWrite = useCanWrite(); const allOnline = (summary?.devices.offline ?? 0) === 0 && (summary?.devices.total ?? 0) > 0; const statusText = allOnline ? "Everything's running." : `${summary?.devices.offline} device${summary?.devices.offline !== 1 ? 's' : ''} unreachable.`; const statusColor = allOnline ? 'var(--accent)' : 'var(--warn)'; @@ -652,8 +654,8 @@ function OperationsView({ const ok = r.data.results.filter(x => x.ok).length; return `Backed up ${ok}/${r.data.total} device${r.data.total !== 1 ? 's' : ''}`; }) }, - { key: 'terminal', label: 'Open terminal', sub: 'Pick a device', icon: Terminal, color: 'var(--violet)', - run: () => { setActionMsg(null); setPickTerminal(true); } }, + ...(canWrite ? [{ key: 'terminal', label: 'Open terminal', sub: 'Pick a device', icon: Terminal, color: 'var(--violet)', + run: () => { setActionMsg(null); setPickTerminal(true); } }] : []), { key: 'sync', label: 'Sync config', sub: 'Pull latest /export', icon: RefreshCw, color: 'var(--ink-2)', run: () => runAction('sync', async () => { const r = await operationsApi.syncAll(); diff --git a/frontend/src/pages/DeviceDetailPage.tsx b/frontend/src/pages/DeviceDetailPage.tsx index 86587a1..ab4b228 100644 --- a/frontend/src/pages/DeviceDetailPage.tsx +++ b/frontend/src/pages/DeviceDetailPage.tsx @@ -180,14 +180,16 @@ export default function DeviceDetailPage() { Web Admin - + {canWrite && ( + + )} {canWrite && (