Härtung: REST-Query-Injection-Parität + TOFU-Fingerprint-Fallback

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>
This commit is contained in:
Kay
2026-09-17 18:14:44 +02:00
co-authored by Claude Sonnet 5
parent 800b48406b
commit aad2ccb7d4
4 changed files with 100 additions and 4 deletions
@@ -3,12 +3,19 @@ import Security
import CryptoKit
enum CertificateFingerprint {
static func sha256(of trust: SecTrust) -> String {
/// `nil` means the certificate chain couldn't be read at all the caller must treat that as
/// a hard connection failure, never as a fingerprint to compare or offer for trust. The
/// previous version returned the fixed string `"unbekannt"` in that case, which however
/// unlikely to trigger in practice meant every certificate that failed extraction shared
/// the exact same "fingerprint", so trusting one such (extraction-failed) certificate would
/// have silently trusted every other one too, defeating TOFU pinning for that host. bugs.md,
/// "maximale Sicherheit"-Durchgang, 2026-09-17 hardening, not a live-reproduced exploit.
static func sha256(of trust: SecTrust) -> String? {
guard
let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
let leaf = chain.first
else {
return "unbekannt"
return nil
}
let data = SecCertificateCopyData(leaf) as Data
let digest = SHA256.hash(data: data)
@@ -104,14 +104,31 @@ final class RestTransport: NSObject, RouterOSTransport {
/// 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 data = try await send(path: "\(restPath)?\(whereField)=\(whereValue)", method: "GET", jsonBody: nil)
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 = ""
@@ -258,7 +275,14 @@ extension RestTransport: URLSessionDelegate {
return
}
let fingerprint = CertificateFingerprint.sha256(of: serverTrust)
// `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 {
+45
View File
@@ -192,6 +192,51 @@ Produktentscheidung ist, keine reine Korrektur. Nur als Beobachtung vermerkt.
---
## 2026-09-17 (Nachtrag 2: "finale Test für maximale Sicherheit")
Dritter Durchgang, gezielt sicherheitsfokussiert: Zugangsdaten-Speicherung (Keychain, SavedRoutersStore),
TOFU-Implementierung (Zertifikat/SSH-Hostkey), alle verbleibenden Stellen, die Text in Router-Befehle
oder URLs interpolieren. Positiv: `KeychainService` speichert Passwörter korrekt im macOS-Keychain
(nie Klartext/UserDefaults), `SavedRoutersStore`/`SavedRouter` enthalten kein Passwort-Feld,
`BackupService.escapeForRouterOSScript` escapte bereits vor diesem Durchgang korrekt (unabhängig von
Fund #5 — offenbar aus einem früheren Lockout-Vorfall gelernt), `SSHHostKeyFingerprint` hat keine
Fallback-Schwäche. Zwei weitere Lücken gefunden und gehärtet (defensiv, nicht live exploitiert):
### 8. REST-Pendant zu Fund #5: ungeschützte Query-String-Interpolation
**Status:** fixed (Build grün, nicht live exploitiert — aktuell nur mit hartkodiertem Wert aufgerufen)
**Confidence:** hoch (dieselbe Bugklasse wie #5, nur im REST- statt SSH-Pfad)
`RestTransport.fetchFieldValues` baute den Query-String `"\(restPath)?\(whereField)=\(whereValue)"`
ohne jede Kodierung — beim SSH-Gegenstück wurde das in Fund #5 bereits gefixt, das REST-Pendant
dabei übersehen. Ein `whereValue` mit `&` hätte einen zusätzlichen Query-Parameter einschleusen
können. Aktuell nur mit hartkodiertem `"no"` aufgerufen (kein Live-Exploit möglich), aber generische
`RouterOSTransport`-Methode — Härtung für zukünftige Aufrufer mit echtem Nutzer-/Gerätetext.
**Fix:** `whereValue` wird jetzt RFC-3986-"unreserved characters"-only percent-encoded (strenger
als `.urlQueryAllowed`, das `&`/`=`/`+`/`#` weiterhin durchlässt).
### 9. TOFU-Zertifikatsprüfung hatte einen Fallback-Konstante-Blindfleck
**Status:** fixed (Build grün, Härtung — kein realistisch auslösbarer Live-Exploit gefunden)
**Confidence:** mittel (theoretische Lücke im Code nachvollzogen, keine funktionierende PoC gebaut,
da eine echte TLS-Handshake-Situation gebraucht würde, in der `SecTrustCopyCertificateChain` trotz
abgeschlossenem Handshake leer zurückkommt — unüblich, aber laut Apple-API-Vertrag nicht ausgeschlossen)
`CertificateFingerprint.sha256(of:)` gab bei fehlgeschlagener Zertifikatsketten-Extraktion den festen
String `"unbekannt"` statt eines echten Fingerabdrucks zurück. Da `CertificateTrustStore.isTrusted`
Fingerabdrücke per exaktem String-Vergleich prüft, hätte das Vertrauen in EIN Zertifikat, dessen
Extraktion fehlschlägt, automatisch JEDES andere ebenfalls extraktions-fehlschlagende Zertifikat für
denselben Host als vertrauenswürdig durchgehen lassen — ein theoretisches TOFU-Pinning-Bypass-Fenster.
**Fix:** Rückgabetyp auf `String?` geändert; schlägt die Extraktion fehl, lehnt
`RestTransport.urlSession(_:didReceive:completionHandler:)` die Challenge jetzt hart ab
(`.cancelAuthenticationChallenge`, kein `lastRejectedFingerprint` gesetzt) — der Nutzer bekommt in
diesem Fall nie einen "Zertifikat vertrauen?"-Dialog mit einer nicht verifizierbaren Kennung
angeboten, sondern einen normalen Verbindungsfehler.
Build + alle 101 Unit-Tests grün nach beiden Fixes.
---
## Noch nicht geprüft / außerhalb dieses Durchgangs
- UI-Interaktion selbst (Klickpfade, Darstellung) — nicht automatisierbar, siehe Hinweis oben.
+20
View File
@@ -275,3 +275,23 @@ diesmal mit echten Exploit-Versuchen gegen den Router statt nur Code-Lesen:
Bugfix).
Build grün, alle 101 Unit-Tests grün (2 neue Regressionstests). Details in `bugs.md`.
### 13. Dritter Durchgang: gezielte Sicherheitshärtung (bugs.md #8-#9, 2026-09-17)
**Status:** fixed (2 Härtungsfixes, keine weiteren Live-Exploits gefunden)
Auf Nutzerwunsch ("finale Test für maximale Sicherheit, test alles was du finden kannst") gezielt
Zugangsdaten-Speicherung und TOFU-Mechanismen geprüft. Positiv bestätigt: Keychain-Nutzung korrekt,
keine Passwörter in UserDefaults/JSON, `BackupService`s eigene Skript-Escaping-Logik war bereits
vor diesem Durchgang korrekt. Zwei Härtungslücken gefunden und geschlossen (beide defensiv, nicht
live exploitiert — anders als #5/#6 im vorigen Durchgang):
- **#8**: `RestTransport.fetchFieldValues` hatte dieselbe ungeschützte String-Interpolation wie das
SSH-Pendant aus Fund #5, nur als URL-Query-String statt CLI-Zeile — beim ersten Fix übersehen.
Jetzt RFC-3986-konform percent-encoded.
- **#9**: `CertificateFingerprint.sha256` fiel bei Extraktionsfehlern auf einen festen String
"unbekannt" zurück statt einen echten Fingerabdruck zu liefern — theoretisches TOFU-Bypass-Fenster
(zwei verschiedene, beide extraktions-fehlschlagende Zertifikate hätten sich denselben
"Fingerabdruck" geteilt). Rückgabetyp auf optional geändert, Extraktionsfehler führt jetzt zu
hartem Verbindungsabbruch statt einem Vertrauens-Dialog mit unverifizierbarer Kennung.
Build grün, alle 101 Unit-Tests grün. Details in `bugs.md`.