Files
RouterOS/RouterOSAssistant/Core/Models/RouterOSCommand.swift
T
KayandClaude Sonnet 5 800b48406b Fix live exploitierte RouterOS-CLI-Injection + Parser-Datenverlust
Deep-Dive-Gegencheck mit echtem Exploit-Nachweis gegen den Testrouter:

- RouterOSCommand.cliLine quotete Werte nur bei Leerzeichen und escapte
  eingebettete Anführungszeichen nie. Ein Kommentar wie test" ; :log
  warning "X schloss das Quoting vorzeitig und ließ RouterOS den Rest
  als zweiten Befehl ausführen. Live exploitiert (injizierter
  script,warning-Log-Eintrag) und live als behoben bestätigt. Betraf
  jede Schreiboperation über SSH - auf dem aktuellen Testrouter ist
  www-ssl deaktiviert, REST also unerreichbar, der Bug war aktiv.
- RouterOSCliParser.keyValues nahm an, print terse quote mehrwortige
  Werte - live an zwei Menüs widerlegt (RouterOS 7.24.4 quotet dort
  nichts). Trunkierte jeden mehrwortigen Wert beim ersten Leerzeichen.
  Fix: Token-Scan statt Regex.
- SSHTransport.fetchFieldValues defensiv gegen dieselbe Injection-Klasse
  gehärtet (aktuell nur hartkodiert aufgerufen, aber generische API).

2 neue Regressionstests, alle 101 Unit-Tests grün. Details in bugs.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:08:34 +02:00

156 lines
7.3 KiB
Swift

import Foundation
/// A single RouterOS configuration change, expressed once and executed over either transport
/// (rendered as a CLI line for SSH, as a JSON body — plus a lookup for `.set` — for REST).
struct RouterOSCommand: Equatable, Identifiable {
enum Operation: Equatable {
/// Creates a new item under `restPath` (SSH: `<menuPath> add ...`, REST: `POST`).
case add
/// Modifies an existing item matched by one field's value (SSH: `<menuPath> set [find
/// field=value] ...`; REST has no such inline lookup, so it needs a GET first to find
/// the item's `.id`, then `PATCH restPath/<id>`).
case set(matchField: String, matchValue: String)
/// Deletes an existing item matched by one field's value (SSH: `<menuPath> remove [find
/// field=value]`; REST again needs a GET-for-id first, then `DELETE restPath/<id>`).
case remove(matchField: String, matchValue: String)
/// A RouterOS "menu specific command" beyond add/set/remove, applied to one item matched
/// by a field's value — e.g. `/ip dhcp-server lease make-static (id)`, which converts a
/// dynamic lease to a permanent one. Officially documented for the CLI as taking a bare
/// id/index (https://help.mikrotik.com/docs/spaces/ROS/pages/24805500/DHCP); this app
/// renders it via the same `[find field=value]` selector already proven live for
/// `.set`/`.remove` rather than a positional index, since RouterOS "id"-type arguments
/// accept both and this app never has a reliable position, only real `.id` values. REST
/// has no official documentation for this action at all — the shape used here
/// (`POST <restPath>/<name>` with `{"numbers": <id>}`) is community-reported only
/// (https://forum.mikrotik.com/t/rest-api-convert-lease-to-static/176515), unverified
/// against real hardware.
case action(name: String, matchField: String, matchValue: String)
}
var id: String {
switch operation {
case .add:
return "add:\(menuPath):\(summary)"
case .set(let field, let value):
return "set:\(menuPath):\(field)=\(value):\(summary)"
case .remove(let field, let value):
return "remove:\(menuPath):\(field)=\(value):\(summary)"
case .action(let name, let field, let value):
return "action:\(menuPath):\(name):\(field)=\(value):\(summary)"
}
}
/// RouterOS menu path without the action word, e.g. "/ip address" or "/interface wireless".
let menuPath: String
/// REST resource path under `/rest/`, e.g. "ip/address" or "interface/wireless".
let restPath: String
let operation: Operation
/// RouterOS "words" (key=value fields) to add or change.
let arguments: [String: String]
/// Human-readable description shown on the review screen before applying.
let summary: String
static func add(menuPath: String, restPath: String, arguments: [String: String], summary: String) -> RouterOSCommand {
RouterOSCommand(menuPath: menuPath, restPath: restPath, operation: .add, arguments: arguments, summary: summary)
}
static func set(
menuPath: String,
restPath: String,
matchField: String,
matchValue: String,
arguments: [String: String],
summary: String
) -> RouterOSCommand {
RouterOSCommand(
menuPath: menuPath,
restPath: restPath,
operation: .set(matchField: matchField, matchValue: matchValue),
arguments: arguments,
summary: summary
)
}
static func remove(
menuPath: String,
restPath: String,
matchField: String,
matchValue: String,
summary: String
) -> RouterOSCommand {
RouterOSCommand(
menuPath: menuPath,
restPath: restPath,
operation: .remove(matchField: matchField, matchValue: matchValue),
arguments: [:],
summary: summary
)
}
static func action(
menuPath: String,
restPath: String,
name: String,
matchField: String,
matchValue: String,
summary: String
) -> RouterOSCommand {
RouterOSCommand(
menuPath: menuPath,
restPath: restPath,
operation: .action(name: name, matchField: matchField, matchValue: matchValue),
arguments: [:],
summary: summary
)
}
/// Renders as a RouterOS CLI line, e.g. `/ip address add address=192.168.88.1/24 interface=bridge`
/// or `/interface wireless set [find name=wlan1] ssid=Home`.
var cliLine: String {
let args = renderedArguments
switch operation {
case .add:
return args.isEmpty ? "\(menuPath) add" : "\(menuPath) add \(args)"
case .set(let field, let value):
// Empty matchField = singleton menu (e.g. "/ip dns", "/system identity"): these hold
// exactly one item and are "set" directly, with no "[find ...]" — there's nothing to
// find. Confirmed live against RouterOS 7.24.2.
guard !field.isEmpty else {
return args.isEmpty ? "\(menuPath) set" : "\(menuPath) set \(args)"
}
let finder = "[find \(field)=\(Self.quoteIfNeeded(value))]"
return args.isEmpty ? "\(menuPath) set \(finder)" : "\(menuPath) set \(finder) \(args)"
case .remove(let field, let value):
return "\(menuPath) remove [find \(field)=\(Self.quoteIfNeeded(value))]"
case .action(let name, let field, let value):
return "\(menuPath) \(name) [find \(field)=\(Self.quoteIfNeeded(value))]"
}
}
private var renderedArguments: String {
arguments
.sorted { $0.key < $1.key }
.map { "\($0.key)=\(Self.quoteIfNeeded($0.value))" }
.joined(separator: " ")
}
/// Always quotes, with `\` and `"` backslash-escaped inside — the previous version only
/// quoted when the value contained a space and never escaped embedded quotes at all, which
/// let a value like `test" ; :log warning "INJECTED` (a completely plausible free-text
/// comment/SSID/hostname) close the quoted argument early and inject a second, independent
/// RouterOS command after the `;` — RouterOS' console uses `;` as a statement separator, same
/// as the injection risk `SSHTransport.runDiagnosticCommand`'s doc comment already flags for
/// its own caller-sanitized input. Live-confirmed exploitable and live-confirmed fixed
/// (2026-09-17, bugs.md): unescaped, this executed an injected `:log warning` as a second
/// command; escaping `\`/`"` (verified live to be RouterOS' own escape syntax — `\"` and `\\`
/// both round-tripped correctly through `print detail`) closes it. Quoting unconditionally
/// (not just "if it contains a space") also verified live to be always accepted, including for
/// plain single-word values and `yes`/`no` booleans — no reason left to special-case those.
private static func quoteIfNeeded(_ value: String) -> String {
let escaped = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
}