From 9089394f73315f80f910d261bcaae5cb63208c2c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 03:55:13 +0700 Subject: [PATCH 01/10] fix(mysql): refuse to send a plain-text password on a session without TLS --- .../MySQLPlaintextPassword.swift | 29 +++++++++++ .../MariaDBPluginConnection.swift | 43 +++++++++++----- .../MariaDBTLSModeTests.swift | 49 +++++++++++++++---- .../TableProMobile/Drivers/MySQLDriver.swift | 16 ++++++ .../Drivers/MySQLTLSPolicyTests.swift | 36 ++++++++++++++ .../scripts/probes/check-mariadb-tls-modes.sh | 8 ++- .../probes/mariadb-tls-modes/tls-client.c | 4 ++ 7 files changed, 161 insertions(+), 24 deletions(-) create mode 100644 apple/Packages/TableProCore/Sources/TableProMySQLCore/MySQLPlaintextPassword.swift 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..cd2257081c 100644 --- a/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -347,24 +347,28 @@ final class MariaDBPluginConnection: @unchecked Sendable { } let mode = self.sslConfig.mode let startsWithTLS = MySQLLocalSocket.attemptsTLS(mode, overSocket: self.transport.isUnixSocket) + let firstMode: SSLMode = startsWithTLS ? mode : .disabled let session: EstablishedSession do { - session = try self.attemptConnect(enforceSSL: startsWithTLS, deadline: deadline) - } catch let error as MariaDBPluginError where startsWithTLS && mode == .preferred && MariaDBSSLClassifier.sslOnlyErrorCodes.contains(error.code) { + session = try self.attemptConnect(mode: firstMode, deadline: deadline) + } catch let error as MariaDBPluginError where firstMode == .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 + } catch let error as MariaDBPluginError where firstMode == .preferred && MySQLPlaintextPassword.isRefusal(code: error.code) { + // Preferred may have had TLS, or may have lost it on the way, so the password goes only once TLS is required. + do { + session = try self.attemptConnect(mode: .required, deadline: deadline) + } catch let retryError as MariaDBPluginError where MariaDBSSLClassifier.sslOnlyErrorCodes.contains(retryError.code) { + throw Self.connectError(error) + } catch let retryError as MariaDBPluginError { + throw Self.connectError(retryError) } - throw error + } catch let error as MariaDBPluginError { + throw Self.connectError(error) } let handle = session.handle @@ -393,8 +397,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 +429,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 +675,12 @@ 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. + if mode == .disabled || mode == .preferred, !transport.isUnixSocket { + _ = 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/MySQLDriverTests/MariaDBTLSModeTests.swift b/apple/Plugins/MySQLDriverTests/MariaDBTLSModeTests.swift index 2d77354d3c..37e6691dea 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,9 +31,9 @@ struct MariaDBTLSModeTests { #expect(!served.sawInPlaintext(Self.secret)) } - @Test("Preferred still signs in to a server without TLS") + @Test("Preferred still signs in to a server without TLS that takes the hashed password") func preferredFallsBack() async throws { - let server = try PlaintextMySQLServer() + let server = try PlaintextMySQLServer(asksForCleartext: false) defer { server.close() } async let session = server.serveOneSession() let connection = connection(port: server.port, mode: .preferred, cleartext: false) @@ -41,7 +42,25 @@ struct MariaDBTLSModeTests { 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") @@ -72,6 +91,7 @@ private final class PlaintextMySQLServer: @unchecked Sendable { struct Session: Sendable { var received: [UInt8] = [] var sawLogin = false + var loggedIn = false func sawInPlaintext(_ secret: String) -> Bool { let needle = Array(secret.utf8) @@ -88,8 +108,10 @@ private final class PlaintextMySQLServer: @unchecked Sendable { let port: Int private let listener: Int32 + private let asksForCleartext: Bool - init() throws { + init(asksForCleartext: Bool = true) throws { + self.asksForCleartext = asksForCleartext let socketFD = socket(AF_INET, SOCK_STREAM, 0) guard socketFD >= 0 else { throw SetupFailure() } var option: Int32 = 1 @@ -120,9 +142,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.. Date: Wed, 21 Oct 2026 07:23:01 +0700 Subject: [PATCH 02/10] chore: add the plain-text password refusal to both catalogs and the changelog --- apple/TablePro/Resources/Localizable.xcstrings | 4 ++++ apple/TableProMobile/TableProMobile/Localizable.xcstrings | 4 ++++ apple/changelog.d/0000.security.md | 1 + 3 files changed, 9 insertions(+) create mode 100644 apple/changelog.d/0000.security.md diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index a24f0918ed..52b8e6372a 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -187393,6 +187393,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/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/changelog.d/0000.security.md b/apple/changelog.d/0000.security.md new file mode 100644 index 0000000000..79d1e46b79 --- /dev/null +++ b/apple/changelog.d/0000.security.md @@ -0,0 +1 @@ +MySQL and MariaDB connections set to Preferred or Disabled sending the password in plain text when the server asked for it that way without TLS. From ce9b514d4f0ee5005c96c0443f25706568b8911b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 07:49:02 +0700 Subject: [PATCH 03/10] fix(mysql): ask for TLS outright on Preferred, and keep the Cloud SQL proxy's token sign-in --- .../MariaDBPluginConnection.swift | 23 ++++---- .../MySQLDriverPlugin/MySQLPluginDriver.swift | 1 + .../MariaDBTLSModeTests.swift | 58 ++++++++++++++++++- .../Core/Database/DatabaseManager+SSH.swift | 4 +- .../TableProMobile/Drivers/MySQLDriver.swift | 14 ++--- .../Drivers/FakeMySQLServer.swift | 26 ++++++++- .../Drivers/MySQLTLSPolicyTests.swift | 37 ++++++++---- .../Core/Database/CloudSQLIAMAuthTests.swift | 15 +++++ apple/changelog.d/0000.security.md | 2 +- .../scripts/probes/check-mariadb-tls-modes.sh | 7 +-- .../probes/mariadb-tls-modes/tls-client.c | 9 +-- 11 files changed, 147 insertions(+), 49 deletions(-) diff --git a/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index cd2257081c..f8f370dd68 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,26 +350,19 @@ final class MariaDBPluginConnection: @unchecked Sendable { } let mode = self.sslConfig.mode let startsWithTLS = MySQLLocalSocket.attemptsTLS(mode, overSocket: self.transport.isUnixSocket) - let firstMode: SSLMode = startsWithTLS ? mode : .disabled + // 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(mode: firstMode, deadline: deadline) - } catch let error as MariaDBPluginError where firstMode == .preferred && MariaDBSSLClassifier.sslOnlyErrorCodes.contains(error.code) { + } 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(mode: .disabled, deadline: deadline) } catch let fallbackError as MariaDBPluginError { throw Self.connectError(fallbackError) } - } catch let error as MariaDBPluginError where firstMode == .preferred && MySQLPlaintextPassword.isRefusal(code: error.code) { - // Preferred may have had TLS, or may have lost it on the way, so the password goes only once TLS is required. - do { - session = try self.attemptConnect(mode: .required, deadline: deadline) - } catch let retryError as MariaDBPluginError where MariaDBSSLClassifier.sslOnlyErrorCodes.contains(retryError.code) { - throw Self.connectError(error) - } catch let retryError as MariaDBPluginError { - throw Self.connectError(retryError) - } } catch let error as MariaDBPluginError { throw Self.connectError(error) } @@ -675,8 +671,9 @@ 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. - if mode == .disabled || mode == .preferred, !transport.isUnixSocket { + // A local socket never leaves this Mac, and the mysql client trusts it the same way. The Cloud SQL Auth Proxy + // encrypts the hop to the instance, which its 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) } 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 37e6691dea..7afd68abf1 100644 --- a/apple/Plugins/MySQLDriverTests/MariaDBTLSModeTests.swift +++ b/apple/Plugins/MySQLDriverTests/MariaDBTLSModeTests.swift @@ -31,16 +31,54 @@ struct MariaDBTLSModeTests { #expect(!served.sawInPlaintext(Self.secret)) } + // 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 session = server.serveOneSession() + 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 = 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.loggedIn) } @@ -84,7 +122,8 @@ 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 {} @@ -104,14 +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(asksForCleartext: Bool = true) 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 @@ -168,6 +211,15 @@ private final class PlaintextMySQLServer: @unchecked Sendable { } guard login.payload.count != Self.sslRequestLength else { return session } session.sawLogin = true + if asksForFullSHA2Auth { + // A client that thinks the session is secure answers the full-authentication request with the password. + guard send(Self.switchToSHA2, sequence: login.sequence &+ 1, to: client), + let scramble = readPacket(client, into: &session), + send([0x01, 0x04], sequence: scramble.sequence &+ 1, to: client) + else { return session } + _ = readPacket(client, into: &session) + return session + } if asksForCleartext { guard send(Self.switchToCleartext, sequence: login.sequence &+ 1, to: client), let answer = readPacket(client, into: &session), 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/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index 43376a253c..2f8f9b9f90 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -562,23 +562,17 @@ 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 policy == .preferred && MySQLPlaintextPassword.isRefusal(code: failure.code) { - // Preferred may have had TLS, or may have lost it on the way, so the password goes only once TLS is - // required, as the Mac does. - do { - handle = try await open(endpoint, ssl: ssl, policy: .required, serverName: nil, deadline: deadline) - } catch let retry as AttemptFailure where MariaDBSSLClassifier.sslOnlyErrorCodes.contains(retry.code) { - throw failure - } } } catch let failure as AttemptFailure where MySQLPlaintextPassword.isRefusal(code: failure.code) { throw MySQLError.connectionFailed(MySQLPlaintextPassword.refusalMessage) 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 239885c00f..59c67e04dd 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/MySQLTLSPolicyTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/MySQLTLSPolicyTests.swift @@ -31,19 +31,21 @@ 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 session = server.serveOneSession() + 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 session - #expect(!served.encrypted) - #expect(served.loggedIn) + 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. @@ -64,21 +66,34 @@ struct MySQLTLSPolicyTests { #expect(!served.contains { $0.sawInPlaintext(Self.password) }) } - @Test("Preferred sends a password asked for in plain text once TLS is required, to a server that has TLS") + // 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 sessions = server.serve(sessions: 2) + async let session = server.serveOneSession() 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].cleartextPassword == nil) - #expect(served[1].encrypted) - #expect(served[1].cleartextPassword == Self.password) - #expect(served[1].loggedIn) + let served = await session + #expect(served.encrypted) + #expect(served.cleartextPassword == Self.password) + #expect(served.loggedIn) } @Test("Preferred retries in plaintext after a failed TLS handshake, as the Mac does") diff --git a/apple/TableProTests/Core/Database/CloudSQLIAMAuthTests.swift b/apple/TableProTests/Core/Database/CloudSQLIAMAuthTests.swift index 0cedd88b3c..2bde11c602 100644 --- a/apple/TableProTests/Core/Database/CloudSQLIAMAuthTests.swift +++ b/apple/TableProTests/Core/Database/CloudSQLIAMAuthTests.swift @@ -324,6 +324,21 @@ 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("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/0000.security.md b/apple/changelog.d/0000.security.md index 79d1e46b79..3d1f6320c0 100644 --- a/apple/changelog.d/0000.security.md +++ b/apple/changelog.d/0000.security.md @@ -1 +1 @@ -MySQL and MariaDB connections set to Preferred or Disabled sending the password in plain text when the server asked for it that way without TLS. +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 f3da0d2538..41065e3eb4 100755 --- a/apple/scripts/probes/check-mariadb-tls-modes.sh +++ b/apple/scripts/probes/check-mariadb-tls-modes.sh @@ -85,11 +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" - # The server asks for the password in plain text, which goes only over TLS; the apps retry Preferred as Required. - check "Preferred, server without TLS" $app refuse "" 127.0.0.1 preferred "$SECRET" - check "Disabled, server without TLS" $app refuse "" 127.0.0.1 disabled "$SECRET" - check "Preferred, self-signed certificate" $app refuse "--cert self.pem --key self.key" 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 ebdcdd8ce4..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,10 +32,10 @@ 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 that may run without TLS. + // 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"); From 3fcac50f04b47ab282ef9485c9345dbf6e1b7a72 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 07:54:24 +0700 Subject: [PATCH 04/10] fix(cloudsql): refuse a proxy port another program already listens on --- .../Core/CloudSQL/CloudSQLProxyError.swift | 6 ++++++ .../Core/CloudSQL/CloudSQLProxyManager.swift | 10 ++++++++++ .../CloudSQL/CloudSQLProxyManagerTests.swift | 16 +++++++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apple/TablePro/Core/CloudSQL/CloudSQLProxyError.swift b/apple/TablePro/Core/CloudSQL/CloudSQLProxyError.swift index d905168e54..6f02383c82 100644 --- a/apple/TablePro/Core/CloudSQL/CloudSQLProxyError.swift +++ b/apple/TablePro/Core/CloudSQL/CloudSQLProxyError.swift @@ -12,6 +12,7 @@ enum CloudSQLProxyError: Error, LocalizedError, Equatable { case credentialsWriteFailed case startupFailed(stderrTail: String) case readinessTimeout(stderrTail: String) + case portInUse(Int) var errorDescription: String? { switch self { @@ -27,6 +28,11 @@ enum CloudSQLProxyError: Error, LocalizedError, Equatable { return stderrTail.isEmpty ? String(localized: "The Cloud SQL Auth Proxy failed to start.") : String(format: String(localized: "The Cloud SQL Auth Proxy failed to start: %@"), stderrTail) + case .portInUse(let port): + return String( + format: String(localized: "Another program is already listening on local port %d. Choose another port for the Cloud SQL Auth Proxy, or leave it empty."), + port + ) case .readinessTimeout(let stderrTail): return stderrTail.isEmpty ? String(localized: "The Cloud SQL Auth Proxy did not become ready in time.") diff --git a/apple/TablePro/Core/CloudSQL/CloudSQLProxyManager.swift b/apple/TablePro/Core/CloudSQL/CloudSQLProxyManager.swift index a2538a8976..190af5c9ca 100644 --- a/apple/TablePro/Core/CloudSQL/CloudSQLProxyManager.swift +++ b/apple/TablePro/Core/CloudSQL/CloudSQLProxyManager.swift @@ -93,6 +93,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) diff --git a/apple/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift b/apple/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift index e60486ce50..44eb4351ee 100644 --- a/apple/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift +++ b/apple/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift @@ -106,7 +106,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 @@ -226,6 +226,20 @@ 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(runnerFactory: { fake }) + + await #expect(throws: CloudSQLProxyError.portInUse(port)) { + _ = try await manager.createTunnel(connectionId: UUID(), config: self.config(localPort: port)) + } + #expect(fake.startCallCount == 0) + } + @Test("createTunnel fails when the proxy exits during startup") func startupFailure() async { let fake = FakeCloudSQLProxyRunner(behavior: .startupFailure) From d22dc1b1151f09419c5f7b312f42688aa3973bb5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 07:54:30 +0700 Subject: [PATCH 05/10] docs(changelog): note the Cloud SQL proxy port fix --- apple/changelog.d/0000.security.1.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/0000.security.1.md diff --git a/apple/changelog.d/0000.security.1.md b/apple/changelog.d/0000.security.1.md new file mode 100644 index 0000000000..3f5c5e26f9 --- /dev/null +++ b/apple/changelog.d/0000.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. From 0ce4f4b9d80fb693b55d2129ddad71880c58cd04 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 07:57:33 +0700 Subject: [PATCH 06/10] fix(mysql): keep plain-text sign-ins through an SSH forward to the server's socket --- .../MariaDBPluginConnection.swift | 4 ++-- .../Core/Database/DatabaseManager+Tunnel.swift | 3 ++- .../Core/Database/CloudSQLIAMAuthTests.swift | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index f8f370dd68..047604a05d 100644 --- a/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -671,8 +671,8 @@ 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 - // encrypts the hop to the instance, which its loopback listener cannot show as TLS. + // 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) 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/TableProTests/Core/Database/CloudSQLIAMAuthTests.swift b/apple/TableProTests/Core/Database/CloudSQLIAMAuthTests.swift index 2bde11c602..82d55cbcad 100644 --- a/apple/TableProTests/Core/Database/CloudSQLIAMAuthTests.swift +++ b/apple/TableProTests/Core/Database/CloudSQLIAMAuthTests.swift @@ -339,6 +339,20 @@ struct CloudSQLIAMAuthTests { #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") From 9002e78c3d348920dccc2ae5baf96fda04071629 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 08:07:24 +0700 Subject: [PATCH 07/10] fix(cloudsql): take the proxy as ready only once its own process listens on the port --- .../Core/CloudSQL/CloudSQLProxyManager.swift | 11 ++++- .../TablePro/Core/Process/LoopbackPort.swift | 24 ++++++++++ .../CloudSQL/CloudSQLProxyManagerTests.swift | 46 +++++++++++++++---- 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/apple/TablePro/Core/CloudSQL/CloudSQLProxyManager.swift b/apple/TablePro/Core/CloudSQL/CloudSQLProxyManager.swift index 190af5c9ca..0e129e088c 100644 --- a/apple/TablePro/Core/CloudSQL/CloudSQLProxyManager.swift +++ b/apple/TablePro/Core/CloudSQL/CloudSQLProxyManager.swift @@ -29,6 +29,7 @@ actor CloudSQLProxyManager: TunnelManaging { private let systemBinaryLookup: (String) -> 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 @@ -291,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/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/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift b/apple/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift index 44eb4351ee..9e1d179d7e 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) } @@ -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 { @@ -156,6 +165,7 @@ struct CloudSQLProxyManagerTests { started.set() return FakeCloudSQLProxyRunner(behavior: .ready) }, + ownsListener: { _, _ in true }, binaryManager: CloudSQLProxyBinaryManager( baseDirectory: dir, expectedSHA256: ["arm64": "deadbeef", "amd64": "deadbeef"], @@ -189,6 +199,7 @@ struct CloudSQLProxyManagerTests { let manager = CloudSQLProxyManager( runnerFactory: { FakeCloudSQLProxyRunner(behavior: .ready) }, + ownsListener: { _, _ in true }, binaryManager: CloudSQLProxyBinaryManager( baseDirectory: dir, expectedSHA256: ["arm64": digest, "amd64": digest], @@ -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()) @@ -232,7 +243,7 @@ struct CloudSQLProxyManagerTests { let squatter = try #require(FakeCloudSQLProxyRunner.openListener(port: port)) defer { close(squatter) } let fake = FakeCloudSQLProxyRunner(behavior: .ready) - let manager = CloudSQLProxyManager(runnerFactory: { fake }) + let manager = CloudSQLProxyManager(testRunner: { fake }) await #expect(throws: CloudSQLProxyError.portInUse(port)) { _ = try await manager.createTunnel(connectionId: UUID(), config: self.config(localPort: port)) @@ -240,10 +251,25 @@ struct CloudSQLProxyManagerTests { #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)) @@ -253,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( @@ -289,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")) @@ -298,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) { @@ -309,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", @@ -336,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() @@ -354,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) From bafd642119bded46006e32373a68459a1c0a9177 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 08:10:58 +0700 Subject: [PATCH 08/10] test(cloudsql): pass the listener owner in declaration order --- .../CloudSQL/CloudSQLProxyManagerTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apple/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift b/apple/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift index 9e1d179d7e..622e47ea43 100644 --- a/apple/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift +++ b/apple/TableProTests/CloudSQL/CloudSQLProxyManagerTests.swift @@ -165,13 +165,13 @@ struct CloudSQLProxyManagerTests { started.set() return FakeCloudSQLProxyRunner(behavior: .ready) }, - ownsListener: { _, _ in true }, binaryManager: CloudSQLProxyBinaryManager( baseDirectory: dir, 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) { @@ -199,13 +199,13 @@ struct CloudSQLProxyManagerTests { let manager = CloudSQLProxyManager( runnerFactory: { FakeCloudSQLProxyRunner(behavior: .ready) }, - ownsListener: { _, _ in true }, binaryManager: CloudSQLProxyBinaryManager( baseDirectory: dir, expectedSHA256: ["arm64": digest, "amd64": digest], fetch: { _ in bytes } ), - systemBinaryLookup: { _ in nil } + systemBinaryLookup: { _ in nil }, + ownsListener: { _, _ in true } ) let port = try await manager.createTunnel( From 1ea063b2a34b16df081724c5f194aa0f3d88ba4b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 08:17:06 +0700 Subject: [PATCH 09/10] chore(l10n): add the proxy port message to the Mac catalog --- apple/TablePro/Resources/Localizable.xcstrings | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 52b8e6372a..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." : { From 4e804cdf1e2bdbb1ded947819065994d01b7611c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 08:22:24 +0700 Subject: [PATCH 10/10] docs(changelog): number the fragments after #3734 --- apple/changelog.d/{0000.security.1.md => 3734.security.1.md} | 0 apple/changelog.d/{0000.security.md => 3734.security.md} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename apple/changelog.d/{0000.security.1.md => 3734.security.1.md} (100%) rename apple/changelog.d/{0000.security.md => 3734.security.md} (100%) diff --git a/apple/changelog.d/0000.security.1.md b/apple/changelog.d/3734.security.1.md similarity index 100% rename from apple/changelog.d/0000.security.1.md rename to apple/changelog.d/3734.security.1.md diff --git a/apple/changelog.d/0000.security.md b/apple/changelog.d/3734.security.md similarity index 100% rename from apple/changelog.d/0000.security.md rename to apple/changelog.d/3734.security.md