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:
co-authored by
Claude Opus 4.8
parent
79e97e14cc
commit
27560fa1d6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.16.7-beta",
|
||||
"version": "0.16.8-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 @@
|
||||
export const APP_VERSION = 'v0.16.7 Beta';
|
||||
export const APP_VERSION = 'v0.16.8 Beta';
|
||||
|
||||
Reference in New Issue
Block a user