fix(security): enforce role auth on /terminal SSH namespace (v0.16.8-beta)

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 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-07-14 08:34:09 -07:00
co-authored by Claude Opus 4.8
parent 79e97e14cc
commit 27560fa1d6
7 changed files with 68 additions and 16 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.16.7-beta",
"version": "0.16.8-beta",
"private": true,
"type": "module",
"scripts": {
+4 -2
View File
@@ -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();
+10 -8
View File
@@ -180,14 +180,16 @@ export default function DeviceDetailPage() {
<ExternalLink className="w-4 h-4" />
<span className="hidden sm:inline">Web Admin</span>
</button>
<button
onClick={() => setShowTerminal(true)}
className="btn-secondary flex items-center gap-2 text-sm"
title="Open SSH terminal"
>
<TerminalSquare className="w-4 h-4" />
<span className="hidden sm:inline">Terminal</span>
</button>
{canWrite && (
<button
onClick={() => setShowTerminal(true)}
className="btn-secondary flex items-center gap-2 text-sm"
title="Open SSH terminal"
>
<TerminalSquare className="w-4 h-4" />
<span className="hidden sm:inline">Terminal</span>
</button>
)}
{canWrite && (
<button
onClick={() => syncMutation.mutate()}
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.16.7 Beta';
export const APP_VERSION = 'v0.16.8 Beta';