From cd6d354ffc94419fa331adfadfb5977182a2e140 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 4 Mar 2025 17:22:51 +1100 Subject: [PATCH 1/6] chore: enforce minimum coder server version of v2.20.0 --- Coder Desktop/VPNLib/Download.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Coder Desktop/VPNLib/Download.swift b/Coder Desktop/VPNLib/Download.swift index 586c8af..da28253 100644 --- a/Coder Desktop/VPNLib/Download.swift +++ b/Coder Desktop/VPNLib/Download.swift @@ -10,6 +10,7 @@ public enum ValidationError: Error { case invalidTeamIdentifier(identifier: String?) case missingInfoPList case invalidVersion(version: String?) + case belowMinimumCoderVersion public var description: String { switch self { @@ -29,6 +30,8 @@ public enum ValidationError: Error { "Invalid team identifier: \(identifier ?? "unknown")." case .missingInfoPList: "Info.plist is not embedded within the dylib." + case .belowMinimumCoderVersion: + "The Coder deployment must be version \(SignatureValidator.minimumCoderVersion) or higher to use Coder Desktop." } } @@ -36,6 +39,9 @@ public enum ValidationError: Error { } public class SignatureValidator { + // Whilst older dylibs exist, this app assumes v2.20 or later. + static let minimumCoderVersion = "2.20.0" + private static let expectedName = "CoderVPN" private static let expectedIdentifier = "com.coder.Coder-Desktop.VPN.dylib" private static let expectedTeamIdentifier = "4399GN35BJ" @@ -95,11 +101,20 @@ public class SignatureValidator { throw .invalidIdentifier(identifier: infoPlist[infoNameKey] as? String) } + // Downloaded dylib must match the version of the server guard let dylibVersion = infoPlist[infoShortVersionKey] as? String, - expectedVersion.compare(dylibVersion, options: .numeric) != .orderedDescending + expectedVersion == dylibVersion else { throw .invalidVersion(version: infoPlist[infoShortVersionKey] as? String) } + + // Downloaded dylib must be at least the minimum Coder server version + guard let dylibVersion = infoPlist[infoShortVersionKey] as? String, + // x.compare(y) is .orderedDescending if x > y + minimumCoderVersion.compare(dylibVersion, options: .numeric) != .orderedDescending + else { + throw .belowMinimumCoderVersion + } } } From 6e993581fb2e49d3d9d80f87b07b478350e2e68d Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 4 Mar 2025 17:38:04 +1100 Subject: [PATCH 2/6] lint --- Coder Desktop/VPN/Manager.swift | 4 ++-- Coder Desktop/VPNLib/Download.swift | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Coder Desktop/VPN/Manager.swift b/Coder Desktop/VPN/Manager.swift index f074abb..a1dc6bc 100644 --- a/Coder Desktop/VPN/Manager.swift +++ b/Coder Desktop/VPN/Manager.swift @@ -31,9 +31,9 @@ actor Manager { // The tunnel might be asked to start before the network interfaces have woken up from sleep sessionConfig.waitsForConnectivity = true // URLSession's waiting for connectivity sometimes hangs even when - // the network is up so this is deliberately short (15s) to avoid a + // the network is up so this is deliberately short (30s) to avoid a // poor UX where it appears stuck. - sessionConfig.timeoutIntervalForResource = 15 + sessionConfig.timeoutIntervalForResource = 30 try await download(src: dylibPath, dest: dest, urlSession: URLSession(configuration: sessionConfig)) } catch { throw .download(error) diff --git a/Coder Desktop/VPNLib/Download.swift b/Coder Desktop/VPNLib/Download.swift index da28253..76e9fc2 100644 --- a/Coder Desktop/VPNLib/Download.swift +++ b/Coder Desktop/VPNLib/Download.swift @@ -31,7 +31,10 @@ public enum ValidationError: Error { case .missingInfoPList: "Info.plist is not embedded within the dylib." case .belowMinimumCoderVersion: - "The Coder deployment must be version \(SignatureValidator.minimumCoderVersion) or higher to use Coder Desktop." + """ + The Coder deployment must be version \(SignatureValidator.minimumCoderVersion) + or higher to use Coder Desktop. + """ } } @@ -53,6 +56,7 @@ public class SignatureValidator { private static let signInfoFlags: SecCSFlags = .init(rawValue: kSecCSSigningInformation) // `expectedVersion` must be of the form `[0-9]+.[0-9]+.[0-9]+` + // swiftlint:disable:next cyclomatic_complexity public static func validate(path: URL, expectedVersion: String) throws(ValidationError) { guard FileManager.default.fileExists(atPath: path.path) else { throw .fileNotFound From 48c893cd6ae68075a5cda8aa6a0344a28a976bd3 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 7 Mar 2025 12:49:10 +1100 Subject: [PATCH 3/6] new function --- Coder Desktop/VPNLib/Download.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Coder Desktop/VPNLib/Download.swift b/Coder Desktop/VPNLib/Download.swift index 76e9fc2..d9df989 100644 --- a/Coder Desktop/VPNLib/Download.swift +++ b/Coder Desktop/VPNLib/Download.swift @@ -56,7 +56,6 @@ public class SignatureValidator { private static let signInfoFlags: SecCSFlags = .init(rawValue: kSecCSSigningInformation) // `expectedVersion` must be of the form `[0-9]+.[0-9]+.[0-9]+` - // swiftlint:disable:next cyclomatic_complexity public static func validate(path: URL, expectedVersion: String) throws(ValidationError) { guard FileManager.default.fileExists(atPath: path.path) else { throw .fileNotFound @@ -97,6 +96,10 @@ public class SignatureValidator { throw .missingInfoPList } + try validateInfo(infoPlist: infoPlist, expectedVersion: expectedVersion) + } + + private static func validateInfo(infoPlist: [String: AnyObject], expectedVersion: String) throws(ValidationError) { guard let plistIdent = infoPlist[infoIdentifierKey] as? String, plistIdent == expectedIdentifier else { throw .invalidIdentifier(identifier: infoPlist[infoIdentifierKey] as? String) } From 4c892a6c6f88d7a1d7e5cca42b1992c5045b3346 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 7 Mar 2025 18:33:40 +1100 Subject: [PATCH 4/6] also check at login --- .../Coder Desktop/Views/LoginForm.swift | 26 +++++++++++++++++++ Coder Desktop/VPNLib/Download.swift | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Coder Desktop/Coder Desktop/Views/LoginForm.swift b/Coder Desktop/Coder Desktop/Views/LoginForm.swift index 881c1a8..14b37f7 100644 --- a/Coder Desktop/Coder Desktop/Views/LoginForm.swift +++ b/Coder Desktop/Coder Desktop/Views/LoginForm.swift @@ -1,5 +1,6 @@ import CoderSDK import SwiftUI +import VPNLib struct LoginForm: View { @EnvironmentObject var state: AppState @@ -78,6 +79,22 @@ struct LoginForm: View { loginError = .failedAuth(error) return } + let buildInfo: BuildInfoResponse + do { + buildInfo = try await client.buildInfo() + } catch { + loginError = .failedAuth(error) + return + } + guard let semver = buildInfo.semver else { + loginError = .missingServerVersion + return + } + // x.compare(y) is .orderedDescending if x > y + guard SignatureValidator.minimumCoderVersion.compare(semver, options: .numeric) != .orderedDescending else { + loginError = .outdatedCoderVersion + return + } state.login(baseAccessURL: url, sessionToken: sessionToken) dismiss() } @@ -190,6 +207,8 @@ enum LoginError: Error { case httpsRequired case noHost case invalidURL + case outdatedCoderVersion + case missingServerVersion case failedAuth(ClientError) var description: String { @@ -200,8 +219,15 @@ enum LoginError: Error { "URL must have a host" case .invalidURL: "Invalid URL" + case .outdatedCoderVersion: + """ + The Coder deployment must be version \(SignatureValidator.minimumCoderVersion) + or higher to use Coder Desktop. + """ case let .failedAuth(err): "Could not authenticate with Coder deployment:\n\(err.localizedDescription)" + case .missingServerVersion: + "Coder deployment did not provide a server version" } } diff --git a/Coder Desktop/VPNLib/Download.swift b/Coder Desktop/VPNLib/Download.swift index d9df989..559be37 100644 --- a/Coder Desktop/VPNLib/Download.swift +++ b/Coder Desktop/VPNLib/Download.swift @@ -43,7 +43,7 @@ public enum ValidationError: Error { public class SignatureValidator { // Whilst older dylibs exist, this app assumes v2.20 or later. - static let minimumCoderVersion = "2.20.0" + public static let minimumCoderVersion = "2.20.0" private static let expectedName = "CoderVPN" private static let expectedIdentifier = "com.coder.Coder-Desktop.VPN.dylib" From d23b1a2a4afa31f44492fbd835e8977d231a30e0 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 7 Mar 2025 18:58:07 +1100 Subject: [PATCH 5/6] fix tests --- .../Coder DesktopTests/LoginFormTests.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Coder Desktop/Coder DesktopTests/LoginFormTests.swift b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift index b58f817..0739f5d 100644 --- a/Coder Desktop/Coder DesktopTests/LoginFormTests.swift +++ b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift @@ -73,6 +73,14 @@ struct LoginTests { @Test func testFailedAuthentication() async throws { let url = URL(string: "https://testFailedAuthentication.com")! + let buildInfo = BuildInfoResponse( + version: "v2.20.0" + ) + try Mock( + url: url.appendingPathComponent("/api/v2/buildinfo"), + statusCode: 200, + data: [.get: Client.encoder.encode(buildInfo)] + ).register() Mock(url: url.appendingPathComponent("/api/v2/users/me"), statusCode: 401, data: [.get: Data()]).register() try await ViewHosting.host(view) { @@ -95,6 +103,9 @@ struct LoginTests { id: UUID(), username: "admin" ) + let buildInfo = BuildInfoResponse( + version: "v2.20.0" + ) try Mock( url: url.appendingPathComponent("/api/v2/users/me"), @@ -102,6 +113,12 @@ struct LoginTests { data: [.get: Client.encoder.encode(user)] ).register() + try Mock( + url: url.appendingPathComponent("/api/v2/buildinfo"), + statusCode: 200, + data: [.get: Client.encoder.encode(buildInfo)] + ).register() + try await ViewHosting.host(view) { try await sut.inspection.inspect { view in try view.find(ViewType.TextField.self).setInput(url.absoluteString) From 590e97c84c273714e595abd2a00f8752d9febe9f Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 7 Mar 2025 19:10:14 +1100 Subject: [PATCH 6/6] add extra test --- .../Coder DesktopTests/LoginFormTests.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Coder Desktop/Coder DesktopTests/LoginFormTests.swift b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift index 0739f5d..a07ced3 100644 --- a/Coder Desktop/Coder DesktopTests/LoginFormTests.swift +++ b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift @@ -95,6 +95,30 @@ struct LoginTests { } } + @Test + func testOutdatedServer() async throws { + let url = URL(string: "https://testOutdatedServer.com")! + let buildInfo = BuildInfoResponse( + version: "v2.19.0" + ) + try Mock( + url: url.appendingPathComponent("/api/v2/buildinfo"), + statusCode: 200, + data: [.get: Client.encoder.encode(buildInfo)] + ).register() + + try await ViewHosting.host(view) { + try await sut.inspection.inspect { view in + try view.find(ViewType.TextField.self).setInput(url.absoluteString) + try view.find(button: "Next").tap() + #expect(throws: Never.self) { try view.find(text: "Session Token") } + try view.find(ViewType.SecureField.self).setInput("valid-token") + try await view.actualView().submit() + #expect(throws: Never.self) { try view.find(ViewType.Alert.self) } + } + } + } + @Test func testSuccessfulLogin() async throws { let url = URL(string: "https://testSuccessfulLogin.com")!