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
+50 -2
View File
@@ -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<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) => {
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) => {