diff --git a/Bitkit/Utilities/URLValidationPattern.swift b/Bitkit/Utilities/URLValidationPattern.swift new file mode 100644 index 000000000..fb56dedf9 --- /dev/null +++ b/Bitkit/Utilities/URLValidationPattern.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Linear (non-backtracking) regex fragments for hostname / URL validation. +/// +/// Labels are length-bounded to the DNS maximum, so the composed patterns cannot +/// backtrack catastrophically on long dotless input (ReDoS). Fragments are lowercase; +/// each call site applies its own case sensitivity (e.g. `.caseInsensitive`). +enum URLValidationPattern { + /// Maximum length of a DNS name; hosts longer than this are rejected before running the regex. + static let maxHostLength = 253 + + /// A single DNS label: starts and ends alphanumeric, hyphens allowed inside, up to 63 chars. + static let hostLabel = #"[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?"# + + /// One or more dot-terminated labels, e.g. "sub.example." — combine with a trailing TLD. + static let dotSeparatedLabels = #"(?:\#(hostLabel)\.)+"# + + /// A dotted-decimal IPv4 address. + static let ipv4 = #"(?:\d{1,3}\.){3}\d{1,3}"# + + /// A hostname requiring a public-style alphabetic TLD of >= 2 chars, e.g. "example.com". + static let domainWithPublicTld = #"\#(dotSeparatedLabels)[a-z]{2,}"# + + /// A hostname allowing any final label, e.g. custom TLDs like ".local". + static let domainWithAnyTld = #"\#(dotSeparatedLabels)[a-z\d-]+"# + + /// Full RGS server URL: optional scheme, a domain-or-IPv4 host, optional port and path. + static let rgsServerUrl = #"^(https?://)?(\#(domainWithPublicTld)|\#(ipv4))(:\d+)?(/[-a-z\d%_.~+]*)*"# + + /// Electrum host: a domain with any TLD, or an IPv4 address. + static let electrumHost = #"^\#(domainWithAnyTld)|\#(ipv4)$"# +} diff --git a/Bitkit/ViewModels/Extensions/SettingsViewModel+Electrum.swift b/Bitkit/ViewModels/Extensions/SettingsViewModel+Electrum.swift index 32c7cb0af..f6dc2bf75 100644 --- a/Bitkit/ViewModels/Extensions/SettingsViewModel+Electrum.swift +++ b/Bitkit/ViewModels/Extensions/SettingsViewModel+Electrum.swift @@ -26,8 +26,11 @@ extension SettingsViewModel { let host = electrumHost.trimmingCharacters(in: .whitespaces) let port = electrumPort.trimmingCharacters(in: .whitespaces) - // Validate input first - if let validationError = validateElectrumInput(host: host, port: port) { + // Validate input off the main thread (regex could block on pathological input) + let validationError = await Task.detached { [self] in + validateElectrumInput(host: host, port: port) + }.value + if let validationError { electrumIsLoading = false return (success: false, host: host, port: port, errorMessage: validationError) } @@ -110,7 +113,7 @@ extension SettingsViewModel { electrumSelectedProtocol = server.protocolType } - private func validateElectrumInput(host: String, port: String) -> String? { + nonisolated func validateElectrumInput(host: String, port: String) -> String? { // Check if both host and port are empty if host.isEmpty && port.isEmpty { return t("settings__es__error_host_port") @@ -145,7 +148,7 @@ extension SettingsViewModel { return nil // No validation errors } - private func isValidElectrumURL(_ data: String) -> Bool { + nonisolated func isValidElectrumURL(_ data: String) -> Bool { // Add 'http://' if the protocol is missing to enable URL parsing let normalizedData = data.hasPrefix("http://") || data.hasPrefix("https://") ? data : "http://\(data)" @@ -155,11 +158,10 @@ extension SettingsViewModel { let hostname = url.host ?? "" - // Allow standard domains, custom TLDs like .local, and IPv4 addresses - let isValidDomainOrIP = - hostname.range( - of: #"^([a-z\d]([a-z\d-]*[a-z\d])*\.)+[a-z\d-]+|(\d{1,3}\.){3}\d{1,3}$"#, options: .regularExpression, range: nil, locale: nil - ) != nil + // Reject over-long hosts before running the regex + guard hostname.count <= URLValidationPattern.maxHostLength else { + return false + } // Always allow .local domains if hostname.hasSuffix(".local") { @@ -171,7 +173,11 @@ extension SettingsViewModel { return true } - return isValidDomainOrIP + // Allow standard domains, custom TLDs, and IPv4 addresses + return hostname.range( + of: URLValidationPattern.electrumHost, + options: .regularExpression, range: nil, locale: nil + ) != nil } private func parseElectrumScanData(_ data: String) -> ElectrumServer? { diff --git a/Bitkit/ViewModels/Extensions/SettingsViewModel+Rgs.swift b/Bitkit/ViewModels/Extensions/SettingsViewModel+Rgs.swift index de1123245..64375b3c5 100644 --- a/Bitkit/ViewModels/Extensions/SettingsViewModel+Rgs.swift +++ b/Bitkit/ViewModels/Extensions/SettingsViewModel+Rgs.swift @@ -21,6 +21,21 @@ extension SettingsViewModel { let url = rgsServerUrl.trimmingCharacters(in: .whitespaces) + // Re-validate the exact URL at connect time; the debounced rgsUrlIsValid can be stale + // for ~300ms after the field changes, which would otherwise leave Connect enabled. + let isValid = await Task.detached { [self] in isValidRgsUrl(url) }.value + guard isValid else { + rgsIsLoading = false + return (success: false, url: url, errorMessage: nil) + } + + // Verify the endpoint actually serves a snapshot before restarting the node; a + // well-formed but wrong URL would otherwise report success. Empty URL disables RGS. + if !url.isEmpty, await !isRgsEndpointReachable(url) { + rgsIsLoading = false + return (success: false, url: url, errorMessage: nil) + } + do { // Save the configuration to settings first rgsConfigService.saveServerUrl(url) @@ -43,9 +58,35 @@ extension SettingsViewModel { } } + /// RGS servers serve snapshots at /; timestamp 0 is the full snapshot + /// and returns a 2xx for a valid endpoint, so a HEAD request confirms reachability without + /// downloading the body. Mirrors the Android RGS connect check. + nonisolated func isRgsEndpointReachable(_ url: String) async -> Bool { + let base = url.hasSuffix("/") ? String(url.dropLast()) : url + guard let testUrl = URL(string: "\(base)/0") else { + return false + } + + var request = URLRequest(url: testUrl) + request.httpMethod = "HEAD" + request.timeoutInterval = 10 + + do { + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + return false + } + return (200 ... 299).contains(http.statusCode) + } catch { + Logger.warn("RGS endpoint unreachable at \(testUrl.absoluteString): \(error.localizedDescription)") + return false + } + } + func onRgsScan(_ data: String) async -> (success: Bool, url: String, errorMessage: String?)? { - // Validate scanned data - guard isValidRgsUrl(data) else { + // Validate scanned data off the main thread (regex could block on pathological input) + let isValid = await Task.detached { [self] in isValidRgsUrl(data) }.value + guard isValid else { return nil } diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index a366785a4..befe2b783 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -124,6 +124,8 @@ class SettingsViewModel: NSObject, ObservableObject { // RGS Server Settings @Published var rgsServerUrl: String = "" @Published var rgsIsLoading: Bool = false + @Published var rgsUrlIsValid: Bool = false + private var rgsValidationCancellable: AnyCancellable? // Services let lightningService: LightningService @@ -166,6 +168,17 @@ class SettingsViewModel: NSObject, ObservableObject { } updatePinEnabledState() + setupRgsValidationDebounce() + } + + private func setupRgsValidationDebounce() { + rgsValidationCancellable = $rgsServerUrl + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .map { $0.trimmingCharacters(in: .whitespaces) } + .receive(on: DispatchQueue.global(qos: .userInitiated)) + .map { [weak self] url in self?.isValidRgsUrl(url) ?? false } + .receive(on: DispatchQueue.main) + .sink { [weak self] isValid in self?.rgsUrlIsValid = isValid } } deinit { @@ -252,7 +265,7 @@ class SettingsViewModel: NSObject, ObservableObject { var rgsCanConnect: Bool { let formUrl = rgsServerUrl.trimmingCharacters(in: .whitespaces) - return rgsHasEdited && !formUrl.isEmpty && isValidRgsUrl(formUrl) + return rgsHasEdited && !formUrl.isEmpty && rgsUrlIsValid } var rgsCanReset: Bool { @@ -538,7 +551,12 @@ class SettingsViewModel: NSObject, ObservableObject { // MARK: - RGS URL Validation - func isValidRgsUrl(_ url: String) -> Bool { + private nonisolated static let rgsUrlRegex = try? NSRegularExpression( + pattern: URLValidationPattern.rgsServerUrl, + options: .caseInsensitive + ) + + nonisolated func isValidRgsUrl(_ url: String) -> Bool { // Allow empty URL (disables RGS) if url.isEmpty { return true @@ -554,8 +572,8 @@ class SettingsViewModel: NSObject, ObservableObject { return false } - // Must have a host - guard urlObj.host != nil else { + // Must have a host, and reject over-long hosts before running the regex + guard let host = urlObj.host, host.count <= URLValidationPattern.maxHostLength else { return false } @@ -564,11 +582,11 @@ class SettingsViewModel: NSObject, ObservableObject { return true } - // Basic URL pattern validation - let pattern = #"^(https?:\/\/)?((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3}))(\:\d+)?(\/[-a-z\d%_.~+]*)*"# - let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) + guard let regex = Self.rgsUrlRegex else { + return false + } let range = NSRange(location: 0, length: url.utf16.count) - return regex?.firstMatch(in: url, options: [], range: range) != nil + return regex.firstMatch(in: url, options: [], range: range) != nil } // MARK: - Backup/Restore diff --git a/BitkitTests/SettingsUrlValidationTests.swift b/BitkitTests/SettingsUrlValidationTests.swift new file mode 100644 index 000000000..df08d71b9 --- /dev/null +++ b/BitkitTests/SettingsUrlValidationTests.swift @@ -0,0 +1,66 @@ +@testable import Bitkit +import XCTest + +/// Regression + correctness tests for RGS and Electrum server URL validation. +/// The long-dotless-host cases guard against catastrophic regex backtracking (ReDoS) +/// that previously froze the settings screens. +final class SettingsUrlValidationTests: XCTestCase { + @MainActor + func testRgsUrlValidationRejectsLongDotlessHostQuickly() { + let settings = SettingsViewModel.shared + let longHost = "https://" + String(repeating: "a", count: 40) + + let start = Date() + let isValid = settings.isValidRgsUrl(longHost) + let elapsed = Date().timeIntervalSince(start) + + XCTAssertFalse(isValid) + XCTAssertLessThan(elapsed, 0.1, "RGS validation must not catastrophically backtrack") + } + + @MainActor + func testElectrumUrlValidationRejectsLongDotlessHostQuickly() { + let settings = SettingsViewModel.shared + let longHost = String(repeating: "a", count: 40) + + let start = Date() + let isValid = settings.isValidElectrumURL(longHost) + let elapsed = Date().timeIntervalSince(start) + + XCTAssertFalse(isValid) + XCTAssertLessThan(elapsed, 0.1, "Electrum validation must not catastrophically backtrack") + } + + @MainActor + func testRgsUrlValidationAcceptsValidUrls() { + let settings = SettingsViewModel.shared + + XCTAssertTrue(settings.isValidRgsUrl(""), "empty URL disables RGS and is valid") + XCTAssertTrue(settings.isValidRgsUrl("https://rapidsync.lightningdevkit.org/snapshot")) + XCTAssertTrue(settings.isValidRgsUrl("https://1.2.3.4:443")) + } + + @MainActor + func testRgsUrlValidationRejectsInvalidUrls() { + let settings = SettingsViewModel.shared + + XCTAssertFalse(settings.isValidRgsUrl("http://example.com"), "non-https is rejected") + XCTAssertFalse(settings.isValidRgsUrl("https://"), "missing host is rejected") + } + + @MainActor + func testElectrumUrlValidationAcceptsValidHosts() { + let settings = SettingsViewModel.shared + + XCTAssertTrue(settings.isValidElectrumURL("electrum.blockstream.info")) + XCTAssertTrue(settings.isValidElectrumURL("1.2.3.4")) + XCTAssertTrue(settings.isValidElectrumURL("myhost.local")) + } + + @MainActor + func testElectrumUrlValidationRejectsBareDotlessHost() { + let settings = SettingsViewModel.shared + + XCTAssertFalse(settings.isValidElectrumURL("foobar")) + } +} diff --git a/changelog.d/next/629.fixed.md b/changelog.d/next/629.fixed.md new file mode 100644 index 000000000..be3805737 --- /dev/null +++ b/changelog.d/next/629.fixed.md @@ -0,0 +1 @@ +Fixed a freeze on the Electrum and RGS server settings screens when entering a long hostname.