feat(security): Security Center — firewall, NAT, bandwidth, posture, connections (v0.14.0-beta)

A dedicated top-level Security section plus a Meraki/UniFi-grade firewall built
on the full RouterOS feature set.

Security Center (new /security page)
- Fleet hardening scores, per-device posture list, and a "Common Findings"
  rollup aggregating identical issues across the fleet
- "Manage" deep-links into the device's Security tab (?tab= support added)

Firewall & NAT (per device + in Security Center)
- Friendly rule builder: Allow/Drop/Reject chips, Any/Address/Address-List
  pickers, port presets, connection-state chips, logging, live plain-English
  preview
- Address lists as reusable objects, referenced from rules
- Rule reordering (/ip/firewall/{filter,nat}/move); per-rule hit counters
  (print stats) with dead-rule highlighting + reset
- NAT wizards: Port Forward / Masquerade / 1:1 NAT + Custom
- Safe-apply lockout guard: backend 409 on an unscoped input drop/reject that
  would sever management; UI confirms before forcing (detectLockoutRisk, tested)

Access & security
- Security posture audit: graduated, de-duplicated scoring that never proposes
  disabling the management service the platform connects through (no self-
  lockout); switch-aware input-firewall severity; service hardening table
- Bandwidth: simple-queue CRUD + one-click "Limit this client" on client detail
- Active connections viewer

These L3 features are available on all managed RouterOS devices (routers,
switches, APs), not gated by device type.

Backend: DeviceCollector address-list/move/stats/connections/queues/service
methods; device routes for all of the above; firewallSafety + firewallSummary
utils with unit tests (19 new).

Security: bump nodemailer 8 -> 9.0.1 to clear GHSA-p6gq-j5cr-w38f (our
createTransport/sendMail usage is unaffected by the advisory's raw-option path).

Verified live against RouterOS devices; lint/typecheck/build/jest green in both
workspaces; npm audit --audit-level=high clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-06-18 09:24:22 -07:00
co-authored by Claude Opus 4.8
parent 623e54120d
commit ee9c9ff9f7
22 changed files with 1988 additions and 656 deletions
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mikrotik-manager-backend",
"version": "0.13.3-beta",
"version": "0.14.0-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mikrotik-manager-backend",
"version": "0.13.3-beta",
"version": "0.14.0-beta",
"dependencies": {
"@influxdata/influxdb-client": "^1.33.2",
"@influxdata/influxdb-client-apis": "^1.35.0",
@@ -21,7 +21,7 @@
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"nodemailer": "^8.0.5",
"nodemailer": "^9.0.1",
"otpauth": "^9.5.1",
"pg": "^8.11.3",
"qrcode": "^1.5.4",
@@ -6680,9 +6680,9 @@
}
},
"node_modules/nodemailer": {
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.13.3-beta",
"version": "0.14.0-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
@@ -26,7 +26,7 @@
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"nodemailer": "^8.0.5",
"nodemailer": "^9.0.1",
"otpauth": "^9.5.1",
"pg": "^8.11.3",
"qrcode": "^1.5.4",
+249 -3
View File
@@ -11,6 +11,7 @@ import type { CredentialPresetRow } from './credentialPresets';
import { createDeviceFromBody, type CreateDeviceInput, type CreateDeviceContext } from '../services/deviceCreation';
import { parsePort } from '../utils/parsePort';
import { safeConnectionError } from '../utils/safeClientError';
import { detectLockoutRisk } from '../utils/firewallSafety';
import { redis } from '../config/redis';
import { enqueueBulkAddJob, getBulkAddJobState } from '../services/DeviceBulkAddWorker';
@@ -649,14 +650,20 @@ function bodyToRosParams(body: Record<string, unknown>): Record<string, string>
// POST /api/devices/:id/firewall
router.post('/:id/firewall', requireWrite, async (req: Request, res: Response) => {
const { chain, action } = req.body;
const { chain, action, force } = req.body;
if (!chain || !action) return res.status(400).json({ error: 'chain and action are required' });
const params = bodyToRosParams(req.body);
// Safe-apply: refuse a self-lockout rule unless the operator confirms (force).
if (!force) {
const lock = detectLockoutRisk(params);
if (lock.risky) return res.status(409).json({ lockout: true, reason: lock.reason });
}
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
const collector = new DeviceCollector(deviceRow);
try {
await collector.connect();
await collector.addFirewallRule(bodyToRosParams(req.body));
await collector.addFirewallRule(params);
return res.status(201).json(await collector.getFirewallRules());
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
@@ -667,12 +674,51 @@ router.post('/:id/firewall', requireWrite, async (req: Request, res: Response) =
// PUT /api/devices/:id/firewall/:ruleId
router.put('/:id/firewall/:ruleId', requireWrite, async (req: Request, res: Response) => {
const params = bodyToRosParams(req.body);
if (!req.body.force) {
const lock = detectLockoutRisk(params);
if (lock.risky) return res.status(409).json({ lockout: true, reason: lock.reason });
}
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
const collector = new DeviceCollector(deviceRow);
try {
await collector.connect();
await collector.updateFirewallRule(req.params.ruleId, bodyToRosParams(req.body));
await collector.updateFirewallRule(req.params.ruleId, params);
return res.json(await collector.getFirewallRules());
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// POST /api/devices/:id/firewall/move { id, destination? }
router.post('/:id/firewall/move', requireWrite, async (req: Request, res: Response) => {
const { id, destination } = req.body;
if (!id) return res.status(400).json({ error: 'id is required' });
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
const collector = new DeviceCollector(deviceRow);
try {
await collector.connect();
await collector.moveFirewallRule(String(id), destination ? String(destination) : undefined);
return res.json(await collector.getFirewallRules());
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// POST /api/devices/:id/firewall/reset-counters
router.post('/:id/firewall/reset-counters', requireWrite, async (req: Request, res: Response) => {
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
const collector = new DeviceCollector(deviceRow);
try {
await collector.connect();
await collector.resetFirewallCounters();
return res.json(await collector.getFirewallRules());
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
@@ -783,6 +829,206 @@ router.delete('/:id/nat/:ruleId', requireWrite, async (req: Request, res: Respon
}
});
// POST /api/devices/:id/nat/move { id, destination? }
router.post('/:id/nat/move', requireWrite, async (req: Request, res: Response) => {
const { id, destination } = req.body;
if (!id) return res.status(400).json({ error: 'id is required' });
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
const collector = new DeviceCollector(deviceRow);
try {
await collector.connect();
await collector.moveNatRule(String(id), destination ? String(destination) : undefined);
return res.json(await collector.getNatRules());
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// ─── Firewall Address Lists ─────────────────────────────────────────────────────
// Shared helper for the security-feature routes below (DRYs the device fetch +
// connect/disconnect lifecycle the rest of this file uses inline).
async function withCollector<T>(
id: string,
res: Response,
fn: (c: DeviceCollector) => Promise<T>
): Promise<void> {
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [id]);
if (!deviceRow) { res.status(404).json({ error: 'Device not found' }); return; }
const collector = new DeviceCollector(deviceRow);
try {
await collector.connect();
const result = await fn(collector);
res.json(result);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
}
router.get('/:id/address-lists', async (req, res) => {
await withCollector(req.params.id, res, (c) => c.getAddressLists());
});
router.post('/:id/address-lists', requireWrite, async (req, res) => {
const { list, address } = req.body;
if (!list || !address) return res.status(400).json({ error: 'list and address are required' });
const params: Record<string, string> = { list: String(list), address: String(address) };
if (req.body.comment) params.comment = String(req.body.comment);
if (req.body.timeout) params.timeout = String(req.body.timeout);
await withCollector(req.params.id, res, async (c) => { await c.addAddressListEntry(params); return c.getAddressLists(); });
});
router.put('/:id/address-lists/:entryId', requireWrite, async (req, res) => {
const params: Record<string, string> = {};
for (const k of ['list', 'address', 'comment'] as const) if (req.body[k] !== undefined) params[k] = String(req.body[k]);
if (req.body.disabled !== undefined) params.disabled = req.body.disabled ? 'yes' : 'no';
await withCollector(req.params.id, res, async (c) => { await c.updateAddressListEntry(req.params.entryId, params); return c.getAddressLists(); });
});
router.delete('/:id/address-lists/:entryId', requireWrite, async (req, res) => {
await withCollector(req.params.id, res, async (c) => { await c.removeAddressListEntry(req.params.entryId); return { message: 'Entry removed' }; });
});
// ─── Active Connections (read-only) ─────────────────────────────────────────────
router.get('/:id/connections', async (req, res) => {
const limit = Math.min(parseInt(String(req.query.limit || '500'), 10) || 500, 2000);
await withCollector(req.params.id, res, async (c) => {
const rows = await c.getConnections();
// Connection tables can be huge; cap and surface the total so the UI can note truncation.
return { total: rows.length, connections: rows.slice(0, limit) };
});
});
// ─── Simple Queues (bandwidth control) ──────────────────────────────────────────
const QUEUE_FIELD_MAP: Record<string, string> = {
name: 'name', target: 'target', max_limit: 'max-limit', burst_limit: 'burst-limit',
burst_threshold: 'burst-threshold', burst_time: 'burst-time', priority: 'priority',
comment: 'comment', parent: 'parent',
};
function queueBodyToRos(body: Record<string, unknown>): Record<string, string> {
const p: Record<string, string> = {};
for (const [js, ros] of Object.entries(QUEUE_FIELD_MAP)) {
const v = body[js];
if (v !== undefined && v !== null && v !== '') p[ros] = String(v);
}
if (body.disabled !== undefined) p.disabled = body.disabled ? 'yes' : 'no';
return p;
}
router.get('/:id/queues', async (req, res) => {
await withCollector(req.params.id, res, (c) => c.getSimpleQueues());
});
router.post('/:id/queues', requireWrite, async (req, res) => {
const { name, target, max_limit } = req.body;
if (!name || !target || !max_limit) return res.status(400).json({ error: 'name, target and max_limit are required' });
await withCollector(req.params.id, res, async (c) => { await c.addSimpleQueue(queueBodyToRos(req.body)); return c.getSimpleQueues(); });
});
router.put('/:id/queues/:queueId', requireWrite, async (req, res) => {
await withCollector(req.params.id, res, async (c) => { await c.updateSimpleQueue(req.params.queueId, queueBodyToRos(req.body)); return c.getSimpleQueues(); });
});
router.delete('/:id/queues/:queueId', requireWrite, async (req, res) => {
await withCollector(req.params.id, res, async (c) => { await c.removeSimpleQueue(req.params.queueId); return { message: 'Queue removed' }; });
});
// ─── IP Services + Security Posture audit ────────────────────────────────────────
router.get('/:id/services', async (req, res) => {
await withCollector(req.params.id, res, (c) => c.getServices());
});
router.put('/:id/services/:serviceId', requireWrite, async (req, res) => {
if (typeof req.body.disabled !== 'boolean') return res.status(400).json({ error: 'disabled (boolean) is required' });
await withCollector(req.params.id, res, async (c) => { await c.setServiceDisabled(req.params.serviceId, req.body.disabled); return c.getServices(); });
});
// GET /api/devices/:id/security-posture — computes a hardening checklist.
// Heuristic, not standards-based: graduated weighting that won't saturate to 0,
// de-duplicated services, and it never proposes disabling the management
// service MikroTik Manager itself connects through (that would self-lockout).
router.get('/:id/security-posture', async (req, res) => {
const device = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!device) return res.status(404).json({ error: 'Device not found' });
// Which RouterOS service the platform uses to reach this device. Default api
// (8728); api-ssl is 8729. We must never offer to disable this one.
const mgmtService = device.api_port === 8729 ? 'api-ssl' : 'api';
const isRouter = device.device_type === 'router';
await withCollector(req.params.id, res, async (c) => {
const [servicesRaw, fwRules, snmp] = await Promise.all([
c.getServices().catch(() => [] as Record<string, string>[]),
c.getFirewallRules().catch(() => [] as Record<string, string>[]),
c.getSnmpConfig().catch(() => ({} as Record<string, string>)),
]);
// De-duplicate /ip/service rows by name (some devices report duplicates,
// e.g. api/route_BFD twice) so a service is only ever counted once.
const services = Array.from(new Map(servicesRaw.map(s => [s['name'] ?? s['.id'], s])).values());
type Sev = 'high' | 'medium' | 'low';
type Check = { id: string; severity: Sev; title: string; detail: string; serviceId?: string };
const checks: Check[] = [];
for (const s of services) {
const name = s['name'] ?? '';
if (s['disabled'] === 'true') continue;
if (name === 'telnet' || name === 'ftp') {
checks.push({ id: `service-${name}`, severity: 'high', serviceId: s['.id'],
title: `Cleartext service "${name}" is enabled`,
detail: `${name} sends credentials and data unencrypted. Disable it; use SSH/SFTP instead.` });
} else if (name === 'www') {
checks.push({ id: 'service-www', severity: 'medium', serviceId: s['.id'],
title: 'Unencrypted WebFig (www) is enabled',
detail: 'The HTTP WebFig interface is unencrypted. Disable "www" and use "www-ssl" (HTTPS) instead.' });
} else if (name === 'api' && mgmtService !== 'api') {
// Plaintext API is on but the platform connects via api-ssl — safe to flag/disable.
checks.push({ id: 'service-api', severity: 'medium', serviceId: s['.id'],
title: 'Unencrypted API (api) is enabled',
detail: 'The plaintext RouterOS API is exposed. Disable "api" and use "api-ssl" (8729).' });
} else if (name === mgmtService && name === 'api') {
// The platform is connected through cleartext API — advise, but do NOT
// offer to disable it (that would cut MikroTik Manager off the device).
checks.push({ id: 'mgmt-api-cleartext', severity: 'low',
title: 'MikroTik Manager connects over cleartext API',
detail: 'This device is managed via the unencrypted API (8728). Consider migrating the device and its credentials to API-SSL (8729) for encrypted management.' });
}
}
// Input-chain firewall: a real concern on routers; switches/APs commonly
// (and legitimately) rely on an upstream router's firewall, so it's only
// informational there rather than a high-severity hit.
const hasInputDrop = fwRules.some(r => (r['chain'] === 'input') && ['drop', 'reject'].includes(r['action'] ?? '') && r['disabled'] !== 'true');
if (!hasInputDrop) {
checks.push({
id: 'no-input-firewall', severity: isRouter ? 'high' : 'low',
title: 'No input-chain firewall rule',
detail: isRouter
? 'This router has no enabled drop/reject rule on the input chain, leaving its own services exposed. Add a baseline input firewall.'
: 'No input-chain firewall on this device. Switches/APs often rely on an upstream router firewall — confirm that is the case.',
});
}
if (snmp && snmp['enabled'] === 'true') {
checks.push({ id: 'snmp-enabled', severity: 'low', title: 'SNMP is enabled',
detail: 'Ensure SNMP uses a non-default community string and is restricted to trusted addresses.' });
}
// Graduated score: lighter weights + a floor of 5 so it never reads a hard
// 0 (which falsely implies "maximally insecure"). It's a relative hardening
// indicator, not an absolute grade.
const WEIGHT: Record<Sev, number> = { high: 15, medium: 7, low: 3 };
const penalty = checks.reduce((n, c2) => n + WEIGHT[c2.severity], 0);
const score = checks.length === 0 ? 100 : Math.max(5, 100 - penalty);
return { score, checks };
});
});
// GET /api/devices/:id/resources (live resource usage)
router.get('/:id/resources', async (req: Request, res: Response) => {
const deviceRow = await queryOne<any>(
@@ -1747,8 +1747,10 @@ export class DeviceCollector {
return this.client.execute('/ip/route/print', { detail: '' });
}
// `stats` makes RouterOS include the per-rule bytes/packets hit counters so
// the UI can show which rules are matching (and flag dead, zero-hit rules).
async getFirewallRules(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/firewall/filter/print', { detail: '' });
return this.client.execute('/ip/firewall/filter/print', { detail: '', stats: '' });
}
async addFirewallRule(params: Record<string, string>): Promise<void> {
@@ -1763,8 +1765,21 @@ export class DeviceCollector {
await this.client.execute('/ip/firewall/filter/remove', { '.id': id });
}
// Reorder: RouterOS evaluates rules top-to-bottom, so order is decisive.
// `destination` is the .id the rule should be placed *before* (RouterOS
// semantics), or omitted to move to the end.
async moveFirewallRule(id: string, destination?: string): Promise<void> {
const params: Record<string, string> = { numbers: id };
if (destination) params.destination = destination;
await this.client.execute('/ip/firewall/filter/move', params);
}
async resetFirewallCounters(): Promise<void> {
await this.client.execute('/ip/firewall/filter/reset-counters-all', {});
}
async getNatRules(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/firewall/nat/print', { detail: '' });
return this.client.execute('/ip/firewall/nat/print', { detail: '', stats: '' });
}
async addNatRule(params: Record<string, string>): Promise<void> {
@@ -1779,6 +1794,64 @@ export class DeviceCollector {
await this.client.execute('/ip/firewall/nat/remove', { '.id': id });
}
async moveNatRule(id: string, destination?: string): Promise<void> {
const params: Record<string, string> = { numbers: id };
if (destination) params.destination = destination;
await this.client.execute('/ip/firewall/nat/move', params);
}
// ─── Firewall Address Lists (reusable address objects) ──────────────────────
async getAddressLists(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/firewall/address-list/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addAddressListEntry(params: Record<string, string>): Promise<void> {
await this.client.execute('/ip/firewall/address-list/add', params);
}
async updateAddressListEntry(id: string, params: Record<string, string>): Promise<void> {
await this.client.execute('/ip/firewall/address-list/set', { '.id': id, ...params });
}
async removeAddressListEntry(id: string): Promise<void> {
await this.client.execute('/ip/firewall/address-list/remove', { '.id': id });
}
// ─── Connection tracking (live active connections) ──────────────────────────
async getConnections(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/firewall/connection/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
// ─── Simple Queues (per-client / per-subnet bandwidth control) ──────────────
async getSimpleQueues(): Promise<Record<string, string>[]> {
return this.client.execute('/queue/simple/print', { detail: '', stats: '' }).catch(() => [] as Record<string, string>[]);
}
async addSimpleQueue(params: Record<string, string>): Promise<void> {
await this.client.execute('/queue/simple/add', params);
}
async updateSimpleQueue(id: string, params: Record<string, string>): Promise<void> {
await this.client.execute('/queue/simple/set', { '.id': id, ...params });
}
async removeSimpleQueue(id: string): Promise<void> {
await this.client.execute('/queue/simple/remove', { '.id': id });
}
// ─── IP Services (for security-posture audit + hardening) ───────────────────
async getServices(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/service/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async setServiceDisabled(id: string, disabled: boolean): Promise<void> {
await this.client.execute('/ip/service/set', { '.id': id, disabled: disabled ? 'yes' : 'no' });
}
// ─── Bridge ────────────────────────────────────────────────────────────────
async setBridgeVlanFiltering(bridgeName: string, enabled: boolean): Promise<void> {
@@ -0,0 +1,52 @@
import { detectLockoutRisk } from '../firewallSafety';
describe('detectLockoutRisk', () => {
it('flags a broad input-chain drop with no source scoping', () => {
const r = detectLockoutRisk({ chain: 'input', action: 'drop' });
expect(r.risky).toBe(true);
expect(r.reason).toMatch(/lock MikroTik Manager/);
});
it('flags an unscoped input drop targeting a management port (ssh)', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'drop', protocol: 'tcp', 'dst-port': '22' }).risky).toBe(true);
});
it('flags reject and tarpit the same as drop', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'reject' }).risky).toBe(true);
expect(detectLockoutRisk({ chain: 'input', action: 'tarpit' }).risky).toBe(true);
});
it('does NOT flag rules scoped to a source address', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'drop', 'src-address': '203.0.113.0/24' }).risky).toBe(false);
});
it('does NOT flag rules scoped to a source address-list (underscore key form)', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'drop', src_address_list: 'Trusted' }).risky).toBe(false);
});
it('does NOT flag input drops on non-management ports only', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'drop', protocol: 'tcp', 'dst-port': '12345' }).risky).toBe(false);
});
it('does NOT flag forward-chain drops (through-traffic, not device access)', () => {
expect(detectLockoutRisk({ chain: 'forward', action: 'drop' }).risky).toBe(false);
});
it('does NOT flag accept rules', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'accept' }).risky).toBe(false);
});
it('does NOT flag disabled rules (both string forms)', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'drop', disabled: 'yes' }).risky).toBe(false);
expect(detectLockoutRisk({ chain: 'input', action: 'drop', disabled: 'true' }).risky).toBe(false);
});
it('does NOT flag icmp/udp-only input drops', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'drop', protocol: 'icmp' }).risky).toBe(false);
expect(detectLockoutRisk({ chain: 'input', action: 'drop', protocol: 'udp', 'dst-port': '53' }).risky).toBe(false);
});
it('flags an unscoped drop whose port range covers a management port', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'drop', protocol: 'tcp', 'dst-port': '8000-8800' }).risky).toBe(true); // covers 8291/8728/8729
});
});
+94
View File
@@ -0,0 +1,94 @@
// Lockout guard for firewall changes.
//
// MikroTik Manager controls devices over the RouterOS API (and admins reach
// them over SSH/Winbox). A careless `input`-chain drop/reject rule is the
// classic way to lock both the platform and the operator out of a device. This
// detects that rule shape so the UI can warn and require explicit confirmation
// before applying. It is intentionally conservative: it flags broad,
// unscoped input-chain blocks rather than trying to know the manager's exact
// source IP (which is unknowable through NAT).
export interface FirewallRuleLike {
chain?: string;
action?: string;
protocol?: string;
disabled?: string | boolean;
// accept both RouterOS dashed keys and the API's underscore keys
'src-address'?: string;
src_address?: string;
'dst-port'?: string;
dst_port?: string;
'src-address-list'?: string;
src_address_list?: string;
}
// TCP management ports that, if blocked on the input chain, sever control:
// SSH(22), www(80)/ssl(443), Winbox(8291), API(8728)/API-SSL(8729).
export const DEFAULT_MGMT_PORTS = [22, 80, 443, 8291, 8728, 8729];
export interface LockoutResult {
risky: boolean;
reason: string;
}
function field(rule: FirewallRuleLike, dashed: keyof FirewallRuleLike, under: keyof FirewallRuleLike): string {
const v = (rule[dashed] ?? rule[under] ?? '') as string;
return String(v).trim();
}
// Parse a RouterOS port spec ("22", "80,443", "8000-8100") into a matcher.
function portSpecIncludes(spec: string, ports: number[]): boolean {
const parts = spec.split(',').map((p) => p.trim()).filter(Boolean);
for (const part of parts) {
if (part.includes('-')) {
const [lo, hi] = part.split('-').map((n) => parseInt(n, 10));
if (Number.isFinite(lo) && Number.isFinite(hi) && ports.some((p) => p >= lo && p <= hi)) return true;
} else {
const n = parseInt(part, 10);
if (Number.isFinite(n) && ports.includes(n)) return true;
}
}
return false;
}
export function detectLockoutRisk(
rule: FirewallRuleLike,
opts: { mgmtPorts?: number[] } = {}
): LockoutResult {
const ok = { risky: false, reason: '' };
const mgmtPorts = opts.mgmtPorts ?? DEFAULT_MGMT_PORTS;
const disabled = rule.disabled === true || rule.disabled === 'yes' || rule.disabled === 'true';
if (disabled) return ok;
const action = field(rule, 'action', 'action').toLowerCase();
if (!['drop', 'reject', 'tarpit'].includes(action)) return ok;
// Only the input chain governs access *to the device itself*.
const chain = field(rule, 'chain', 'chain').toLowerCase();
if (chain !== 'input') return ok;
// Management access is TCP; an icmp/udp-only block isn't a lockout.
const protocol = field(rule, 'protocol', 'protocol').toLowerCase();
if (protocol && protocol !== 'tcp') return ok;
// If the rule is scoped to a specific source (address or address-list), assume
// the operator narrowed it deliberately and don't cry wolf.
const srcAddr = field(rule, 'src-address', 'src_address');
const srcList = field(rule, 'src-address-list', 'src_address_list');
if (srcAddr || srcList) return ok;
// Unscoped source. If it targets all ports, or explicitly a management port,
// it would cut control.
const dstPort = field(rule, 'dst-port', 'dst_port');
const portsHit = !dstPort || portSpecIncludes(dstPort, mgmtPorts);
if (!portsHit) return ok;
return {
risky: true,
reason:
`This input-chain "${action}" rule has no source restriction and would match management traffic ` +
`(SSH/Winbox/API/HTTPS). Applying it can lock MikroTik Manager and admins out of the device. ` +
`Scope it with a source address/list, or confirm to apply anyway.`,
};
}