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
@@ -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.
|
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.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.16.7-beta",
|
"version": "0.16.8-beta",
|
||||||
"description": "Mikrotik Network Management Platform - Backend API",
|
"description": "Mikrotik Network Management Platform - Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+50
-2
@@ -24,7 +24,7 @@ import {
|
|||||||
stopBulkAddWorker,
|
stopBulkAddWorker,
|
||||||
} from './services/DeviceBulkAddWorker';
|
} from './services/DeviceBulkAddWorker';
|
||||||
import { netflowCollector } from './services/netflow/NetflowCollector';
|
import { netflowCollector } from './services/netflow/NetflowCollector';
|
||||||
import { verifyToken } from './middleware/auth';
|
import { verifyToken, type AuthPayload } from './middleware/auth';
|
||||||
import { decrypt } from './utils/crypto';
|
import { decrypt } from './utils/crypto';
|
||||||
import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins';
|
import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins';
|
||||||
|
|
||||||
@@ -81,11 +81,46 @@ io.on('connection', (socket) => {
|
|||||||
// ─── SSH Terminal namespace ────────────────────────────────────────────────────
|
// ─── SSH Terminal namespace ────────────────────────────────────────────────────
|
||||||
const terminalNs = io.of('/terminal');
|
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<number, number[]>();
|
||||||
|
|
||||||
|
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) => {
|
terminalNs.use((socket, next) => {
|
||||||
const token = (socket.handshake.auth as { token?: string })?.token;
|
const token = (socket.handshake.auth as { token?: string })?.token;
|
||||||
if (!token) return next(new Error('No token'));
|
if (!token) return next(new Error('No token'));
|
||||||
try {
|
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();
|
next();
|
||||||
} catch {
|
} catch {
|
||||||
next(new Error('Invalid or expired token'));
|
next(new Error('Invalid or expired token'));
|
||||||
@@ -95,10 +130,22 @@ terminalNs.use((socket, next) => {
|
|||||||
terminalNs.on('connection', (socket) => {
|
terminalNs.on('connection', (socket) => {
|
||||||
let sshClient: SshClient | null = null;
|
let sshClient: SshClient | null = null;
|
||||||
let shellStream: ClientChannel | 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 }) => {
|
socket.on('start', async (payload: { deviceId: number; cols?: number; rows?: number }) => {
|
||||||
const { deviceId, cols = 80, rows = 24 } = payload;
|
const { deviceId, cols = 80, rows = 24 } = payload;
|
||||||
try {
|
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<{
|
const device = await queryOne<{
|
||||||
ip_address: string;
|
ip_address: string;
|
||||||
ssh_port: number | null;
|
ssh_port: number | null;
|
||||||
@@ -124,6 +171,7 @@ terminalNs.on('connection', (socket) => {
|
|||||||
(err, stream) => {
|
(err, stream) => {
|
||||||
if (err) { socket.emit('error', err.message); return; }
|
if (err) { socket.emit('error', err.message); return; }
|
||||||
shellStream = stream;
|
shellStream = stream;
|
||||||
|
auditTerminal(user, clientIp, `opened SSH shell on device ${deviceId} (${device.ip_address})`, deviceId);
|
||||||
socket.emit('ready');
|
socket.emit('ready');
|
||||||
|
|
||||||
stream.on('data', (data: Buffer) => {
|
stream.on('data', (data: Buffer) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.16.7-beta",
|
"version": "0.16.8-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { metricsApi, eventsApi, devicesApi, clientsApi, trafficApi, operationsAp
|
|||||||
import type { OpsAttentionItem, OpsCapacityRow, OpsActivityItem } from '../services/api';
|
import type { OpsAttentionItem, OpsCapacityRow, OpsActivityItem } from '../services/api';
|
||||||
import TerminalModal from '../components/TerminalModal';
|
import TerminalModal from '../components/TerminalModal';
|
||||||
import { useSocket } from '../hooks/useSocket';
|
import { useSocket } from '../hooks/useSocket';
|
||||||
|
import { useCanWrite } from '../hooks/useCanWrite';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { format, formatDistanceToNow } from 'date-fns';
|
import { format, formatDistanceToNow } from 'date-fns';
|
||||||
import type { Device, DeviceEvent } from '../types';
|
import type { Device, DeviceEvent } from '../types';
|
||||||
@@ -592,6 +593,7 @@ function OperationsView({
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const canWrite = useCanWrite();
|
||||||
const allOnline = (summary?.devices.offline ?? 0) === 0 && (summary?.devices.total ?? 0) > 0;
|
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 statusText = allOnline ? "Everything's running." : `${summary?.devices.offline} device${summary?.devices.offline !== 1 ? 's' : ''} unreachable.`;
|
||||||
const statusColor = allOnline ? 'var(--accent)' : 'var(--warn)';
|
const statusColor = allOnline ? 'var(--accent)' : 'var(--warn)';
|
||||||
@@ -652,8 +654,8 @@ function OperationsView({
|
|||||||
const ok = r.data.results.filter(x => x.ok).length;
|
const ok = r.data.results.filter(x => x.ok).length;
|
||||||
return `Backed up ${ok}/${r.data.total} device${r.data.total !== 1 ? 's' : ''}`;
|
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)',
|
...(canWrite ? [{ key: 'terminal', label: 'Open terminal', sub: 'Pick a device', icon: Terminal, color: 'var(--violet)',
|
||||||
run: () => { setActionMsg(null); setPickTerminal(true); } },
|
run: () => { setActionMsg(null); setPickTerminal(true); } }] : []),
|
||||||
{ key: 'sync', label: 'Sync config', sub: 'Pull latest /export', icon: RefreshCw, color: 'var(--ink-2)',
|
{ key: 'sync', label: 'Sync config', sub: 'Pull latest /export', icon: RefreshCw, color: 'var(--ink-2)',
|
||||||
run: () => runAction('sync', async () => {
|
run: () => runAction('sync', async () => {
|
||||||
const r = await operationsApi.syncAll();
|
const r = await operationsApi.syncAll();
|
||||||
|
|||||||
@@ -180,14 +180,16 @@ export default function DeviceDetailPage() {
|
|||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Web Admin</span>
|
<span className="hidden sm:inline">Web Admin</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
{canWrite && (
|
||||||
onClick={() => setShowTerminal(true)}
|
<button
|
||||||
className="btn-secondary flex items-center gap-2 text-sm"
|
onClick={() => setShowTerminal(true)}
|
||||||
title="Open SSH terminal"
|
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>
|
<TerminalSquare className="w-4 h-4" />
|
||||||
</button>
|
<span className="hidden sm:inline">Terminal</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<button
|
<button
|
||||||
onClick={() => syncMutation.mutate()}
|
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