Default TLS validation on; scope skip-TLS bypass to the configured host
The app shipped with certificate validation bypassed globally and on by default — InsecureTrustDelegate trusted any cert from any host. That was the evaluation's P1: anyone on the LAN could MITM call audio, transcripts, and voiceprints. The backend's Start9 cert already validates under normal system trust when the StartOS Root CA is installed in the keychain (confirmed: URLSession default validation returns 200 against the backend and its fallback), so the bypass is unnecessary: - skip-TLS now defaults to off - when explicitly enabled, the bypass is scoped to the configured host via InsecureTrustDelegate.allowsTrustOverride, never "trust any server" - the host gate is pure and unit-tested (InsecureTrustDelegateTests) Docs reconciled: AGENTS.md backend/TLS line and Current state.
This commit is contained in:
@@ -33,7 +33,9 @@ final class GatewayLLMClient {
|
||||
config.timeoutIntervalForRequest = 600
|
||||
config.timeoutIntervalForResource = 900
|
||||
config.waitsForConnectivity = false
|
||||
let delegate: URLSessionDelegate? = skipTLS ? InsecureTrustDelegate() : nil
|
||||
let delegate: URLSessionDelegate? = skipTLS
|
||||
? InsecureTrustDelegate(allowedHost: URL(string: self.baseURL)?.host)
|
||||
: nil
|
||||
self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
/// URLSession delegate that trusts the server certificate without validation.
|
||||
/// URLSession delegate that bypasses certificate validation for **one host only**
|
||||
/// — the configured SparkControl backend.
|
||||
///
|
||||
/// SparkControl sits behind a Start9 self-signed Root CA on the LAN, so default
|
||||
/// trust evaluation rejects it. This delegate is used **only** when the
|
||||
/// "Skip TLS verification" setting is on. It trusts any server certificate —
|
||||
/// acceptable for a personal tool on a trusted local network and nothing else.
|
||||
/// SparkControl sits behind a Start9 self-signed Root CA on the LAN. The supported
|
||||
/// path is to install that CA in the System keychain; default trust evaluation then
|
||||
/// succeeds and this delegate is never used. It exists only as an opt-in escape
|
||||
/// hatch (the "Skip TLS verification" setting, off by default) for a machine where
|
||||
/// the CA isn't installed. Even then it trusts a certificate only when the challenge
|
||||
/// host equals `allowedHost` — a server-trust challenge from any other host falls
|
||||
/// back to default validation, so the bypass can never become "trust any server".
|
||||
final class InsecureTrustDelegate: NSObject, URLSessionDelegate {
|
||||
/// The single host the bypass is scoped to (the configured backend host). When
|
||||
/// nil — only reachable via a malformed base URL — the gate never fires and every
|
||||
/// challenge falls back to default validation: the safe degenerate case.
|
||||
private let allowedHost: String?
|
||||
|
||||
init(allowedHost: String?) {
|
||||
self.allowedHost = allowedHost
|
||||
}
|
||||
|
||||
/// The security gate: the trust override may fire only for a server-trust
|
||||
/// challenge whose host matches `allowedHost`. Pure and synchronous so the
|
||||
/// host-scoping can be unit-tested without fabricating a `SecTrust`; the
|
||||
/// credential itself is built only when this is true *and* a serverTrust exists.
|
||||
func allowsTrustOverride(for space: URLProtectionSpace) -> Bool {
|
||||
guard let allowedHost else { return false }
|
||||
return space.authenticationMethod == NSURLAuthenticationMethodServerTrust
|
||||
&& space.host == allowedHost
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
guard
|
||||
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
allowsTrustOverride(for: challenge.protectionSpace),
|
||||
let serverTrust = challenge.protectionSpace.serverTrust
|
||||
else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
|
||||
@@ -82,7 +82,9 @@ final class SparkControlClient {
|
||||
config.timeoutIntervalForRequest = 600 // diarization can take up to ~600s
|
||||
config.timeoutIntervalForResource = 900
|
||||
config.waitsForConnectivity = false
|
||||
let delegate: URLSessionDelegate? = skipTLS ? InsecureTrustDelegate() : nil
|
||||
let delegate: URLSessionDelegate? = skipTLS
|
||||
? InsecureTrustDelegate(allowedHost: URL(string: self.baseURL)?.host)
|
||||
: nil
|
||||
self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ final class SparkControlHealth: ObservableObject {
|
||||
config.timeoutIntervalForRequest = 8
|
||||
config.waitsForConnectivity = false
|
||||
|
||||
let delegate: URLSessionDelegate? = skipTLS ? InsecureTrustDelegate() : nil
|
||||
let delegate: URLSessionDelegate? = skipTLS
|
||||
? InsecureTrustDelegate(allowedHost: url.host)
|
||||
: nil
|
||||
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user