Dritter, sicherheitsfokussierter Deep-Dive-Durchgang ("maximale Sicherheit"):
- RestTransport.fetchFieldValues hatte dieselbe ungeschützte
String-Interpolation wie das SSH-Pendant aus dem vorigen Fix, nur als
URL-Query statt CLI-Zeile - beim ersten Fix übersehen. Jetzt
RFC-3986-konform percent-encoded.
- CertificateFingerprint.sha256 fiel bei Extraktionsfehlern auf einen
festen String "unbekannt" zurück statt echtem Fingerabdruck -
theoretisches TOFU-Pinning-Bypass-Fenster (zwei verschiedene,
extraktions-fehlschlagende Zertifikate hätten sich denselben
"Fingerabdruck" geteilt). Rückgabetyp optional, Extraktionsfehler
führt jetzt zu hartem Verbindungsabbruch statt Trust-Dialog.
Beide Fixes defensiv/gehärtet, nicht live exploitiert. Zugangsdaten-
Speicherung (Keychain) und BackupServices eigene Escaping-Logik
gegengeprüft - bereits korrekt. Build + alle 101 Unit-Tests grün.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
294 lines
16 KiB
Swift
294 lines
16 KiB
Swift
import Foundation
|
|
|
|
/// REST-API transport for RouterOS >= 7.1 (JSON over HTTPS, `/rest/...`).
|
|
final class RestTransport: NSObject, RouterOSTransport {
|
|
let kind: RouterOSTransportKind = .rest
|
|
|
|
private let credentials: RouterOSCredentials
|
|
private let certificateTrust: CertificateTrustStore
|
|
private var lastRejectedFingerprint: String?
|
|
|
|
/// RouterOS sends `Cache-Control: max-age=31536000` (one year) + a matching `Expires` header
|
|
/// on every `/rest/...` response, live-confirmed (2026-09-16) — almost certainly a blanket
|
|
/// default meant for static web-UI assets that leaks onto the REST API too. `.ephemeral`
|
|
/// only means "no data persisted to disk", it still keeps an in-memory `URLCache` by default
|
|
/// and honors those headers — so the *first* GET to a given path (e.g. an empty item list,
|
|
/// fetched once by `loadCrossReferenceOptions` or an earlier menu open) got served back for
|
|
/// every subsequent identical GET for the rest of the app's lifetime, even after a `.set`/
|
|
/// `.add`/`.remove` changed the underlying data. Symptom, live: a freshly-created address-list
|
|
/// entry (confirmed to exist via `curl`) never appeared in the Experte-Tab list, even after
|
|
/// navigating away and back. `urlCache = nil` + `.reloadIgnoringLocalCacheData` disable
|
|
/// caching at both the session and per-request level, since RouterOS' write endpoints (unlike
|
|
/// its GETs) aren't idempotent enough to risk relying on just one of the two.
|
|
private lazy var session: URLSession = {
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.urlCache = nil
|
|
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
return URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
|
}()
|
|
|
|
init(credentials: RouterOSCredentials, certificateTrust: CertificateTrustStore) {
|
|
self.credentials = credentials
|
|
self.certificateTrust = certificateTrust
|
|
}
|
|
|
|
/// Trailing slash is load-bearing: `URL(string:relativeTo:)` resolves a relative reference
|
|
/// like "system/resource" against a base's *last path segment* per RFC 3986 §5.3 — without
|
|
/// the trailing "/", that segment is "rest" itself, so the merge replaces it instead of
|
|
/// appending after it (`.../rest` + "system/resource" → `.../system/resource`, silently
|
|
/// dropping "/rest" and 404ing). Confirmed live (2026-09-16, first-ever successful REST
|
|
/// connection to real hardware): app fell back to SSH with a swallowed "HTTP 404" instead of
|
|
/// showing the certificate-trust dialog, traced via a temporary debug print in
|
|
/// `ConnectionService.connect`.
|
|
private var baseURL: URL {
|
|
URL(string: "https://\(credentials.host):\(credentials.httpsPort)/rest/")!
|
|
}
|
|
|
|
func connect() async throws {
|
|
_ = try await fetchDeviceInfo()
|
|
}
|
|
|
|
func fetchDeviceInfo() async throws -> RouterDeviceInfo {
|
|
let json = try await getJSONObject(path: "system/resource")
|
|
guard
|
|
let boardName = json["board-name"] as? String,
|
|
let version = json["version"] as? String,
|
|
let arch = json["architecture-name"] as? String
|
|
else {
|
|
throw RouterOSError.invalidResponse("system/resource")
|
|
}
|
|
let uptime = json["uptime"] as? String ?? "-"
|
|
return RouterDeviceInfo(boardName: boardName, routerOSVersion: version, architecture: arch, uptime: uptime)
|
|
}
|
|
|
|
func fetchInterfaces() async throws -> [NetworkInterface] {
|
|
let array = try await getJSONArray(path: "interface")
|
|
return array.compactMap { item in
|
|
guard let name = item["name"] as? String, let type = item["type"] as? String else {
|
|
return nil
|
|
}
|
|
return NetworkInterface(
|
|
name: name,
|
|
type: type,
|
|
running: (item["running"] as? String) == "true",
|
|
disabled: (item["disabled"] as? String) == "true",
|
|
macAddress: item["mac-address"] as? String
|
|
)
|
|
}
|
|
}
|
|
|
|
func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts {
|
|
let filterItems = try await getJSONArray(path: "ip/firewall/filter")
|
|
let natItems = try await getJSONArray(path: "ip/firewall/nat")
|
|
return FirewallRuleCounts(filterRuleCount: filterItems.count, natRuleCount: natItems.count)
|
|
}
|
|
|
|
/// Creates or modifies the item described by `command`. `.add` is a plain
|
|
/// `POST /rest/<restPath>`. `.set` has no CLI-style inline lookup on REST, so it first
|
|
/// `GET`s the collection to find the item whose `matchField` equals `matchValue`, reads
|
|
/// its RouterOS-internal `.id`, then `PATCH`es `restPath/<id>`.
|
|
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem] {
|
|
let data = try await send(path: restPath, method: "GET", jsonBody: nil)
|
|
if let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
|
|
return array.map { Self.menuItem(from: $0) }
|
|
}
|
|
// Singleton menu (e.g. "/ip dns") — REST returns one JSON object, not an array.
|
|
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
return [Self.menuItem(from: object)]
|
|
}
|
|
throw RouterOSError.invalidResponse(restPath)
|
|
}
|
|
|
|
/// Unverified against real hardware (this app's REST write/query paths in general are —
|
|
/// see HANDOFF.md). RouterOS REST's general convention is that a GET accepts query-string
|
|
/// property filters (`?field=value`), mirroring the console's `find field=value` — used here
|
|
/// on the same assumption, not confirmed for this exact property. Unlike SSH, REST's GET
|
|
/// already returns full objects, so no separate id-overlay is involved here at all.
|
|
///
|
|
/// `whereValue` is percent-encoded before landing in the query string — same defensive
|
|
/// reasoning as `SSHTransport.fetchFieldValues`'s CLI-escaping (see `RouterOSCommand`'s
|
|
/// `quoteIfNeeded` doc comment for the injection class this class of fix addresses): both
|
|
/// current callers only ever pass the hardcoded literal `"no"`, but an unencoded value
|
|
/// containing `&` could inject an additional, attacker-chosen query parameter into this
|
|
/// request for any future caller passing real (e.g. device-controlled) text.
|
|
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String> {
|
|
let encodedValue = whereValue.addingPercentEncoding(withAllowedCharacters: Self.queryValueAllowedCharacters) ?? whereValue
|
|
let data = try await send(path: "\(restPath)?\(whereField)=\(encodedValue)", method: "GET", jsonBody: nil)
|
|
guard let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
return Set(array.compactMap { $0[returnField] as? String })
|
|
}
|
|
|
|
/// RFC 3986 "unreserved characters" only — deliberately stricter than `.urlQueryAllowed`,
|
|
/// which still permits `&`/`=`/`+`/`#` (valid query-string bytes in general, but exactly the
|
|
/// characters that let an encoded value be misread as introducing a second parameter).
|
|
private static let queryValueAllowedCharacters: CharacterSet = {
|
|
var set = CharacterSet.alphanumerics
|
|
set.insert(charactersIn: "-._~")
|
|
return set
|
|
}()
|
|
|
|
private static func menuItem(from item: [String: Any]) -> RouterOSMenuItem {
|
|
var fields: [String: String] = [:]
|
|
var id = ""
|
|
for (key, value) in item {
|
|
let stringValue = (value as? String) ?? String(describing: value)
|
|
if key == ".id" {
|
|
id = stringValue
|
|
} else {
|
|
fields[key] = stringValue
|
|
}
|
|
}
|
|
return RouterOSMenuItem(id: id.isEmpty ? "singleton" : id, fields: fields)
|
|
}
|
|
|
|
func apply(_ command: RouterOSCommand) async throws {
|
|
switch command.operation {
|
|
case .add:
|
|
// RouterOS' REST API creates new entries via PUT, not POST — confirmed live
|
|
// (2026-09-16, first-ever REST write test against real hardware): POST returned
|
|
// HTTP 400 `{"detail":"no such command","error":400,"message":"Bad Request"}` on
|
|
// `/rest/ip/firewall/address-list`, PUT with the identical body succeeded (201,
|
|
// full item echoed back). Never caught before — every prior test device had
|
|
// `www-ssl` off, so this path only ever ran against mocks (see HANDOFF.md).
|
|
_ = try await send(path: command.restPath, method: "PUT", jsonBody: command.arguments)
|
|
case .set(let matchField, let matchValue):
|
|
// Empty matchField = singleton menu (e.g. "/ip dns") — PATCH the resource directly,
|
|
// there's no list/id to look up. See RouterOSCommand.cliLine for the SSH counterpart.
|
|
if matchField.isEmpty {
|
|
_ = try await send(path: command.restPath, method: "PATCH", jsonBody: command.arguments)
|
|
} else {
|
|
let itemID = try await findItemID(path: command.restPath, matchField: matchField, matchValue: matchValue)
|
|
_ = try await send(path: "\(command.restPath)/\(itemID)", method: "PATCH", jsonBody: command.arguments)
|
|
}
|
|
case .remove(let matchField, let matchValue):
|
|
let itemID = try await findItemID(path: command.restPath, matchField: matchField, matchValue: matchValue)
|
|
_ = try await send(path: "\(command.restPath)/\(itemID)", method: "DELETE", jsonBody: nil)
|
|
case .action(let name, let matchField, let matchValue):
|
|
// Community-reported shape (not MikroTik-documented, see RouterOSCommand.Operation)
|
|
// — POST to the action's own sub-path with the matched item's id under "numbers",
|
|
// mirroring the console's own `<path> <name> numbers=<id>` argument name.
|
|
let itemID = try await findItemID(path: command.restPath, matchField: matchField, matchValue: matchValue)
|
|
_ = try await send(path: "\(command.restPath)/\(name)", method: "POST", jsonBody: ["numbers": itemID])
|
|
}
|
|
}
|
|
|
|
private func findItemID(path: String, matchField: String, matchValue: String) async throws -> String {
|
|
let items = try await getJSONArray(path: path)
|
|
guard let match = items.first(where: { ($0[matchField] as? String) == matchValue }) else {
|
|
throw RouterOSError.invalidResponse("Kein Eintrag mit \(matchField)=\(matchValue) unter \(path) gefunden")
|
|
}
|
|
guard let id = match[".id"] as? String else {
|
|
throw RouterOSError.invalidResponse("Eintrag unter \(path) hat keine .id")
|
|
}
|
|
return id
|
|
}
|
|
|
|
func disconnect() async {
|
|
session.invalidateAndCancel()
|
|
}
|
|
|
|
private func getJSONObject(path: String) async throws -> [String: Any] {
|
|
let data = try await send(path: path, method: "GET", jsonBody: nil)
|
|
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw RouterOSError.invalidResponse(path)
|
|
}
|
|
return object
|
|
}
|
|
|
|
private func getJSONArray(path: String) async throws -> [[String: Any]] {
|
|
let data = try await send(path: path, method: "GET", jsonBody: nil)
|
|
guard let array = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
|
|
throw RouterOSError.invalidResponse(path)
|
|
}
|
|
return array
|
|
}
|
|
|
|
private func send(path: String, method: String, jsonBody: [String: String]?) async throws -> Data {
|
|
// `URL(string:relativeTo:)` (not `appendingPathComponent`, which percent-encodes "?")
|
|
// so a path carrying a query string (e.g. "ip/dhcp-server/lease?dynamic=no" from
|
|
// `fetchItemIDs`) is actually sent as a query, not a literal "?"-containing path segment.
|
|
guard let url = URL(string: path, relativeTo: baseURL) else {
|
|
throw RouterOSError.invalidResponse(path)
|
|
}
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = method
|
|
request.cachePolicy = .reloadIgnoringLocalCacheData
|
|
|
|
let authString = "\(credentials.username):\(credentials.password)"
|
|
guard let authData = authString.data(using: .utf8) else {
|
|
throw RouterOSError.invalidResponse("credentials")
|
|
}
|
|
request.setValue("Basic \(authData.base64EncodedString())", forHTTPHeaderField: "Authorization")
|
|
request.timeoutInterval = 5
|
|
|
|
if let jsonBody {
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: jsonBody)
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
}
|
|
|
|
lastRejectedFingerprint = nil
|
|
do {
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw RouterOSError.invalidResponse(path)
|
|
}
|
|
if httpResponse.statusCode == 401 {
|
|
throw RouterOSError.authenticationFailed
|
|
}
|
|
guard (200...299).contains(httpResponse.statusCode) else {
|
|
// RouterOS' REST error body is JSON (e.g. `{"detail":"no such command",
|
|
// "error":400,"message":"Bad Request"}`) — surface "detail" when present instead
|
|
// of just the bare status code, or diagnosing a REST write failure means guessing
|
|
// blind (confirmed live, 2026-09-16: the PUT-vs-POST bug below was only found by
|
|
// replaying the exact same request with curl, since the app only showed "HTTP 400").
|
|
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let detail = object["detail"] as? String {
|
|
throw RouterOSError.invalidResponse("HTTP \(httpResponse.statusCode): \(detail)")
|
|
}
|
|
throw RouterOSError.invalidResponse("HTTP \(httpResponse.statusCode)")
|
|
}
|
|
return data
|
|
} catch let error as RouterOSError {
|
|
throw error
|
|
} catch {
|
|
if let fingerprint = lastRejectedFingerprint {
|
|
throw RouterOSError.untrustedCertificate(fingerprint: fingerprint)
|
|
}
|
|
throw RouterOSError.transportUnavailable("REST-Verbindung fehlgeschlagen: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
extension RestTransport: URLSessionDelegate {
|
|
func urlSession(
|
|
_ session: URLSession,
|
|
didReceive challenge: URLAuthenticationChallenge,
|
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
|
) {
|
|
guard
|
|
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
|
let serverTrust = challenge.protectionSpace.serverTrust
|
|
else {
|
|
completionHandler(.performDefaultHandling, nil)
|
|
return
|
|
}
|
|
|
|
// `nil` (certificate chain unreadable) is always a hard rejection — never routed through
|
|
// `lastRejectedFingerprint`/`untrustedCertificate`, since that flow ends in a dialog
|
|
// offering to trust this exact fingerprint, and there is no reliable fingerprint to trust
|
|
// here (see `CertificateFingerprint.sha256`'s doc comment).
|
|
guard let fingerprint = CertificateFingerprint.sha256(of: serverTrust) else {
|
|
completionHandler(.cancelAuthenticationChallenge, nil)
|
|
return
|
|
}
|
|
if certificateTrust.isTrusted(host: credentials.host, fingerprint: fingerprint) {
|
|
completionHandler(.useCredential, URLCredential(trust: serverTrust))
|
|
} else {
|
|
lastRejectedFingerprint = fingerprint
|
|
completionHandler(.cancelAuthenticationChallenge, nil)
|
|
}
|
|
}
|
|
}
|