Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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."
)
}
}
38 changes: 26 additions & 12 deletions apple/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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")

Expand Down Expand Up @@ -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<MYSQL>) {
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) }
Expand Down
1 change: 1 addition & 0 deletions apple/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
103 changes: 93 additions & 10 deletions apple/Plugins/MySQLDriverTests/MariaDBTLSModeTests.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Darwin
import Foundation
import TableProMySQLCore
import TableProPluginKit
import Testing

Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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..<count).map { _ in self.runSession() })
}
}
}
Expand All @@ -142,10 +211,24 @@ private final class PlaintextMySQLServer: @unchecked Sendable {
}
guard login.payload.count != Self.sslRequestLength else { return session }
session.sawLogin = true
guard send(Self.switchToCleartext, sequence: login.sequence &+ 1, to: client),
let answer = readPacket(client, into: &session),
send(Self.okPacket, sequence: answer.sequence &+ 1, to: client)
else { return session }
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),
send(Self.okPacket, sequence: answer.sequence &+ 1, to: client)
else { return session }
} else {
guard send(Self.okPacket, sequence: login.sequence &+ 1, to: client) else { return session }
}
session.loggedIn = true
while let command = readPacket(client, into: &session), command.payload.first != 0x01 {
guard send(Self.okPacket, sequence: command.sequence &+ 1, to: client) else { break }
}
Expand Down
6 changes: 6 additions & 0 deletions apple/TablePro/Core/CloudSQL/CloudSQLProxyError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.")
Expand Down
21 changes: 19 additions & 2 deletions apple/TablePro/Core/CloudSQL/CloudSQLProxyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ actor CloudSQLProxyManager: TunnelManaging {
private let systemBinaryLookup: (String) -> String?
private var staleSweep: Task<Void, Never>?
private let reaperTimings: StaleProcessReaper.Timings
private let ownsListener: @Sendable (Int32?, Int) -> Bool

private static let runnerRegistry = OSAllocatedUnfairLock(initialState: [UUID: any SupervisedProcessRunner]())

Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion apple/TablePro/Core/Database/DatabaseManager+SSH.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading