diff --git a/apple/Packages/TableProCore/Sources/TableProMySQLCore/MySQLPlaintextPassword.swift b/apple/Packages/TableProCore/Sources/TableProMySQLCore/MySQLPlaintextPassword.swift new file mode 100644 index 0000000000..fe50cdbc93 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProMySQLCore/MySQLPlaintextPassword.swift @@ -0,0 +1,29 @@ +import Foundation + +// Connector/C answers a server that asks for the password as typed (mysql_clear_password, or dialog for PAM) +// even without TLS, ignoring MYSQL_ENABLE_CLEARTEXT_PLUGIN. A server, or anyone who strips TLS on the way, can +// ask for it that way, so a session that might run without TLS allows only the methods that keep it hidden. +public enum MySQLPlaintextPassword { + public static let methodsWithoutTLS = [ + "mysql_native_password", + "caching_sha2_password", + "sha256_password", + "mysql_old_password", + "client_ed25519", + "parsec", + "auth_gssapi_client" + ].joined(separator: ",") + + // CR_PLUGIN_NOT_ALLOWED, which Connector/C 3.4.4 returns before it sends anything for the password. + public static let refusedCode: UInt32 = 5_010 + + public static func isRefusal(code: UInt32) -> Bool { + code == refusedCode + } + + public static var refusalMessage: String { + String( + localized: "The server asked for the password in plain text, which TablePro sends only over TLS. Set SSL Mode to Required, or turn TLS on for the server." + ) + } +} diff --git a/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index 18c2f102dc..047604a05d 100644 --- a/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -103,6 +103,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { private let database: String private let sslConfig: SSLConfiguration private let enableCleartextPlugin: Bool + private let tunnelSecuresTransport: Bool private let queryTimeoutSeconds: Int private let connectionEncoding: MySQLConnectionEncoding private let connectTimeoutMilliseconds: Int @@ -307,6 +308,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { database: String, sslConfig: SSLConfiguration, enableCleartextPlugin: Bool = false, + tunnelSecuresTransport: Bool = false, queryTimeoutSeconds: Int = 0, connectionEncoding: MySQLConnectionEncoding = .utf8, connectTimeoutMilliseconds: Int = MySQLConnectTimeout.defaultMilliseconds, @@ -318,6 +320,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { self.database = database self.sslConfig = sslConfig self.enableCleartextPlugin = enableCleartextPlugin + self.tunnelSecuresTransport = tunnelSecuresTransport self.queryTimeoutSeconds = queryTimeoutSeconds self.connectionEncoding = connectionEncoding self.connectTimeoutMilliseconds = MySQLConnectTimeout(milliseconds: connectTimeoutMilliseconds).milliseconds @@ -347,24 +350,21 @@ final class MariaDBPluginConnection: @unchecked Sendable { } let mode = self.sslConfig.mode let startsWithTLS = MySQLLocalSocket.attemptsTLS(mode, overSocket: self.transport.isUnixSocket) + // Connector/C 3.4.4 takes a requested TLS for an established one and answers caching_sha2_password's full + // authentication with the typed password, so Preferred asks for TLS outright and falls back without it. + let firstMode: SSLMode = startsWithTLS ? (mode == .preferred ? .required : mode) : .disabled let session: EstablishedSession do { - session = try self.attemptConnect(enforceSSL: startsWithTLS, deadline: deadline) + session = try self.attemptConnect(mode: firstMode, deadline: deadline) } catch let error as MariaDBPluginError where startsWithTLS && mode == .preferred && MariaDBSSLClassifier.sslOnlyErrorCodes.contains(error.code) { logger.notice("MySQL SSL handshake failed (code \(error.code)); falling back to plaintext for .preferred mode") do { - session = try self.attemptConnect(enforceSSL: false, deadline: deadline) + session = try self.attemptConnect(mode: .disabled, deadline: deadline) } catch let fallbackError as MariaDBPluginError { - if let sslError = MariaDBSSLClassifier.classifySSLError(code: fallbackError.code, message: fallbackError.message) { - throw sslError - } - throw fallbackError + throw Self.connectError(fallbackError) } } catch let error as MariaDBPluginError { - if let sslError = MariaDBSSLClassifier.classifySSLError(code: error.code, message: error.message) { - throw sslError - } - throw error + throw Self.connectError(error) } let handle = session.handle @@ -393,8 +393,15 @@ final class MariaDBPluginConnection: @unchecked Sendable { let errorLanguage: String.Encoding? } + private static func connectError(_ error: MariaDBPluginError) -> any Error { + if MySQLPlaintextPassword.isRefusal(code: error.code) { + return MariaDBPluginError(code: 0, message: MySQLPlaintextPassword.refusalMessage, sqlState: nil) + } + return MariaDBSSLClassifier.classifySSLError(code: error.code, message: error.message) ?? error + } + private func attemptConnect( - enforceSSL: Bool, + mode: SSLMode, deadline: MySQLConnectDeadline ) throws -> EstablishedSession { var timeout = try connectSocketTimeoutSeconds(deadline: deadline) @@ -418,7 +425,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { var allowLocalInfile: UInt32 = 0 mysql_options(mysql, MYSQL_OPT_LOCAL_INFILE, &allowLocalInfile) - applyTLS(enforceSSL ? sslConfig.mode : .disabled, to: mysql) + applyTLS(mode, to: mysql) mysql_options(mysql, MYSQL_SET_CHARSET_NAME, "utf8mb4") @@ -664,6 +671,13 @@ final class MariaDBPluginConnection: @unchecked Sendable { // Connector/C turns TLS on for any CA, certificate or key it is given, so a plaintext attempt gets none. private func applyTLS(_ mode: SSLMode, to handle: UnsafeMutablePointer) { MariaDBTLSMode.apply(mode, to: handle) + // A local socket never leaves this Mac, and the mysql client trusts it the same way. The Cloud SQL Auth Proxy and + // an SSH forward to the server's socket encrypt the way there, which their loopback listener cannot show as TLS. + if mode == .disabled || mode == .preferred, !transport.isUnixSocket, !tunnelSecuresTransport { + _ = MySQLPlaintextPassword.methodsWithoutTLS.withCString { + mysql_options(handle, MARIADB_OPT_RESTRICTED_AUTH, $0) + } + } guard mode != .disabled else { return } if sslConfig.verifiesCertificate, !sslConfig.caCertificatePath.isEmpty { _ = sslConfig.caCertificatePath.withCString { mysql_options(handle, MYSQL_OPT_SSL_CA, $0) } diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 2c58123048..4f0256ffb1 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -177,6 +177,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { database: activeDatabaseName, sslConfig: sslConfig, enableCleartextPlugin: config.additionalFields["enableCleartextPlugin"] == "true", + tunnelSecuresTransport: config.additionalFields["tunnelSecuresTransport"] == "true", queryTimeoutSeconds: config.additionalFields["queryTimeoutSeconds"].flatMap { Int($0) } ?? 0, connectionEncoding: MySQLConnectionEncoding(additionalFields: config.additionalFields), connectTimeoutMilliseconds: connectTimeout.milliseconds, diff --git a/apple/Plugins/MySQLDriverTests/MariaDBTLSModeTests.swift b/apple/Plugins/MySQLDriverTests/MariaDBTLSModeTests.swift index 2d77354d3c..7afd68abf1 100644 --- a/apple/Plugins/MySQLDriverTests/MariaDBTLSModeTests.swift +++ b/apple/Plugins/MySQLDriverTests/MariaDBTLSModeTests.swift @@ -1,5 +1,6 @@ import Darwin import Foundation +import TableProMySQLCore import TableProPluginKit import Testing @@ -30,18 +31,74 @@ struct MariaDBTLSModeTests { #expect(!served.sawInPlaintext(Self.secret)) } - @Test("Preferred still signs in to a server without TLS") + // Preferred asks for TLS outright first, so a server without it sees one attempt end before the sign-in. + @Test("Preferred still signs in to a server without TLS that takes the hashed password") func preferredFallsBack() async throws { + let server = try PlaintextMySQLServer(asksForCleartext: false) + defer { server.close() } + async let sessions = server.serve(sessions: 2) + let connection = connection(port: server.port, mode: .preferred, cleartext: false) + + try await connection.connect() + connection.disconnect() + + let served = await sessions + #expect(!served[0].sawLogin) + #expect(served[1].loggedIn) + } + + // Connector/C 3.4.4 reads a requested TLS as an established one for caching_sha2_password's full authentication. + @Test("Preferred never sends the typed password to a server without TLS that asks for full SHA-2 authentication") + func preferredKeepsPasswordFromFullSHA2Auth() async throws { + let server = try PlaintextMySQLServer(asksForFullSHA2Auth: true) + defer { server.close() } + async let sessions = server.serve(sessions: 2) + let connection = connection(port: server.port, mode: .preferred, cleartext: false) + + _ = try? await connection.connect() + + let served = await sessions + #expect(!served.contains { $0.loggedIn || $0.sawInPlaintext(Self.secret) }) + } + + @Test("Behind the Cloud SQL Auth Proxy, which encrypts the hop itself, a token asked for in plain text is sent") + func securedTunnelSendsPlaintextPassword() async throws { let server = try PlaintextMySQLServer() defer { server.close() } async let session = server.serveOneSession() - let connection = connection(port: server.port, mode: .preferred, cleartext: false) + let connection = MariaDBPluginConnection( + transport: .tcp(host: "127.0.0.1", port: UInt32(server.port)), + user: "app", + password: Self.secret, + database: "", + sslConfig: SSLConfiguration(mode: .disabled), + enableCleartextPlugin: true, + tunnelSecuresTransport: true + ) try await connection.connect() connection.disconnect() let served = await session - #expect(served.sawLogin) + #expect(served.loggedIn) + } + + // Preferred tries again with TLS required, which this server cannot give, so it connects twice. + @Test( + "Disabled and Preferred refuse a server without TLS that asks for the password in plain text", + arguments: [(SSLMode.disabled, 1), (SSLMode.preferred, 2)] + ) + func plaintextPasswordNeedsTLS(mode: SSLMode, connects: Int) async throws { + let server = try PlaintextMySQLServer() + defer { server.close() } + async let sessions = server.serve(sessions: connects) + let connection = connection(port: server.port, mode: mode, cleartext: false) + + let error = await #expect(throws: (any Error).self) { try await connection.connect() } + + #expect(error?.localizedDescription == MySQLPlaintextPassword.refusalMessage) + let served = await sessions + #expect(!served.contains { $0.loggedIn || $0.sawInPlaintext(Self.secret) }) } @Test("The kill connection keeps TLS when the session had it, and Preferred becomes Required there") @@ -65,13 +122,15 @@ struct MariaDBTLSModeTests { } } -// One session: a handshake without CLIENT_SSL, then a switch to mysql_clear_password, then OK. +// One session: a handshake without CLIENT_SSL, then a switch to mysql_clear_password (or to caching_sha2_password and +// its full authentication), then OK. private final class PlaintextMySQLServer: @unchecked Sendable { struct SetupFailure: Error {} struct Session: Sendable { var received: [UInt8] = [] var sawLogin = false + var loggedIn = false func sawInPlaintext(_ secret: String) -> Bool { let needle = Array(secret.utf8) @@ -84,12 +143,18 @@ private final class PlaintextMySQLServer: @unchecked Sendable { private static let okPacket: [UInt8] = [0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00] private static let switchToCleartext: [UInt8] = [0xFE] + Array("mysql_clear_password".utf8) + [0] + private static let switchToSHA2: [UInt8] = [0xFE] + Array("caching_sha2_password".utf8) + [0] + + Array("abcdefghijklmnopqrst".utf8) + [0] private static let sslRequestLength = 32 let port: Int private let listener: Int32 + private let asksForCleartext: Bool + private let asksForFullSHA2Auth: Bool - init() throws { + init(asksForCleartext: Bool = true, asksForFullSHA2Auth: Bool = false) throws { + self.asksForCleartext = asksForCleartext + self.asksForFullSHA2Auth = asksForFullSHA2Auth let socketFD = socket(AF_INET, SOCK_STREAM, 0) guard socketFD >= 0 else { throw SetupFailure() } var option: Int32 = 1 @@ -120,9 +185,13 @@ private final class PlaintextMySQLServer: @unchecked Sendable { } func serveOneSession() async -> Session { + await serve(sessions: 1)[0] + } + + func serve(sessions count: Int) async -> [Session] { await withCheckedContinuation { continuation in DispatchQueue(label: "com.TablePro.tests.plaintext-mysql").async { - continuation.resume(returning: self.runSession()) + continuation.resume(returning: (0.. String? private var staleSweep: Task? private let reaperTimings: StaleProcessReaper.Timings + private let ownsListener: @Sendable (Int32?, Int) -> Bool private static let runnerRegistry = OSAllocatedUnfairLock(initialState: [UUID: any SupervisedProcessRunner]()) @@ -38,9 +39,13 @@ actor CloudSQLProxyManager: TunnelManaging { runnerFactory: @escaping () -> any SupervisedProcessRunner = { ProcessSupervisedRunner() }, binaryManager: CloudSQLProxyBinaryManager = .shared, systemBinaryLookup: @escaping (String) -> String? = { CLIExecutableFinder.findExecutable($0) }, - reaperTimings: StaleProcessReaper.Timings = .production + reaperTimings: StaleProcessReaper.Timings = .production, + ownsListener: @escaping @Sendable (Int32?, Int) -> Bool = { pid, port in + pid.map { LoopbackPort.isListening(pid: $0, port: port) } ?? false + } ) { self.runnerFactory = runnerFactory + self.ownsListener = ownsListener self.binaryManager = binaryManager self.systemBinaryLookup = systemBinaryLookup self.reaperTimings = reaperTimings @@ -93,6 +98,16 @@ actor CloudSQLProxyManager: TunnelManaging { deleteCredentialsFile(at: credentialsFilePath) throw CloudSQLProxyError.noAvailablePort } + // Readiness only sees that something answers on the port, and a program already there would take the + // sign-in meant for the proxy, which can be a password or token the proxy's encryption lets go as typed. + if await LoopbackPort.isReachable(host: "127.0.0.1", port: port) { + guard config.localPort == nil else { + deleteCredentialsFile(at: credentialsFilePath) + throw CloudSQLProxyError.portInUse(port) + } + lastError = CloudSQLProxyError.noAvailablePort + continue + } let runner = runnerFactory() let arguments = Self.buildArguments(config: config, port: port, credentialsFilePath: credentialsFilePath) @@ -281,7 +296,9 @@ actor CloudSQLProxyManager: TunnelManaging { if await monitor.streamEnded { throw CloudSQLProxyError.startupFailed(stderrTail: await monitor.tail) } - if await LoopbackPort.isReachable(host: "127.0.0.1", port: port) { + // A program that took the port after the check above answers too, so the proxy has to own it. + if await LoopbackPort.isReachable(host: "127.0.0.1", port: port), + ownsListener(runner.processIdentifier, port) { return } let remainingNanoseconds = UInt64(max(1, deadline.remainingMilliseconds)) * 1_000_000 diff --git a/apple/TablePro/Core/Database/DatabaseManager+SSH.swift b/apple/TablePro/Core/Database/DatabaseManager+SSH.swift index 7163a8158d..cfe7f5596f 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+SSH.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+SSH.swift @@ -26,7 +26,9 @@ extension DatabaseManager { sshPasswordOverride: String? = nil, deadline: ConnectionDeadline ) async throws -> DatabaseConnection { - let connection = SSHProfileStorage.shared.refreshingLinkedProfile(original) + var connection = SSHProfileStorage.shared.refreshingLinkedProfile(original) + // Only the tunnel this connect opens says it secures the transport; a saved or imported field never does. + connection.additionalFields[DatabaseConnection.tunnelSecuresTransportKey] = nil let enabledKinds = connection.enabledTunnelKinds guard enabledKinds.count <= 1 else { throw ConnectionTunnelError.mutualExclusivityViolation(enabledKinds) diff --git a/apple/TablePro/Core/Database/DatabaseManager+Tunnel.swift b/apple/TablePro/Core/Database/DatabaseManager+Tunnel.swift index 6917989920..af4fef7ec8 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Tunnel.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Tunnel.swift @@ -57,7 +57,8 @@ extension DatabaseManager { let forwardEndpoint = connection.tunnelForwardEndpoint effectiveFields[DatabaseConnection.preTunnelHostKey] = forwardEndpoint.host effectiveFields[DatabaseConnection.preTunnelPortKey] = String(forwardEndpoint.port) - effectiveFields[DatabaseConnection.tunnelSecuresTransportKey] = securesTransport ? "true" : nil + // An SSH forward to the server's own socket is encrypted the whole way, as the proxy's hop is. + effectiveFields[DatabaseConnection.tunnelSecuresTransportKey] = securesTransport || forwardsToUnixSocket ? "true" : nil /// A host list names the servers the driver would reach for itself, and behind a tunnel /// there is one forwarded endpoint instead, so the list has to go. An SRV connection is /// the exception: its host is a lookup name rather than a server to dial, the driver diff --git a/apple/TablePro/Core/Process/LoopbackPort.swift b/apple/TablePro/Core/Process/LoopbackPort.swift index 898849b9af..59c8b46008 100644 --- a/apple/TablePro/Core/Process/LoopbackPort.swift +++ b/apple/TablePro/Core/Process/LoopbackPort.swift @@ -65,4 +65,28 @@ enum LoopbackPort { connection.start(queue: .global(qos: .utility)) } } + + // Whether this process holds a listening TCP socket on the port, read from the kernel's own table, so a program + // that answers on the port first is never taken for it. + static func isListening(pid: Int32, port: Int) -> Bool { + let bytes = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0) + guard bytes > 0 else { return false } + let stride = MemoryLayout.stride + var descriptors = [proc_fdinfo](repeating: proc_fdinfo(), count: Int(bytes) / stride) + let filled = descriptors.withUnsafeMutableBytes { + proc_pidinfo(pid, PROC_PIDLISTFDS, 0, $0.baseAddress, Int32($0.count)) + } + guard filled > 0 else { return false } + for descriptor in descriptors.prefix(Int(filled) / stride) + where descriptor.proc_fdtype == UInt32(PROX_FDTYPE_SOCKET) { + var info = socket_fdinfo() + let size = Int32(MemoryLayout.size) + guard proc_pidfdinfo(pid, descriptor.proc_fd, PROC_PIDFDSOCKETINFO, &info, size) == size, + info.psi.soi_kind == Int32(SOCKINFO_TCP) else { continue } + let tcp = info.psi.soi_proto.pri_tcp + let localPort = Int(UInt16(bigEndian: UInt16(truncatingIfNeeded: tcp.tcpsi_ini.insi_lport))) + if localPort == port, tcp.tcpsi_state == TSI_S_LISTEN { return true } + } + return false + } } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index a24f0918ed..9f9f306b74 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -24515,6 +24515,9 @@ } } } + }, + "Another program is already listening on local port %d. Choose another port for the Cloud SQL Auth Proxy, or leave it empty." : { + }, "Another selected row adds the same query." : { @@ -187393,6 +187396,10 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "The server asked for the password in plain text, which TablePro sends only over TLS. Set SSL Mode to Required, or turn TLS on for the server." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The server chose this row's key." : { "localizations" : { "fr" : { diff --git a/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index 43918f6b89..2f8f9b9f90 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -562,15 +562,20 @@ private actor MySQLActor { host: host, port: portU32, user: user, password: password, database: database, signsInWithToken: signsInWithToken ) + // Connector/C 3.4.4 takes a requested TLS for an established one and answers caching_sha2_password's full + // authentication with the typed password, so Preferred asks for TLS outright and falls back without it. + let firstPolicy: MySQLTLSPolicy = policy == .preferred ? .required : policy let handle: UnsafeMutablePointer do { do { - handle = try await open(endpoint, ssl: ssl, policy: policy, serverName: serverName, deadline: deadline) + handle = try await open(endpoint, ssl: ssl, policy: firstPolicy, serverName: serverName, deadline: deadline) } catch let failure as AttemptFailure where policy == .preferred && MariaDBSSLClassifier.sslOnlyErrorCodes.contains(failure.code) { // Preferred retries in plaintext after a failed handshake, as the Mac does. handle = try await open(endpoint, ssl: ssl, policy: .disabled, serverName: nil, deadline: deadline) } + } catch let failure as AttemptFailure where MySQLPlaintextPassword.isRefusal(code: failure.code) { + throw MySQLError.connectionFailed(MySQLPlaintextPassword.refusalMessage) } catch let failure as AttemptFailure { if let tokenFailure = MySQLTokenSignIn.connectFailure( signsInWithToken: signsInWithToken, @@ -636,6 +641,11 @@ private actor MySQLActor { mysql_options(handle, MYSQL_OPT_LOCAL_INFILE, &allowLocalInfile) policy.apply(to: handle, ssl: ssl, serverName: serverName) + if policy == .disabled || policy == .preferred { + _ = MySQLPlaintextPassword.methodsWithoutTLS.withCString { + mysql_options(handle, MARIADB_OPT_RESTRICTED_AUTH, $0) + } + } if endpoint.signsInWithToken { // A no-op in Connector/C 3.4, which answers a cleartext request anyway; set to match the Mac. var enableCleartext: my_bool = 1 diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index 9fdbcc8470..ccea12a0a5 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -25073,6 +25073,10 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "The server asked for the password in plain text, which TablePro sends only over TLS. Set SSL Mode to Required, or turn TLS on for the server." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The server did not encrypt the connection, so TablePro closed it. An IAM sign-in needs TLS." : { }, diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/FakeMySQLServer.swift b/apple/TableProMobile/TableProMobileTests/Drivers/FakeMySQLServer.swift index 9265f6a531..25070de66c 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/FakeMySQLServer.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/FakeMySQLServer.swift @@ -5,7 +5,9 @@ import TableProTLSTestFixtures // Loopback sessions that accept any login. With a certificate it upgrades after the client's SSL // request; with `asksForCleartext` it answers the login with a switch to mysql_clear_password, as RDS -// does; with `breaksHandshake` it answers the SSL request with bytes that are not TLS. +// does; with `asksForFullSHA2Auth` it switches to caching_sha2_password and asks for full authentication, +// which a client sends as the typed password once it thinks the session is secure; with `breaksHandshake` +// it answers the SSL request with bytes that are not TLS. final class FakeMySQLServer: @unchecked Sendable { struct SetupFailure: Error {} @@ -23,6 +25,7 @@ final class FakeMySQLServer: @unchecked Sendable { var encrypted = false var sawLogin = false var cleartextPassword: String? + var fullAuthAnswer: [UInt8]? var loggedIn = false var queries: [String] = [] var quit = false @@ -57,17 +60,27 @@ final class FakeMySQLServer: @unchecked Sendable { private static let okPacket: [UInt8] = [0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00] private static let sslRequestLength = 32 private static let switchToCleartext: [UInt8] = [0xFE] + Array("mysql_clear_password".utf8) + [0] + private static let switchToSHA2: [UInt8] = [0xFE] + Array("caching_sha2_password".utf8) + [0] + + Array("abcdefghijklmnopqrst".utf8) + [0] + private static let performFullAuthentication: [UInt8] = [0x01, 0x04] let port: Int private let listener: Int32 private let asksForCleartext: Bool + private let asksForFullSHA2Auth: Bool private let breaksHandshake: Bool private let fixtures: URL? var offersTLS: Bool { fixtures != nil || breaksHandshake } - init(identity: Identity?, asksForCleartext: Bool = false, breaksHandshake: Bool = false) throws { + init( + identity: Identity?, + asksForCleartext: Bool = false, + asksForFullSHA2Auth: Bool = false, + breaksHandshake: Bool = false + ) throws { self.asksForCleartext = asksForCleartext + self.asksForFullSHA2Auth = asksForFullSHA2Auth self.breaksHandshake = breaksHandshake if let identity { let directory = FileManager.default.temporaryDirectory @@ -167,6 +180,15 @@ final class FakeMySQLServer: @unchecked Sendable { session.sawLogin = true var sequence = login.sequence + if asksForFullSHA2Auth { + guard channel.send(Self.switchToSHA2, sequence: sequence &+ 1), + let scramble = channel.readPacket(), + channel.send(Self.performFullAuthentication, sequence: scramble.sequence &+ 1), + let answer = channel.readPacket() + else { return } + session.fullAuthAnswer = answer.payload + return + } if asksForCleartext { guard channel.send(Self.switchToCleartext, sequence: sequence &+ 1), let answer = channel.readPacket() diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/MySQLTLSPolicyTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/MySQLTLSPolicyTests.swift index 72f94cc002..59c67e04dd 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/MySQLTLSPolicyTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/MySQLTLSPolicyTests.swift @@ -1,6 +1,7 @@ import Foundation @testable import TableProMobile import TableProModels +import TableProMySQLCore import TableProPluginKit import TableProTLSTestFixtures import Testing @@ -30,10 +31,59 @@ struct MySQLTLSPolicyTests { #expect(!served.sawInPlaintext(Self.password)) } + // Preferred asks for TLS outright first, so a server without it sees one attempt end before the sign-in. @Test("Preferred still signs in to a server without TLS, in plaintext") func preferredFallsBackToPlaintext() async throws { let server = try FakeMySQLServer(identity: nil) defer { server.close() } + async let sessions = server.serve(sessions: 2) + let driver = driver(port: server.port, ssl: DriverSSLConfiguration(mode: .require, isPreferred: true)) + + try await driver.connect() + try await driver.disconnect() + + let served = await sessions + #expect(!served[0].sawLogin) + #expect(!served[1].encrypted) + #expect(served[1].loggedIn) + } + + // A server, or anyone who strips TLS on the way, can ask for the password as typed; the mysql client refuses. + @Test( + "Disabled and Preferred refuse a server without TLS that asks for the password in plain text", + arguments: [DriverSSLConfiguration.disabled, DriverSSLConfiguration(mode: .require, isPreferred: true)] + ) + func plaintextPasswordNeedsTLS(ssl: DriverSSLConfiguration) async throws { + let server = try FakeMySQLServer(identity: nil, asksForCleartext: true) + defer { server.close() } + async let sessions = server.serve(sessions: ssl.isPreferred ? 2 : 1) + + let error = await connectOutcome(port: server.port, ssl: ssl) + + #expect(error?.localizedDescription.contains(MySQLPlaintextPassword.refusalMessage) == true) + let served = await sessions + #expect(served.allSatisfy { $0.cleartextPassword == nil && !$0.loggedIn }) + #expect(!served.contains { $0.sawInPlaintext(Self.password) }) + } + + // Connector/C 3.4.4 reads a requested TLS as an established one for caching_sha2_password's full authentication. + @Test("Preferred never sends the typed password to a server without TLS that asks for full SHA-2 authentication") + func preferredKeepsPasswordFromFullSHA2Auth() async throws { + let server = try FakeMySQLServer(identity: nil, asksForFullSHA2Auth: true) + defer { server.close() } + async let sessions = server.serve(sessions: 2) + + _ = await connectOutcome(port: server.port, ssl: DriverSSLConfiguration(mode: .require, isPreferred: true)) + + let served = await sessions + #expect(!served.contains { $0.sawInPlaintext(Self.password) }) + #expect(!served.contains { $0.loggedIn }) + } + + @Test("Preferred sends a password asked for in plain text over TLS, to a server that has it") + func preferredSendsPlaintextPasswordOverTLS() async throws { + let server = try FakeMySQLServer(identity: .rogue, asksForCleartext: true) + defer { server.close() } async let session = server.serveOneSession() let driver = driver(port: server.port, ssl: DriverSSLConfiguration(mode: .require, isPreferred: true)) @@ -41,7 +91,8 @@ struct MySQLTLSPolicyTests { try await driver.disconnect() let served = await session - #expect(!served.encrypted) + #expect(served.encrypted) + #expect(served.cleartextPassword == Self.password) #expect(served.loggedIn) } diff --git a/apple/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift b/apple/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift index e60486ce50..622e47ea43 100644 --- a/apple/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift +++ b/apple/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift @@ -11,6 +11,8 @@ final class FakeCloudSQLProxyRunner: SupervisedProcessRunner, @unchecked Sendabl case ready case startupFailure case neverReady + // Another program takes the port after the manager's check and before the proxy binds it. + case squatted } let behavior: Behavior @@ -47,7 +49,7 @@ final class FakeCloudSQLProxyRunner: SupervisedProcessRunner, @unchecked Sendabl lock.withLock { startCalls += 1 } switch behavior { - case .ready: + case .ready, .squatted: if let port = Self.parsePort(arguments) { listenerFd = Self.openListener(port: port) } @@ -106,7 +108,7 @@ final class FakeCloudSQLProxyRunner: SupervisedProcessRunner, @unchecked Sendabl return Int(arguments[index + 1]) } - private static func openListener(port: Int) -> Int32? { + static func openListener(port: Int) -> Int32? { let descriptor = socket(AF_INET, SOCK_STREAM, 0) guard descriptor >= 0 else { return nil } var reuse: Int32 = 1 @@ -128,6 +130,13 @@ final class FakeCloudSQLProxyRunner: SupervisedProcessRunner, @unchecked Sendabl } } +// The fake runner's listener stands in for the proxy's, so these managers take it as the proxy's own. +private extension CloudSQLProxyManager { + init(testRunner: @escaping () -> any SupervisedProcessRunner) { + self.init(runnerFactory: testRunner, ownsListener: { _, _ in true }) + } +} + @Suite("Cloud SQL Auth Proxy manager", .serialized) struct CloudSQLProxyManagerTests { private func config(instance: String = "proj:region:inst", localPort: Int? = nil) -> CloudSQLProxyConfiguration { @@ -161,7 +170,8 @@ struct CloudSQLProxyManagerTests { expectedSHA256: ["arm64": "deadbeef", "amd64": "deadbeef"], fetch: { _ in Data("still wrong".utf8) } ), - systemBinaryLookup: { _ in nil } + systemBinaryLookup: { _ in nil }, + ownsListener: { _, _ in true } ) await #expect(throws: CloudSQLProxyError.binaryNotFound) { @@ -194,7 +204,8 @@ struct CloudSQLProxyManagerTests { expectedSHA256: ["arm64": digest, "amd64": digest], fetch: { _ in bytes } ), - systemBinaryLookup: { _ in nil } + systemBinaryLookup: { _ in nil }, + ownsListener: { _, _ in true } ) let port = try await manager.createTunnel( @@ -212,7 +223,7 @@ struct CloudSQLProxyManagerTests { @Test("createTunnel returns the allocated port once the proxy is listening") func readinessSucceeds() async throws { let fake = FakeCloudSQLProxyRunner(behavior: .ready) - let manager = CloudSQLProxyManager(runnerFactory: { fake }) + let manager = CloudSQLProxyManager(testRunner: { fake }) let id = UUID() let port = try await manager.createTunnel(connectionId: id, config: config()) @@ -226,10 +237,39 @@ struct CloudSQLProxyManagerTests { #expect(!(await manager.hasTunnel(connectionId: id))) } + @Test("A configured port another program already listens on is refused before the proxy starts") + func occupiedPortIsRefused() async throws { + let port = try #require(LoopbackPort.allocateFree()) + let squatter = try #require(FakeCloudSQLProxyRunner.openListener(port: port)) + defer { close(squatter) } + let fake = FakeCloudSQLProxyRunner(behavior: .ready) + let manager = CloudSQLProxyManager(testRunner: { fake }) + + await #expect(throws: CloudSQLProxyError.portInUse(port)) { + _ = try await manager.createTunnel(connectionId: UUID(), config: self.config(localPort: port)) + } + #expect(fake.startCallCount == 0) + } + + @Test("A listener the proxy process does not own is never taken for the proxy") + func listenerTheProxyDoesNotOwnIsRefused() async throws { + let fake = FakeCloudSQLProxyRunner(behavior: .squatted) + let manager = CloudSQLProxyManager(runnerFactory: { fake }) + let deadline = ConnectionDeadline( + configuredSeconds: 30, + instant: ContinuousClock.now.advanced(by: .milliseconds(800)) + ) + + await #expect(throws: (any Error).self) { + _ = try await manager.createTunnel(connectionId: UUID(), config: self.config(), deadline: deadline) + } + #expect(fake.stopCallCount >= 1) + } + @Test("createTunnel fails when the proxy exits during startup") func startupFailure() async { let fake = FakeCloudSQLProxyRunner(behavior: .startupFailure) - let manager = CloudSQLProxyManager(runnerFactory: { fake }) + let manager = CloudSQLProxyManager(testRunner: { fake }) await #expect(throws: CloudSQLProxyError.self) { _ = try await manager.createTunnel(connectionId: UUID(), config: self.config(localPort: 59_997)) @@ -239,7 +279,7 @@ struct CloudSQLProxyManagerTests { @Test("connect deadline stops the proxy before registration") func deadlineStopsUnreadyProcess() async throws { let fake = FakeCloudSQLProxyRunner(behavior: .neverReady) - let manager = CloudSQLProxyManager(runnerFactory: { fake }) + let manager = CloudSQLProxyManager(testRunner: { fake }) let id = UUID() let creation = Task { let deadline = ConnectionDeadline( @@ -275,7 +315,7 @@ struct CloudSQLProxyManagerTests { @Test("an invalid instance connection name is rejected before launching") func invalidInstance() async { - let manager = CloudSQLProxyManager(runnerFactory: { FakeCloudSQLProxyRunner(behavior: .ready) }) + let manager = CloudSQLProxyManager(testRunner: { FakeCloudSQLProxyRunner(behavior: .ready) }) await #expect(throws: CloudSQLProxyError.invalidInstanceConnectionName) { _ = try await manager.createTunnel(connectionId: UUID(), config: self.config(instance: "not-valid")) @@ -284,7 +324,7 @@ struct CloudSQLProxyManagerTests { @Test("missing binary throws binaryNotFound") func missingBinary() async { - let manager = CloudSQLProxyManager(runnerFactory: { FakeCloudSQLProxyRunner(behavior: .ready) }) + let manager = CloudSQLProxyManager(testRunner: { FakeCloudSQLProxyRunner(behavior: .ready) }) let badConfig = CloudSQLProxyConfiguration(instanceConnectionName: "p:r:i", binaryPath: "/nonexistent/cloud-sql-proxy") await #expect(throws: CloudSQLProxyError.binaryNotFound) { @@ -295,7 +335,7 @@ struct CloudSQLProxyManagerTests { @Test("service account key is written 0600 and removed on close") func credentialsFileLifecycle() async throws { let fake = FakeCloudSQLProxyRunner(behavior: .ready) - let manager = CloudSQLProxyManager(runnerFactory: { fake }) + let manager = CloudSQLProxyManager(testRunner: { fake }) let id = UUID() let keyConfig = CloudSQLProxyConfiguration( instanceConnectionName: "proj:region:inst", @@ -322,7 +362,7 @@ struct CloudSQLProxyManagerTests { @Test("terminateAllProcessesSync stops the running proxy") func terminateAllStops() async throws { let fake = FakeCloudSQLProxyRunner(behavior: .ready) - let manager = CloudSQLProxyManager(runnerFactory: { fake }) + let manager = CloudSQLProxyManager(testRunner: { fake }) _ = try await manager.createTunnel(connectionId: UUID(), config: config()) manager.terminateAllProcessesSync() @@ -340,7 +380,7 @@ struct CloudSQLProxyManagerTests { forKey: "cloudSQLProxyStalePids" ) - let manager = CloudSQLProxyManager(runnerFactory: { FakeCloudSQLProxyRunner(behavior: .ready) }) + let manager = CloudSQLProxyManager(testRunner: { FakeCloudSQLProxyRunner(behavior: .ready) }) await manager.sweepStalePidsIfNeeded() #expect(AppStorageEnvironment.shared.defaults.data(forKey: "cloudSQLProxyStalePids") == nil) diff --git a/apple/TableProTests/Core/Database/CloudSQLIAMAuthTests.swift b/apple/TableProTests/Core/Database/CloudSQLIAMAuthTests.swift index 0cedd88b3c..82d55cbcad 100644 --- a/apple/TableProTests/Core/Database/CloudSQLIAMAuthTests.swift +++ b/apple/TableProTests/Core/Database/CloudSQLIAMAuthTests.swift @@ -324,6 +324,35 @@ struct CloudSQLIAMAuthTests { #expect(connection(type: .mysql, auth: "off").transportSSLConfiguration.mode == .disabled) } + // The MySQL driver sends a password asked for in plain text without TLS only when this claim is set. + @Test("A saved field claiming the tunnel secures the transport is dropped before connecting") + func savedSecuredTransportClaimIsDropped() async throws { + var direct = connection(type: .mysql, auth: "gcpApplicationDefault") + direct.additionalFields[DatabaseConnection.tunnelSecuresTransportKey] = "true" + + let effective = try await DatabaseManager.shared.buildEffectiveConnection( + for: direct, + deadline: ConnectionDeadline(configuredSeconds: 60) + ) + + #expect(!effective.tunnelSecuresTransport) + #expect(effective.transportSSLConfiguration.mode == .required) + } + + @Test("An SSH forward to the server's Unix socket secures the transport the whole way, a TCP forward does not") + func socketForwardSecuresTransport() { + let direct = connection(type: .mysql, auth: "gcpApplicationDefault") + + let socketForward = DatabaseManager.shared.tunneledConnection( + from: direct, localPort: 61_502, forwardsToUnixSocket: true + ) + let tcpForward = DatabaseManager.shared.tunneledConnection(from: direct, localPort: 61_503) + + #expect(socketForward.tunnelSecuresTransport) + #expect(socketForward.transportSSLConfiguration.mode == .disabled) + #expect(!tcpForward.tunnelSecuresTransport) + } + @Test("Behind the Cloud SQL Auth Proxy a token sign-in keeps SSL as set") func proxyKeepsSSL() { let direct = connection(type: .mysql, auth: "gcpApplicationDefault") diff --git a/apple/changelog.d/3734.security.1.md b/apple/changelog.d/3734.security.1.md new file mode 100644 index 0000000000..3f5c5e26f9 --- /dev/null +++ b/apple/changelog.d/3734.security.1.md @@ -0,0 +1 @@ +Cloud SQL Auth Proxy connections set to a Local Port another program already listened on sending the sign-in to that program. diff --git a/apple/changelog.d/3734.security.md b/apple/changelog.d/3734.security.md new file mode 100644 index 0000000000..3d1f6320c0 --- /dev/null +++ b/apple/changelog.d/3734.security.md @@ -0,0 +1 @@ +MySQL and MariaDB connections set to Preferred sending the password in plain text to a server without TLS that asked for full caching_sha2_password authentication, and connections set to Preferred or Disabled sending it to a server that asked for it as typed (mysql_clear_password or PAM dialog) without TLS. diff --git a/apple/scripts/probes/check-mariadb-tls-modes.sh b/apple/scripts/probes/check-mariadb-tls-modes.sh index bd5cca3d22..41065e3eb4 100755 --- a/apple/scripts/probes/check-mariadb-tls-modes.sh +++ b/apple/scripts/probes/check-mariadb-tls-modes.sh @@ -4,7 +4,8 @@ # MYSQL_OPT_SSL_ENFORCE alone falls back to plaintext when a server offers no TLS, verification # checks the host name on every dial but 127.0.0.1, and a 127.0.0.1 dial forgives an untrusted # certificate when no CA is set. The bridge headers work around all three; this proves they still do -# after a libs update. Needs openssl, python3 and Libs/libmariadb_arm64.a (scripts/download-libs.sh). +# after a libs update. Every server here asks for the password in plain text, which Connector/C answers +# even without TLS unless MARIADB_OPT_RESTRICTED_AUTH leaves mysql_clear_password out. Needs openssl, python3 and Libs/libmariadb_arm64.a (scripts/download-libs.sh). # Exits non-zero on any unexpected outcome. set -uo pipefail @@ -84,8 +85,10 @@ check() { } for app in macos ios; do + # Preferred is Required, then Disabled after an SSL-only error such as this refusal. check "Required, server without TLS" $app refuse "" 127.0.0.1 required "$SECRET" - check "Preferred, server without TLS" $app plain "" 127.0.0.1 preferred "$SECRET" + # The server asks for the password in plain text, which goes only over TLS. + check "Disabled (Preferred's fallback), server without TLS" $app refuse "" 127.0.0.1 disabled "$SECRET" check "Required, self-signed certificate" $app connect "--cert self.pem --key self.key" 127.0.0.1 required "$SECRET" check "Verify CA, other name, dialed by name" $app connect "--cert other.pem --key other.key" localhost \ verify-ca "$SECRET" ca.pem diff --git a/apple/scripts/probes/mariadb-tls-modes/tls-client.c b/apple/scripts/probes/mariadb-tls-modes/tls-client.c index d177ba8d6a..f5a76f15d8 100644 --- a/apple/scripts/probes/mariadb-tls-modes/tls-client.c +++ b/apple/scripts/probes/mariadb-tls-modes/tls-client.c @@ -1,5 +1,6 @@ // One connect with the TLS options an app's CMariaDB.h sets for an SSL mode; -DTABLEPRO_IOS picks the iOS header. -// usage: tls-client [ca|-] [server-name] +// Preferred is two of these in the apps: Required, then Disabled after an SSL-only error. +// usage: tls-client [ca|-] [server-name] #include #include @@ -31,9 +32,13 @@ int main(int argc, char **argv) { my_bool on = 1; my_bool off = 0; - if (strcmp(mode, "disabled") == 0 || strcmp(mode, "preferred") == 0) { - mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, strcmp(mode, "preferred") == 0 ? &on : &off); + if (strcmp(mode, "disabled") == 0) { + mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &off); mysql_options(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &off); + // MySQLPlaintextPassword.methodsWithoutTLS, which both apps set on a session without TLS. + mysql_optionsv(mysql, MARIADB_OPT_RESTRICTED_AUTH, + "mysql_native_password,caching_sha2_password,sha256_password,mysql_old_password," + "client_ed25519,parsec,auth_gssapi_client"); } else if (strcmp(mode, "required") == 0) { tablepro_mysql_require_tls_without_verification(mysql); } else if (strcmp(mode, "verify-ca") == 0) {