From 5e291d9b823a486d6695447e51b933b3299cd017 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 12:06:49 +0700 Subject: [PATCH 01/12] fix(mysql): keep backup and restore from sending a plain-text password without TLS --- .../Database/MariaDBClientPluginFolder.swift | 49 ++ .../Core/Database/MySQLClientArguments.swift | 52 ++- .../Core/Database/MySQLServerGreeting.swift | 89 ++++ .../Core/Database/NativeDumpDescriptor.swift | 33 +- .../Core/Database/NativeDumpRegistry.swift | 37 +- .../Core/Database/NativeDumpService.swift | 62 ++- .../Database/ProcessNativeDumpRunner.swift | 26 +- .../Database/MySQLClientArgumentsTests.swift | 22 + .../MySQLDumpPlaintextPasswordTests.swift | 427 ++++++++++++++++++ .../Database/NativeDumpRegistryTests.swift | 6 +- .../scripts/probes/check-mariadb-tls-modes.sh | 2 +- .../check-mysql-dump-plaintext-password.sh | 138 ++++++ .../probes/check-mysql-dump-tool-flags.sh | 1 + .../mariadb-tls-modes/fake-mysql-server.py | 27 +- 14 files changed, 942 insertions(+), 29 deletions(-) create mode 100644 apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift create mode 100644 apple/TablePro/Core/Database/MySQLServerGreeting.swift create mode 100644 apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift create mode 100755 apple/scripts/probes/check-mysql-dump-plaintext-password.sh diff --git a/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift b/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift new file mode 100644 index 0000000000..9b12929048 --- /dev/null +++ b/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift @@ -0,0 +1,49 @@ +// +// MariaDBClientPluginFolder.swift +// TablePro +// + +import Foundation + +// MariaDB's client tools answer a server that asks for the password as typed (mysql_clear_password, or dialog for PAM) +// without TLS, and have no option that refuses it. Both are loaded from the plugin folder, so a run that might go +// without TLS points the tool at a folder that links every plugin but those two. Measured with mariadb-dump and mariadb +// 13.0.2 from Homebrew: they then fail with 2059 "Plugin dialog could not be loaded" before sending anything. +enum MariaDBClientPluginFolder { + static let plaintextPasswordPlugins = ["mysql_clear_password", "dialog"] + + // The two install layouts MariaDB's build defines for this platform, relative to the prefix that holds bin. + // Homebrew uses the first. + private static let layouts = ["lib/plugin", "lib/mysql/plugin"] + + static func pluginDirectory(forTool path: String) -> URL? { + let tool = URL(fileURLWithPath: path).resolvingSymlinksInPath() + let prefix = tool.deletingLastPathComponent().deletingLastPathComponent() + return layouts + .map { prefix.appendingPathComponent($0, isDirectory: true) } + .first { isDirectory($0) } + } + + // A tool whose folder cannot be found gets an empty one: logins built into the tool still work, and none that + // could send the password as typed. + static func make(hiding hidden: [String], from source: URL?, in parent: URL = FileManager.default.temporaryDirectory) throws -> URL { + let manager = FileManager.default + let folder = parent.appendingPathComponent("tablepro-client-plugins-\(UUID().uuidString)", isDirectory: true) + try manager.createDirectory(at: folder, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + guard let source else { return folder } + let hiddenFiles = Set(hidden.map { "\($0).so" }) + let entries = (try? manager.contentsOfDirectory(atPath: source.path)) ?? [] + for entry in entries where !hiddenFiles.contains(entry) { + try manager.createSymbolicLink( + at: folder.appendingPathComponent(entry), + withDestinationURL: source.appendingPathComponent(entry) + ) + } + return folder + } + + private static func isDirectory(_ url: URL) -> Bool { + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue + } +} diff --git a/apple/TablePro/Core/Database/MySQLClientArguments.swift b/apple/TablePro/Core/Database/MySQLClientArguments.swift index 6fc2498df6..f3d55556a1 100644 --- a/apple/TablePro/Core/Database/MySQLClientArguments.swift +++ b/apple/TablePro/Core/Database/MySQLClientArguments.swift @@ -17,7 +17,7 @@ enum MySQLClientArguments { /// | mode | MySQL | MariaDB | /// | --- | --- | --- | /// | disabled | `--ssl-mode=DISABLED` | `--skip-ssl` | - /// | preferred | `--ssl-mode=PREFERRED` | `--ssl --skip-ssl-verify-server-cert` | + /// | preferred | `--ssl-mode=PREFERRED` | `--ssl --skip-ssl-verify-server-cert`, or `--skip-ssl` when the server offers no TLS | /// | required | `--ssl-mode=REQUIRED` | `--ssl --ssl-verify-server-cert` | /// | verifyCa | `--ssl-mode=VERIFY_CA` | `--ssl --ssl-verify-server-cert` | /// | verifyIdentity | `--ssl-mode=VERIFY_IDENTITY` | `--ssl --ssl-verify-server-cert` | @@ -30,13 +30,59 @@ enum MySQLClientArguments { /// `CN=totally.other.invalid`, so the flag requires TLS rather than an identity, and the /// identity check is what `--ssl-ca` adds. MariaDB has no hostname-only tier, so `verifyCa` and /// `verifyIdentity` reach it the same way. - static func tls(_ ssl: SSLConfiguration, flavor: NativeDumpToolFlavor, toolPath: String) throws -> [String] { - guard ssl.isEnabled else { + /// + /// MariaDB's `--ssl` without verification also sends the typed password when the server offers no + /// TLS and caching_sha2_password asks for full authentication, so Preferred dials in plaintext + /// when the server's greeting offered no TLS. Measured with mariadb-dump 13.0.2: `--skip-ssl` + /// asks for the server's RSA key instead. Nil means the greeting was not read. + static func tls( + _ ssl: SSLConfiguration, + flavor: NativeDumpToolFlavor, + toolPath: String, + serverOffersTLS: Bool? = nil + ) throws -> [String] { + guard ssl.isEnabled, !(flavor == .mariadb && ssl.mode == .preferred && serverOffersTLS == false) else { return disabledFlags(flavor: flavor) } return try modeFlags(ssl.mode, flavor: flavor, toolPath: toolPath) + certificateFlags(ssl) } + // Hidden where the session might run without TLS. MariaDB's tools are the ones measured to send the password as + // typed there, and they have no option that refuses it. + static func hidesPlaintextPasswordPlugins( + _ connection: DatabaseConnection, + flavor: NativeDumpToolFlavor, + serverOffersTLS: Bool? + ) -> Bool { + guard flavor == .mariadb, reachesServerOverNetwork(connection) else { return false } + switch connection.transportSSLConfiguration.mode { + case .disabled: return true + case .preferred: return serverOffersTLS != true + case .required, .verifyCa, .verifyIdentity: return false + } + } + + static func needsServerGreeting(_ connection: DatabaseConnection, flavor: NativeDumpToolFlavor) -> Bool { + flavor == .mariadb + && connection.transportSSLConfiguration.mode == .preferred + && reachesServerOverNetwork(connection) + } + + // A unix socket, and a tunnel that encrypts the hop to the server itself, keep the password off the network. + private static func reachesServerOverNetwork(_ connection: DatabaseConnection) -> Bool { + MySQLLocalSocket.path(in: connection.additionalFields) == nil && !connection.tunnelSecuresTransport + } + + static func pluginDirectory(_ folder: URL) -> [String] { + ["--plugin-dir=\(folder.path)"] + } + + static var plaintextPasswordRefusal: 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." + ) + } + /// The protocol is always named, because both families read a host spelled `localhost` as their /// default socket. A socket also names `localhost`: measured with mysql 8.4.6 and mariadb /// 13.0.2, a `host=127.0.0.1` in an option file made MariaDB dial TCP and MySQL fail with 2047 diff --git a/apple/TablePro/Core/Database/MySQLServerGreeting.swift b/apple/TablePro/Core/Database/MySQLServerGreeting.swift new file mode 100644 index 0000000000..cd6770efaf --- /dev/null +++ b/apple/TablePro/Core/Database/MySQLServerGreeting.swift @@ -0,0 +1,89 @@ +// +// MySQLServerGreeting.swift +// TablePro +// + +import Foundation +import Network +import os + +// MariaDB's client tools on Preferred fall back to plaintext when the server offers no TLS, and still send the typed +// password when caching_sha2_password asks for full authentication. Reading the server's greeting first, which carries +// no credentials, lets the dump pick TLS or plaintext up front instead. +enum MySQLServerGreeting { + private static let clientSSL: UInt16 = 0x0800 + private static let errorPacket: UInt8 = 0xFF + private static let queue = DispatchQueue(label: "com.TablePro.MySQLServerGreeting", qos: .userInitiated) + + // Nil when the server cannot be read, which a caller treats as no TLS. + static func offersTLS(host: String, port: Int, deadline: Duration = .seconds(10)) async -> Bool? { + guard let nwPort = NWEndpoint.Port(rawValue: UInt16(clamping: port)), port > 0 else { return nil } + let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp) + let answer: Bool? = await withCheckedContinuation { continuation in + let resumed = OSAllocatedUnfairLock(initialState: false) + let complete: @Sendable (Bool?) -> Void = { value in + let shouldResume = resumed.withLock { done -> Bool in + guard !done else { return false } + done = true + return true + } + guard shouldResume else { return } + connection.cancel() + continuation.resume(returning: value) + } + connection.stateUpdateHandler = { state in + switch state { + case .ready: + readGreeting(from: connection, complete: complete) + case .failed, .cancelled, .waiting: + complete(nil) + default: + break + } + } + connection.start(queue: queue) + queue.asyncAfter(deadline: .now() + deadline.timeInterval) { complete(nil) } + } + return answer + } + + static func offersTLS(greeting payload: [UInt8]) -> Bool? { + guard let protocolVersion = payload.first, protocolVersion != errorPacket else { return nil } + guard let versionEnd = payload.dropFirst().firstIndex(of: 0) else { return nil } + // Thread id (4), the first part of the scramble (8) and a filler byte sit before the capability flags. + let flagsStart = versionEnd + 1 + 4 + 8 + 1 + guard payload.count >= flagsStart + 2 else { return nil } + let flags = UInt16(payload[flagsStart]) | UInt16(payload[flagsStart + 1]) << 8 + return flags & clientSSL != 0 + } + + private static func readGreeting(from connection: NWConnection, complete: @escaping @Sendable (Bool?) -> Void) { + connection.receive(minimumIncompleteLength: 4, maximumLength: 4) { header, _, _, error in + guard error == nil, let header, header.count == 4 else { + complete(nil) + return + } + let length = Int(header[header.startIndex]) + | Int(header[header.startIndex + 1]) << 8 + | Int(header[header.startIndex + 2]) << 16 + guard length > 0 else { + complete(nil) + return + } + connection.receive(minimumIncompleteLength: length, maximumLength: length) { payload, _, _, error in + guard error == nil, let payload, payload.count == length else { + complete(nil) + return + } + complete(offersTLS(greeting: Array(payload))) + } + } + } +} + +private extension Duration { + var timeInterval: TimeInterval { + let parts = components + return TimeInterval(parts.seconds) + TimeInterval(parts.attoseconds) / 1e18 + } +} diff --git a/apple/TablePro/Core/Database/NativeDumpDescriptor.swift b/apple/TablePro/Core/Database/NativeDumpDescriptor.swift index 4274cbfa88..e42cef1596 100644 --- a/apple/TablePro/Core/Database/NativeDumpDescriptor.swift +++ b/apple/TablePro/Core/Database/NativeDumpDescriptor.swift @@ -77,6 +77,12 @@ struct NativeDumpDescriptor: Sendable { /// flags that server needs. `mysqldump` 8.0 reads a table no MariaDB server has. let serverVersion: String? + // Whether the server's greeting offered TLS, for a tool whose SSL options depend on it. Nil when not read. + var serverOffersTLS: Bool? + + // Set by `NativeDumpService.buildCommand` before the arguments are built. + var clientPluginFolder: ClientPluginFolder? + init( connection: DatabaseConnection, database: String, @@ -85,7 +91,8 @@ struct NativeDumpDescriptor: Sendable { scope: NativeDumpScope = .wholeDatabase, currentCatalog: String? = nil, localFilePath: String? = nil, - serverVersion: String? = nil + serverVersion: String? = nil, + serverOffersTLS: Bool? = nil ) { self.connection = connection self.database = database @@ -95,6 +102,7 @@ struct NativeDumpDescriptor: Sendable { self.currentCatalog = currentCatalog self.localFilePath = localFilePath self.serverVersion = serverVersion + self.serverOffersTLS = serverOffersTLS } var host: String { @@ -107,6 +115,18 @@ struct NativeDumpDescriptor: Sendable { let value: String } + // A private folder of the tool's client plugins with some left out, removed once the process exits. + struct ClientPluginFolder: Sendable, Equatable { + let url: URL + // What the tool prints when the server asked for a hidden plugin, and what the user reads instead. + let refusalMarkers: [String] + let refusalMessage: String + + func refusal(in stderr: String) -> String? { + refusalMarkers.contains { stderr.contains($0) } ? refusalMessage : nil + } + } + /// A tool on the user's Mac that the app spawns. struct CommandLineTool: Sendable { /// The candidate names, in the order they are tried. More than one because a tool can ship @@ -148,6 +168,11 @@ struct NativeDumpDescriptor: Sendable { let restoreArguments: @Sendable (Request, NativeDumpResolvedTool) throws -> [String] let environment: @Sendable (Request) -> [String: String] + // Asked before the arguments are built; the answer lands in `Request.serverOffersTLS`. + let serverOffersTLS: @Sendable (Request, NativeDumpResolvedTool) async -> Bool? + + let clientPluginFolder: @Sendable (Request, NativeDumpResolvedTool) throws -> ClientPluginFolder? + init( backupBinaries: [String], restoreBinaries: [String], @@ -162,7 +187,9 @@ struct NativeDumpDescriptor: Sendable { backupArguments: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> [String], restoreArguments: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> [String], environment: @escaping @Sendable (Request) -> [String: String] = { _ in [:] }, - configurationFileEntries: @escaping @Sendable (Request) -> [ConfigurationEntry] = { _ in [] } + configurationFileEntries: @escaping @Sendable (Request) -> [ConfigurationEntry] = { _ in [] }, + serverOffersTLS: @escaping @Sendable (Request, NativeDumpResolvedTool) async -> Bool? = { _, _ in nil }, + clientPluginFolder: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> ClientPluginFolder? = { _, _ in nil } ) { self.backupBinaries = backupBinaries self.restoreBinaries = restoreBinaries @@ -178,6 +205,8 @@ struct NativeDumpDescriptor: Sendable { self.restoreArguments = restoreArguments self.environment = environment self.configurationFileEntries = configurationFileEntries + self.serverOffersTLS = serverOffersTLS + self.clientPluginFolder = clientPluginFolder } func binaries(for kind: NativeDumpKind) -> [String] { diff --git a/apple/TablePro/Core/Database/NativeDumpRegistry.swift b/apple/TablePro/Core/Database/NativeDumpRegistry.swift index 3f8dc4a5df..b8f677b554 100644 --- a/apple/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/apple/TablePro/Core/Database/NativeDumpRegistry.swift @@ -217,6 +217,16 @@ enum NativeDumpRegistry { environment: { request in guard let password = request.password, !password.isEmpty else { return [:] } return ["MYSQL_PWD": password] + }, + serverOffersTLS: { request, resolved in + guard MySQLClientArguments.needsServerGreeting(request.connection, flavor: resolved.flavor) else { + return nil + } + return await MySQLServerGreeting.offersTLS(host: request.host, port: request.connection.port) + ?? false + }, + clientPluginFolder: { request, resolved in + try mysqlPluginFolder(request, resolved) } ) ), @@ -245,6 +255,27 @@ enum NativeDumpRegistry { ["--", request.database] + request.scope.objects.map(\.name) } + private static func mysqlPluginFolder( + _ request: NativeDumpDescriptor.Request, + _ resolved: NativeDumpResolvedTool + ) throws -> NativeDumpDescriptor.ClientPluginFolder? { + guard MySQLClientArguments.hidesPlaintextPasswordPlugins( + request.connection, + flavor: resolved.flavor, + serverOffersTLS: request.serverOffersTLS + ) else { return nil } + let hidden = MariaDBClientPluginFolder.plaintextPasswordPlugins + let folder = try MariaDBClientPluginFolder.make( + hiding: hidden, + from: MariaDBClientPluginFolder.pluginDirectory(forTool: resolved.path) + ) + return NativeDumpDescriptor.ClientPluginFolder( + url: folder, + refusalMarkers: hidden.map { "Plugin \($0) could not be loaded" }, + refusalMessage: MySQLClientArguments.plaintextPasswordRefusal + ) + } + private static func mysqlConnectionFlags( _ request: NativeDumpDescriptor.Request, _ resolved: NativeDumpResolvedTool @@ -261,12 +292,16 @@ enum NativeDumpRegistry { contentsOf: try MySQLClientArguments.tls( request.connection.transportSSLConfiguration, flavor: resolved.flavor, - toolPath: resolved.path + toolPath: resolved.path, + serverOffersTLS: request.serverOffersTLS ) ) if request.connection.usesIAMToken { flags.append(contentsOf: MySQLClientArguments.cleartextAuthentication(flavor: resolved.flavor)) } + if let folder = request.clientPluginFolder { + flags.append(contentsOf: MySQLClientArguments.pluginDirectory(folder.url)) + } return flags } diff --git a/apple/TablePro/Core/Database/NativeDumpService.swift b/apple/TablePro/Core/Database/NativeDumpService.swift index 7b18168b0a..3c3fe3f05d 100644 --- a/apple/TablePro/Core/Database/NativeDumpService.swift +++ b/apple/TablePro/Core/Database/NativeDumpService.swift @@ -100,6 +100,8 @@ struct NativeDumpCommand: Equatable { /// standard input. Removed once the process exits, however it exits. let temporaryCredentialsFileURL: URL? + let clientPluginFolder: NativeDumpDescriptor.ClientPluginFolder? + /// Which direction a redirected file goes: a restore feeds it in as standard input, a backup /// takes standard output and writes it out. let isRestore: Bool @@ -114,6 +116,7 @@ struct NativeDumpCommand: Equatable { delivery: NativeDumpDescriptor.OutputDelivery = .toolWritesFile, redirectedFileURL: URL? = nil, temporaryCredentialsFileURL: URL? = nil, + clientPluginFolder: NativeDumpDescriptor.ClientPluginFolder? = nil, isRestore: Bool = false, exitPolicy: NativeDumpExitPolicy = .zeroExitOnly ) { @@ -124,6 +127,7 @@ struct NativeDumpCommand: Equatable { self.delivery = delivery self.redirectedFileURL = redirectedFileURL self.temporaryCredentialsFileURL = temporaryCredentialsFileURL + self.clientPluginFolder = clientPluginFolder self.isRestore = isRestore self.exitPolicy = exitPolicy } @@ -150,6 +154,19 @@ internal extension NativeDumpJob { guard case .process(let command) = self else { return .zeroExitOnly } return command.exitPolicy } + + var clientPluginFolder: NativeDumpDescriptor.ClientPluginFolder? { + guard case .process(let command) = self else { return nil } + return command.clientPluginFolder + } + + // For a job that never reaches a runner, which is what removes these otherwise. + func discardTemporaryFiles() { + guard case .process(let command) = self else { return } + for url in [command.temporaryCredentialsFileURL, command.clientPluginFolder?.url].compactMap(\.self) { + try? FileManager.default.removeItem(at: url) + } + } } /// Captured terminal state of a finished/cancelled run. @@ -273,7 +290,7 @@ final class NativeDumpService: ObservableObject { : await Self.resolvedCatalog(connectionId: connection.id, database: database) let serverVersion = session?.driver?.serverVersion - let request = NativeDumpDescriptor.Request( + var request = NativeDumpDescriptor.Request( connection: effective, database: database, fileURL: fileURL, @@ -292,6 +309,7 @@ final class NativeDumpService: ObservableObject { serverVersion: serverVersion ) toolName = resolved.name + request.serverOffersTLS = await tool.serverOffersTLS(request, resolved) let command = try Self.buildCommand( kind: kind, tool: tool, @@ -333,9 +351,16 @@ final class NativeDumpService: ObservableObject { fileURL: URL, totalBytesEstimate: Int64? = nil ) throws { - if case .running = state { throw NativeDumpError.alreadyRunning } - if case .cancelling = state { throw NativeDumpError.alreadyRunning } + if case .running = state { + job.discardTemporaryFiles() + throw NativeDumpError.alreadyRunning + } + if case .cancelling = state { + job.discardTemporaryFiles() + throw NativeDumpError.alreadyRunning + } guard !cancelRequested else { + job.discardTemporaryFiles() setState(.cancelled) return } @@ -344,6 +369,7 @@ final class NativeDumpService: ObservableObject { try runner.start() self.runner = runner let exitPolicy = job.exitPolicy + let pluginFolder = job.clientPluginFolder setState(.running(database: database, fileURL: fileURL, bytesProcessed: 0, totalBytes: totalBytesEstimate)) if kind == .backup { @@ -352,7 +378,13 @@ final class NativeDumpService: ObservableObject { Task { @MainActor [weak self] in guard let result = await self?.runner?.result else { return } - self?.handleTermination(result: result, database: database, fileURL: fileURL, exitPolicy: exitPolicy) + self?.handleTermination( + result: result, + database: database, + fileURL: fileURL, + exitPolicy: exitPolicy, + pluginFolder: pluginFolder + ) } } @@ -490,7 +522,16 @@ final class NativeDumpService: ObservableObject { resolved: NativeDumpResolvedTool, request: NativeDumpDescriptor.Request ) throws -> NativeDumpCommand { - var arguments = try tool.arguments(for: kind, request: request, resolved: resolved) + var request = request + let pluginFolder = try tool.clientPluginFolder(request, resolved) + request.clientPluginFolder = pluginFolder + var arguments: [String] + do { + arguments = try tool.arguments(for: kind, request: request, resolved: resolved) + } catch { + if let pluginFolder { try? FileManager.default.removeItem(at: pluginFolder.url) } + throw error + } var environment = minimalEnvironment() environment.merge(tool.environment(request)) { _, new in new } if tool.requiresUntranslatedMessages { @@ -514,6 +555,7 @@ final class NativeDumpService: ObservableObject { delivery: delivery, redirectedFileURL: delivery == .standardOutput ? request.fileURL : nil, temporaryCredentialsFileURL: credentialsFileURL, + clientPluginFolder: pluginFolder, isRestore: kind == .restore, exitPolicy: tool.exitPolicy(for: kind) ) @@ -586,7 +628,8 @@ final class NativeDumpService: ObservableObject { result: NativeDumpRunResult, database: String, fileURL: URL, - exitPolicy: NativeDumpExitPolicy + exitPolicy: NativeDumpExitPolicy, + pluginFolder: NativeDumpDescriptor.ClientPluginFolder? ) { byteSizeTask?.cancel() byteSizeTask = nil @@ -625,9 +668,10 @@ final class NativeDumpService: ObservableObject { } if kind == .backup { Self.removeDestination(fileURL) } - let summary = result.stderr.isEmpty - ? String(format: String(localized: "Process exited with code %d"), Int(result.exitCode)) - : result.stderr + let summary = pluginFolder?.refusal(in: result.stderr) + ?? (result.stderr.isEmpty + ? String(format: String(localized: "Process exited with code %d"), Int(result.exitCode)) + : result.stderr) setState(.failed(message: summary, targetMayBeModified: kind == .restore)) Self.logger.error("\(self.toolName, privacy: .public) failed code=\(result.exitCode) db=\(database, privacy: .private(mask: .hash)) stderr=\(result.stderr)") } diff --git a/apple/TablePro/Core/Database/ProcessNativeDumpRunner.swift b/apple/TablePro/Core/Database/ProcessNativeDumpRunner.swift index 10cc9eb9f8..eedc742147 100644 --- a/apple/TablePro/Core/Database/ProcessNativeDumpRunner.swift +++ b/apple/TablePro/Core/Database/ProcessNativeDumpRunner.swift @@ -18,6 +18,7 @@ final class ProcessNativeDumpRunner: NativeDumpRunner, @unchecked Sendable { private var continuation: CheckedContinuation? private var redirectedHandle: FileHandle? private var credentialsFileURL: URL? + private var pluginFolderURL: URL? init(command: NativeDumpCommand, stderrPipe: Pipe = Pipe()) { self.command = command @@ -33,8 +34,14 @@ final class ProcessNativeDumpRunner: NativeDumpRunner, @unchecked Sendable { process.environment = command.environment process.standardError = stderrPipe credentialsFileURL = command.temporaryCredentialsFileURL + pluginFolderURL = command.clientPluginFolder?.url - try attachRedirection(for: command) + do { + try attachRedirection(for: command) + } catch { + releaseRedirection() + throw error + } stderrReader.start { [weak self] chunk in self?.append(chunk, cap: stderrCap) @@ -61,7 +68,13 @@ final class ProcessNativeDumpRunner: NativeDumpRunner, @unchecked Sendable { pending?.resume(returning: result) } - try process.run() + do { + try process.run() + } catch { + stderrReader.stop() + releaseRedirection() + throw error + } } func cancel() { @@ -110,15 +123,16 @@ final class ProcessNativeDumpRunner: NativeDumpRunner, @unchecked Sendable { } } - /// Runs however the process ended, including a cancel, so a credentials file never outlives the - /// process that needed it. + /// Runs however the process ended, including a cancel or a launch that failed, so a credentials + /// file never outlives the process that needed it. private func releaseRedirection() { try? redirectedHandle?.close() redirectedHandle = nil - if let credentialsFileURL { - try? FileManager.default.removeItem(at: credentialsFileURL) + for url in [credentialsFileURL, pluginFolderURL].compactMap(\.self) { + try? FileManager.default.removeItem(at: url) } credentialsFileURL = nil + pluginFolderURL = nil } var result: NativeDumpRunResult { diff --git a/apple/TableProTests/Database/MySQLClientArgumentsTests.swift b/apple/TableProTests/Database/MySQLClientArgumentsTests.swift index b585f88615..75db4b4f4a 100644 --- a/apple/TableProTests/Database/MySQLClientArgumentsTests.swift +++ b/apple/TableProTests/Database/MySQLClientArgumentsTests.swift @@ -71,6 +71,28 @@ struct MySQLClientArgumentsTests { } } + // Measured with mariadb-dump 13.0.2: --ssl falls back to plaintext and answers caching_sha2_password's full + // authentication with the typed password, where --skip-ssl asks for the server's RSA key. + @Test("MariaDB's Preferred follows the server's greeting, MySQL's does not need to") + func preferredFollowsTheGreeting() throws { + #expect( + try MySQLClientArguments.tls(ssl(.preferred), flavor: .mariadb, toolPath: "/t", serverOffersTLS: false) + == ["--skip-ssl"] + ) + #expect( + try MySQLClientArguments.tls(ssl(.preferred), flavor: .mariadb, toolPath: "/t", serverOffersTLS: true) + == ["--ssl", "--skip-ssl-verify-server-cert"] + ) + #expect( + try MySQLClientArguments.tls(ssl(.preferred), flavor: .mysql, toolPath: "/t", serverOffersTLS: false) + == ["--ssl-mode=PREFERRED"] + ) + #expect( + try MySQLClientArguments.tls(ssl(.required), flavor: .mariadb, toolPath: "/t", serverOffersTLS: false) + == ["--ssl", "--ssl-verify-server-cert"] + ) + } + @Test("The CA goes with the modes that verify one, and the client pair always follows") func certificatePaths() throws { let verifying = ssl(.verifyCa, ca: "/certs/ca.pem", certificate: "/certs/c.pem", key: "/certs/c.key") diff --git a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift new file mode 100644 index 0000000000..7ef44f6857 --- /dev/null +++ b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift @@ -0,0 +1,427 @@ +// +// MySQLDumpPlaintextPasswordTests.swift +// TableProTests +// + +import Foundation +import Network +import os +import TableProPluginKit +import TableProSSHConfig +import Testing + +@testable import TablePro + +// The tool behavior these rules rest on is measured by scripts/probes/check-mysql-dump-plaintext-password.sh. +struct MySQLDumpPlaintextPasswordTests { + private func connection(_ mode: SSLMode) -> DatabaseConnection { + var ssl = SSLConfiguration() + ssl.mode = mode + return DatabaseConnection( + name: "Test", + host: "db.example.com", + port: 3_306, + database: "sales", + username: "alice", + type: .mysql, + sshConfig: SSHConfiguration(), + sslConfig: ssl + ) + } + + private func command( + _ connection: DatabaseConnection, + kind: NativeDumpKind = .backup, + flavor: NativeDumpToolFlavor = .mariadb, + toolPath: String = "/nonexistent/bin/mariadb-dump", + serverOffersTLS: Bool? = nil + ) throws -> NativeDumpCommand { + let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) + return try NativeDumpService.buildCommand( + kind: kind, + tool: tool, + resolved: NativeDumpResolvedTool(name: "mariadb-dump", path: toolPath, flavor: flavor), + request: NativeDumpDescriptor.Request( + connection: connection, + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/out.sql"), + password: "s3cret", + serverOffersTLS: serverOffersTLS + ) + ) + } + + private func discard(_ built: NativeDumpCommand) { + if let url = built.clientPluginFolder?.url { + try? FileManager.default.removeItem(at: url) + } + } + + private func pluginDirectoryArgument(_ built: NativeDumpCommand) -> String? { + built.arguments.first { $0.hasPrefix("--plugin-dir=") } + } + + @Test("Disabled hides the plain-text plugins from MariaDB's tools, in both directions", arguments: [ + NativeDumpKind.backup, .restore + ]) + func disabledHidesPlaintextPlugins(kind: NativeDumpKind) throws { + let built = try command(connection(.disabled), kind: kind) + defer { discard(built) } + let folder = try #require(built.clientPluginFolder) + #expect(pluginDirectoryArgument(built) == "--plugin-dir=\(folder.url.path)") + #expect(FileManager.default.fileExists(atPath: folder.url.path)) + #expect(built.arguments.contains("--skip-ssl")) + let terminator = try #require(built.arguments.firstIndex(of: "--")) + let flag = try #require(built.arguments.firstIndex { $0.hasPrefix("--plugin-dir=") }) + #expect(flag < terminator) + } + + @Test("Preferred dials MariaDB's tools in plaintext with the plugins hidden when the server offers no TLS") + func preferredWithoutTLSOffer() throws { + let built = try command(connection(.preferred), serverOffersTLS: false) + defer { discard(built) } + #expect(built.arguments.contains("--skip-ssl")) + #expect(!built.arguments.contains("--ssl")) + #expect(pluginDirectoryArgument(built) != nil) + } + + @Test("Preferred keeps TLS and every plugin when the server offers TLS") + func preferredWithTLSOffer() throws { + var preferred = connection(.preferred) + preferred.sslConfig.clientCertificatePath = "/certs/client.pem" + let built = try command(preferred, serverOffersTLS: true) + #expect(built.arguments.contains("--ssl")) + #expect(built.arguments.contains("--skip-ssl-verify-server-cert")) + #expect(built.arguments.contains("--ssl-cert=/certs/client.pem")) + #expect(built.clientPluginFolder == nil) + #expect(pluginDirectoryArgument(built) == nil) + } + + // --ssl-cert implies --ssl on MariaDB, which would bring back the fallback that sends the password as typed. + @Test("Preferred in plaintext sends no certificate that would turn TLS back on") + func preferredWithoutTLSOfferDropsCertificates() throws { + var preferred = connection(.preferred) + preferred.sslConfig.clientCertificatePath = "/certs/client.pem" + preferred.sslConfig.clientKeyPath = "/certs/client.key" + let built = try command(preferred, serverOffersTLS: false) + defer { discard(built) } + #expect(!built.arguments.contains { $0.hasPrefix("--ssl") }) + } + + @Test("Modes that require TLS keep every plugin", arguments: [SSLMode.required, .verifyCa, .verifyIdentity]) + func requiredKeepsPlugins(mode: SSLMode) throws { + let built = try command(connection(mode)) + #expect(built.clientPluginFolder == nil) + #expect(pluginDirectoryArgument(built) == nil) + } + + @Test("A token sign-in is raised to Required and keeps the plain-text plugin it needs") + func tokenKeepsPlugins() throws { + var iam = connection(.disabled) + iam.additionalFields["awsAuth"] = "gcpServiceAccount" + let built = try command(iam) + #expect(built.arguments.contains("--ssl-verify-server-cert")) + #expect(built.clientPluginFolder == nil) + } + + @Test("A unix socket and a tunnel that secures the hop keep every plugin") + func secureTransportsKeepPlugins() throws { + var socket = connection(.disabled) + socket.additionalFields[MySQLLocalSocket.fieldKey] = "/tmp/mysql.sock" + #expect(try command(socket).clientPluginFolder == nil) + #expect(!MySQLClientArguments.needsServerGreeting(socket, flavor: .mariadb)) + + var proxied = connection(.preferred) + proxied.additionalFields[DatabaseConnection.tunnelSecuresTransportKey] = "true" + #expect(try command(proxied).clientPluginFolder == nil) + #expect(!MySQLClientArguments.needsServerGreeting(proxied, flavor: .mariadb)) + } + + @Test("MySQL's own tools are left as they are") + func mysqlToolsUntouched() throws { + for mode in [SSLMode.disabled, .preferred] { + let built = try command(connection(mode), flavor: .mysql, serverOffersTLS: false) + #expect(built.clientPluginFolder == nil) + #expect(pluginDirectoryArgument(built) == nil) + #expect(!MySQLClientArguments.needsServerGreeting(connection(mode), flavor: .mysql)) + } + #expect(try command(connection(.preferred), flavor: .mysql).arguments.contains("--ssl-mode=PREFERRED")) + } + + @Test("Only a MariaDB tool on Preferred reads the server's greeting") + func greetingIsReadOnlyForPreferred() async throws { + let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) + for (mode, flavor) in [(SSLMode.disabled, NativeDumpToolFlavor.mariadb), (.required, .mariadb), (.preferred, .mysql)] { + let request = NativeDumpDescriptor.Request( + connection: connection(mode), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/out.sql"), + password: nil + ) + let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: "/t", flavor: flavor) + #expect(await tool.serverOffersTLS(request, resolved) == nil, "\(mode.rawValue) \(flavor)") + } + } + + @Test("A server that cannot be read counts as one without TLS") + func unreadableServerCountsAsNoTLS() async throws { + let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) + var closed = connection(.preferred) + closed.host = "127.0.0.1" + closed.port = try #require(LoopbackPort.allocateFree()) + let request = NativeDumpDescriptor.Request( + connection: closed, + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/out.sql"), + password: nil + ) + let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: "/t", flavor: .mariadb) + #expect(await tool.serverOffersTLS(request, resolved) == false) + } + + @Test("The greeting's capability flags say whether the server offers TLS", arguments: [true, false]) + func greetingFromAServer(offersTLS: Bool) async throws { + let server = try await FakeGreetingServer.start(payload: Self.greeting(offersTLS: offersTLS)) + defer { server.stop() } + #expect(await MySQLServerGreeting.offersTLS(host: "127.0.0.1", port: server.port) == offersTLS) + } + + @Test("An error packet or a short greeting tells nothing") + func unreadableGreetings() { + #expect(MySQLServerGreeting.offersTLS(greeting: [0xFF, 0x69, 0x04] + Array("Host blocked".utf8)) == nil) + #expect(MySQLServerGreeting.offersTLS(greeting: [10] + Array("8.0.36".utf8)) == nil) + #expect(MySQLServerGreeting.offersTLS(greeting: Array(Self.greeting(offersTLS: true).prefix(20))) == nil) + #expect(MySQLServerGreeting.offersTLS(greeting: []) == nil) + } + + @Test("The plugin folder is found beside a Homebrew tool, through its link") + func pluginDirectoryBesideTheTool() throws { + let root = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let keg = root.appendingPathComponent("Cellar/mariadb/13.0.2") + let plugins = keg.appendingPathComponent("lib/plugin") + try FileManager.default.createDirectory(at: keg.appendingPathComponent("bin"), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: plugins, withIntermediateDirectories: true) + let binary = keg.appendingPathComponent("bin/mariadb-dump") + #expect(FileManager.default.createFile(atPath: binary.path, contents: nil)) + try FileManager.default.createDirectory(at: root.appendingPathComponent("bin"), withIntermediateDirectories: true) + let link = root.appendingPathComponent("bin/mariadb-dump") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: binary) + + let found = try #require(MariaDBClientPluginFolder.pluginDirectory(forTool: link.path)) + #expect(found.resolvingSymlinksInPath().path == plugins.resolvingSymlinksInPath().path) + #expect(MariaDBClientPluginFolder.pluginDirectory(forTool: root.appendingPathComponent("nowhere/x").path) == nil) + } + + @Test("The private folder links every plugin but the hidden ones") + func privateFolderLeavesOutHiddenPlugins() throws { + let source = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: source) } + let names = ["caching_sha2_password.so", "client_ed25519.so", "mysql_clear_password.so", "dialog.so", "parsec.so"] + for name in names { + #expect(FileManager.default.createFile(atPath: source.appendingPathComponent(name).path, contents: Data(name.utf8))) + } + + let folder = try MariaDBClientPluginFolder.make( + hiding: MariaDBClientPluginFolder.plaintextPasswordPlugins, + from: source + ) + defer { try? FileManager.default.removeItem(at: folder) } + let linked = try FileManager.default.contentsOfDirectory(atPath: folder.path).sorted() + #expect(linked == ["caching_sha2_password.so", "client_ed25519.so", "parsec.so"]) + let destination = try FileManager.default.destinationOfSymbolicLink( + atPath: folder.appendingPathComponent("parsec.so").path + ) + #expect(destination == source.appendingPathComponent("parsec.so").path) + + let empty = try MariaDBClientPluginFolder.make(hiding: ["dialog"], from: nil) + defer { try? FileManager.default.removeItem(at: empty) } + #expect(try FileManager.default.contentsOfDirectory(atPath: empty.path).isEmpty) + } + + @Test("The refusal is recognized in both tools' wording and nothing else") + func refusalMarkers() throws { + let built = try command(connection(.disabled)) + defer { discard(built) } + let folder = try #require(built.clientPluginFolder) + let dump = #"mariadb-dump: Got error: 2059: "Plugin dialog could not be loaded: dlopen(/x/dialog.so, 0x0002)" when trying to connect"# + let client = "ERROR 2059 (HY000): Plugin mysql_clear_password could not be loaded: dlopen(/x/mysql_clear_password.so)" + #expect(folder.refusal(in: dump) == MySQLClientArguments.plaintextPasswordRefusal) + #expect(folder.refusal(in: client) == MySQLClientArguments.plaintextPasswordRefusal) + #expect(folder.refusal(in: "ERROR 2059 (HY000): Plugin caching_sha2_password could not be loaded") == nil) + #expect(folder.refusal(in: "ERROR 1045 (28000): Access denied") == nil) + } + + @Test("The plugin folder is removed when the tool exits and when it cannot launch") + func runnerRemovesThePluginFolder() async throws { + let ran = try MariaDBClientPluginFolder.make(hiding: [], from: nil) + let runner = ProcessNativeDumpRunner(command: runnerCommand("/bin/sh", folder: ran)) + try runner.start() + _ = await runner.result + #expect(!FileManager.default.fileExists(atPath: ran.path)) + + let unlaunched = try MariaDBClientPluginFolder.make(hiding: [], from: nil) + let failing = ProcessNativeDumpRunner(command: runnerCommand("/nonexistent/tool", folder: unlaunched)) + #expect(throws: (any Error).self) { try failing.start() } + #expect(!FileManager.default.fileExists(atPath: unlaunched.path)) + } + + private func runnerCommand(_ executable: String, folder: URL) -> NativeDumpCommand { + NativeDumpCommand( + executable: URL(fileURLWithPath: executable), + arguments: ["-c", "exit 0"], + environment: [:], + stderrByteCap: 64_000, + clientPluginFolder: NativeDumpDescriptor.ClientPluginFolder( + url: folder, + refusalMarkers: [], + refusalMessage: "" + ) + ) + } + + private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("MySQLDumpPlaintextPasswordTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + static func greeting(offersTLS: Bool) -> [UInt8] { + let capabilities: UInt32 = 0x1 | 0x8 | 0x200 | 0x2000 | 0x8000 | 0x80000 | (offersTLS ? 0x800 : 0) + var payload: [UInt8] = [10] + Array("8.0.36".utf8) + [0] + payload += [1, 0, 0, 0] + Array("abcdefgh".utf8) + [0] + payload += [UInt8(capabilities & 0xFF), UInt8((capabilities >> 8) & 0xFF), 45, 2, 0] + payload += [UInt8((capabilities >> 16) & 0xFF), UInt8((capabilities >> 24) & 0xFF), 21] + [UInt8](repeating: 0, count: 10) + payload += Array("ijklmnopqrst".utf8) + [0] + Array("caching_sha2_password".utf8) + [0] + return payload + } +} + +@MainActor +struct MySQLDumpPlaintextPasswordServiceTests { + private final class FinishingRunner: NativeDumpRunner, @unchecked Sendable { + private let outcome: NativeDumpRunResult + private(set) var started = false + + init(_ outcome: NativeDumpRunResult) { + self.outcome = outcome + } + + func start() throws { + started = true + } + + func cancel() {} + + var result: NativeDumpRunResult { + get async { outcome } + } + } + + private func job(folder: URL) -> NativeDumpJob { + .process( + NativeDumpCommand( + executable: URL(fileURLWithPath: "/usr/bin/true"), + arguments: [], + environment: [:], + stderrByteCap: 64_000, + clientPluginFolder: NativeDumpDescriptor.ClientPluginFolder( + url: folder, + refusalMarkers: ["Plugin dialog could not be loaded"], + refusalMessage: MySQLClientArguments.plaintextPasswordRefusal + ) + ) + ) + } + + private func failure(of service: NativeDumpService, running job: NativeDumpJob) async throws -> String? { + let updates = service.stateUpdates() + try service.run(job: job, database: "sales", fileURL: URL(fileURLWithPath: "/tmp/plaintext-refusal.sql")) + for await state in updates { + if case .failed(let message, _) = state { return message } + if case .finished = state { return nil } + } + return nil + } + + @Test("A server that asked for a hidden plugin fails with the TLS advice") + func refusalBecomesTheMessage() async throws { + let stderr = #"mariadb-dump: Got error: 2059: "Plugin dialog could not be loaded: dlopen(/x/dialog.so)" when trying to connect"# + let runner = FinishingRunner(.init(exitCode: 2, stderr: stderr, wasCancelled: false)) + let service = NativeDumpService(kind: .restore, runnerFactory: { _ in runner }) + let message = try await failure(of: service, running: job(folder: URL(fileURLWithPath: "/nonexistent/folder"))) + #expect(message == MySQLClientArguments.plaintextPasswordRefusal) + } + + @Test("Any other failure keeps the tool's own words") + func otherFailuresKeepStderr() async throws { + let stderr = "mariadb-dump: Got error: 1045: \"Access denied for user 'alice'\" when trying to connect" + let runner = FinishingRunner(.init(exitCode: 2, stderr: stderr, wasCancelled: false)) + let service = NativeDumpService(kind: .restore, runnerFactory: { _ in runner }) + let message = try await failure(of: service, running: job(folder: URL(fileURLWithPath: "/nonexistent/folder"))) + #expect(message == stderr) + } + + @Test("A cancel before the tool runs removes the plugin folder it never used") + func cancelBeforeRunRemovesTheFolder() throws { + let folder = try MariaDBClientPluginFolder.make(hiding: [], from: nil) + defer { try? FileManager.default.removeItem(at: folder) } + let runner = FinishingRunner(.init(exitCode: 0, stderr: "", wasCancelled: false)) + let service = NativeDumpService(kind: .backup, runnerFactory: { _ in runner }) + service.cancel() + try service.run(job: job(folder: folder), database: "sales", fileURL: URL(fileURLWithPath: "/tmp/cancelled.sql")) + #expect(!runner.started) + #expect(!FileManager.default.fileExists(atPath: folder.path)) + } +} + +private final class FakeGreetingServer: @unchecked Sendable { + private let listener: NWListener + let port: Int + + private init(listener: NWListener, port: Int) { + self.listener = listener + self.port = port + } + + static func start(payload: [UInt8]) async throws -> FakeGreetingServer { + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: .any) + let listener = try NWListener(using: parameters) + let queue = DispatchQueue(label: "com.TablePro.tests.FakeGreetingServer") + let packet = Data([UInt8(payload.count & 0xFF), UInt8((payload.count >> 8) & 0xFF), 0, 0] + payload) + listener.newConnectionHandler = { connection in + connection.start(queue: queue) + connection.send(content: packet, completion: .contentProcessed { _ in }) + } + let port: Int = try await withCheckedThrowingContinuation { continuation in + let resumed = OSAllocatedUnfairLock(initialState: false) + listener.stateUpdateHandler = { state in + let value: Result? + switch state { + case .ready: + value = listener.port.map { .success(Int($0.rawValue)) } ?? .failure(CancellationError()) + case .failed(let error): + value = .failure(error) + case .cancelled: + value = .failure(CancellationError()) + default: + value = nil + } + guard let value else { return } + let first = resumed.withLock { done -> Bool in + defer { done = true } + return !done + } + if first { continuation.resume(with: value) } + } + listener.start(queue: queue) + } + return FakeGreetingServer(listener: listener, port: port) + } + + func stop() { + listener.cancel() + } +} diff --git a/apple/TableProTests/Database/NativeDumpRegistryTests.swift b/apple/TableProTests/Database/NativeDumpRegistryTests.swift index 862b365fc1..388b77108c 100644 --- a/apple/TableProTests/Database/NativeDumpRegistryTests.swift +++ b/apple/TableProTests/Database/NativeDumpRegistryTests.swift @@ -49,7 +49,7 @@ struct NativeDumpRegistryTests { ) throws -> NativeDumpCommand { let tool = try #require(NativeDumpRegistry.descriptor(for: type)?.commandLineTool) let effective = overrideConnection ?? connection(type: type) - return try NativeDumpService.buildCommand( + let built = try NativeDumpService.buildCommand( kind: kind, tool: tool, resolved: NativeDumpResolvedTool( @@ -68,6 +68,10 @@ struct NativeDumpRegistryTests { serverVersion: serverVersion ) ) + if let folder = built.clientPluginFolder { + try? FileManager.default.removeItem(at: folder.url) + } + return built } private func configuration(of built: NativeDumpCommand) throws -> String { diff --git a/apple/scripts/probes/check-mariadb-tls-modes.sh b/apple/scripts/probes/check-mariadb-tls-modes.sh index 41065e3eb4..2a9aa1d28f 100755 --- a/apple/scripts/probes/check-mariadb-tls-modes.sh +++ b/apple/scripts/probes/check-mariadb-tls-modes.sh @@ -55,7 +55,7 @@ SECRET="probe-secret-token" check() { local label=$1 app=$2 expect=$3 server_args=$4 host=$5 shift 5 - python3 -I "$PROBE/fake-mysql-server.py" $server_args --cleartext --secret "$SECRET" > "$WORK/server.log" 2>&1 & + python3 -I "$PROBE/fake-mysql-server.py" $server_args --auth cleartext --secret "$SECRET" > "$WORK/server.log" 2>&1 & local server=$! local port="" for _ in $(seq 1 50); do diff --git a/apple/scripts/probes/check-mysql-dump-plaintext-password.sh b/apple/scripts/probes/check-mysql-dump-plaintext-password.sh new file mode 100755 index 0000000000..7c77422790 --- /dev/null +++ b/apple/scripts/probes/check-mysql-dump-plaintext-password.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Checks that MariaDB's backup and restore tools, given the options NativeDumpRegistry builds for each SSL mode, never +# send the password as typed without TLS. +# +# The tools answer mysql_clear_password and dialog (PAM) without TLS and have no option that refuses them, so the app +# points them at a plugin folder without those two. Their --ssl without verification also falls back to plaintext and +# then answers caching_sha2_password's full authentication with the typed password, so Preferred dials with +# --skip-ssl unless the server's greeting offered TLS. Every row runs a real tool against +# mariadb-tls-modes/fake-mysql-server.py. MySQL's own tools are not checked here: the app leaves them as they are. +# +# Needs python3, openssl and MariaDB's mariadb-dump and mariadb (brew install mariadb). Skips (exit 3) without them. +# Exits non-zero on any unexpected outcome. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SERVER="$ROOT/scripts/probes/mariadb-tls-modes/fake-mysql-server.py" +SECRET="probe-secret-token" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +is_mariadb() { + case "$("$1" --version 2> /dev/null)" in *MariaDB* | *mariadb*) return 0 ;; esac + return 1 +} + +find_tool() { + local name candidate + for name in "$@"; do + for candidate in "$(command -v "$name" 2> /dev/null)" /opt/homebrew/opt/mariadb/bin/"$name" \ + /usr/local/opt/mariadb/bin/"$name"; do + [ -n "$candidate" ] && [ -x "$candidate" ] && is_mariadb "$candidate" && { + echo "$candidate" + return 0 + } + done + done + return 1 +} + +DUMP="$(find_tool mariadb-dump mysqldump)" || { + echo "MariaDB's mariadb-dump not found; install it with brew install mariadb" >&2 + exit 3 +} +CLIENT="$(find_tool mariadb mysql)" || { + echo "MariaDB's mariadb not found; install it with brew install mariadb" >&2 + exit 3 +} + +# The folder the app builds: every entry of the tool's own plugin folder but the two that send the password as typed. +plugin_folder() { + local tool prefix source="" layout folder entry + tool="$(python3 -I -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$1")" + prefix="$(dirname "$(dirname "$tool")")" + for layout in lib/plugin lib/mysql/plugin; do + [ -d "$prefix/$layout" ] && source="$prefix/$layout" && break + done + [ -n "$source" ] || { + echo "no plugin folder beside $tool" >&2 + exit 2 + } + folder="$WORK/plugins-$(basename "$1")" + mkdir -p "$folder" + for entry in "$source"/*; do + case "$(basename "$entry")" in mysql_clear_password.so | dialog.so) continue ;; esac + ln -s "$entry" "$folder/$(basename "$entry")" + done + echo "$folder" +} + +openssl req -x509 -newkey rsa:2048 -nodes -keyout "$WORK/self.key" -out "$WORK/self.pem" -days 2 \ + -subj "/CN=db.example.com" > /dev/null 2>&1 || { + echo "could not create a test certificate" >&2 + exit 2 +} + +failures=0 + +# expect is a list of words the outcome must hold: refused-, public-key, tls, leak, ssl-required. +# A secret seen in plaintext fails every row but the one that expects it. +check() { + local label=$1 tool=$2 expect=$3 server_args=$4 + shift 4 + python3 -I "$SERVER" $server_args --secret "$SECRET" > "$WORK/server.log" 2>&1 & + local server=$! port="" + for _ in $(seq 1 50); do + port="$(head -1 "$WORK/server.log" 2> /dev/null)" + [ -n "$port" ] && break + sleep 0.1 + done + local output + output="$(MYSQL_PWD="$SECRET" "$tool" --no-defaults --protocol=TCP -h 127.0.0.1 -P "$port" -u probe "$@" \ + -- probe < /dev/null 2>&1 | head -3)" + wait "$server" + local session + session="$(tail -1 "$WORK/server.log")" + + local verdict=ok word + case "$session" in *'"secretInPlaintext": true'*) [[ " $expect " == *" leak "* ]] || verdict=FAIL ;; esac + for word in $expect; do + case "$word" in + refused-*) [[ "$output" == *"Plugin ${word#refused-} could not be loaded"* ]] || verdict=FAIL ;; + public-key) [[ "$session" == *'"answer": "public key request"'* ]] || verdict=FAIL ;; + tls) [[ "$session" == *'"encrypted": true'* ]] || verdict=FAIL ;; + leak) [[ "$session" == *'"secretInPlaintext": true'* ]] || verdict=FAIL ;; + ssl-required) [[ "$output" == *"2026"* ]] || verdict=FAIL ;; + login) [[ "$session" == *'"loggedIn": true'* ]] || verdict=FAIL ;; + esac + done + [ "$verdict" = ok ] || failures=$((failures + 1)) + printf '%-4s %-13s %-58s %s\n' "$verdict" "$(basename "$tool")" "$label" "$session" +} + +for tool in "$DUMP" "$CLIENT"; do + folder="$(plugin_folder "$tool")" + hidden=(--skip-ssl "--plugin-dir=$folder") + # Disabled, and Preferred after a greeting without TLS, run with the same options. + check "Disabled, server asks for mysql_clear_password" "$tool" refused-mysql_clear_password "--auth cleartext" \ + "${hidden[@]}" + check "Disabled, server asks through PAM dialog" "$tool" refused-dialog "--auth dialog" "${hidden[@]}" + check "Disabled, caching_sha2_password full authentication" "$tool" public-key "--auth sha2-full" "${hidden[@]}" + check "Disabled, mysql_native_password" "$tool" login "" "${hidden[@]}" + check "Preferred, server offers TLS, mysql_clear_password" "$tool" tls \ + "--auth cleartext --cert $WORK/self.pem --key $WORK/self.key" --ssl --skip-ssl-verify-server-cert + check "Preferred, server offers TLS, full authentication" "$tool" tls \ + "--auth sha2-full --cert $WORK/self.pem --key $WORK/self.key" --ssl --skip-ssl-verify-server-cert + check "Required, server without TLS" "$tool" ssl-required "--auth cleartext" --ssl --ssl-verify-server-cert + # Why Preferred reads the greeting: the options it had before send the password to a server without TLS. + check "Old Preferred options, server without TLS, full authentication" "$tool" leak "--auth sha2-full" \ + --ssl --skip-ssl-verify-server-cert +done + +if [ "$failures" -eq 0 ]; then + echo "MariaDB's tools keep the password off the wire without TLS." +else + echo "$failures unexpected outcome(s)." >&2 +fi +exit $((failures > 0)) diff --git a/apple/scripts/probes/check-mysql-dump-tool-flags.sh b/apple/scripts/probes/check-mysql-dump-tool-flags.sh index ea03b67063..17c42199c8 100755 --- a/apple/scripts/probes/check-mysql-dump-tool-flags.sh +++ b/apple/scripts/probes/check-mysql-dump-tool-flags.sh @@ -58,6 +58,7 @@ MARIADB_FLAGS=( "--ssl-ca=/dev/null" "--ssl-cert=/dev/null" "--ssl-key=/dev/null" + "--plugin-dir=/dev/null" ) failures=0 diff --git a/apple/scripts/probes/mariadb-tls-modes/fake-mysql-server.py b/apple/scripts/probes/mariadb-tls-modes/fake-mysql-server.py index 668de9a98d..43fe808dce 100644 --- a/apple/scripts/probes/mariadb-tls-modes/fake-mysql-server.py +++ b/apple/scripts/probes/mariadb-tls-modes/fake-mysql-server.py @@ -1,5 +1,6 @@ -# One loopback MySQL session for check-mariadb-tls-modes.sh. Prints the port, then a JSON line saying -# whether TLS came up, whether the login and a cleartext answer arrived, and whether the secret crossed in plaintext. +# One loopback MySQL session for check-mariadb-tls-modes.sh and check-mysql-dump-plaintext-password.sh. Prints the +# port, then a JSON line saying whether TLS came up, whether the login and the answer to the --auth request arrived, +# and whether the secret crossed in plaintext. import argparse import json import socket @@ -10,7 +11,9 @@ parser = argparse.ArgumentParser() parser.add_argument("--cert") parser.add_argument("--key") -parser.add_argument("--cleartext", action="store_true") +# dialog is how PAM asks; sha2-full is caching_sha2_password asking for full authentication, which a client +# answers with the typed password once it thinks the session is secure. +parser.add_argument("--auth", choices=["cleartext", "dialog", "sha2-full"]) parser.add_argument("--secret", default="") args = parser.parse_args() @@ -28,7 +31,7 @@ connection.settimeout(10) plaintext = bytearray() -state = {"askedForTLS": False, "encrypted": False, "sawLogin": False, "cleartext": None, "loggedIn": False} +state = {"askedForTLS": False, "encrypted": False, "sawLogin": False, "answer": None, "loggedIn": False} stream = connection @@ -71,10 +74,22 @@ def send(payload, sequence): state["encrypted"] = True sequence, login = read_packet() state["sawLogin"] = True - if args.cleartext: + if args.auth == "cleartext": send(b"\xfemysql_clear_password\0", (sequence + 1) & 0xFF) sequence, answer = read_packet() - state["cleartext"] = answer.split(b"\0")[0].decode(errors="replace") + state["answer"] = answer.split(b"\0")[0].decode(errors="replace") + elif args.auth == "dialog": + send(b"\xfedialog\0\x05Password: ", (sequence + 1) & 0xFF) + sequence, answer = read_packet() + state["answer"] = answer.split(b"\0")[0].decode(errors="replace") + elif args.auth == "sha2-full": + send(b"\xfecaching_sha2_password\0abcdefghijklmnopqrst\0", (sequence + 1) & 0xFF) + sequence, _ = read_packet() + send(b"\x01\x04", (sequence + 1) & 0xFF) + sequence, answer = read_packet() + # 0x02 asks for the server's RSA key, which is how a client that knows it is in plaintext answers. + state["answer"] = "public key request" if answer == b"\x02" else answer.split(b"\0")[0].decode(errors="replace") + raise EOFError send(ok, (sequence + 1) & 0xFF) state["loggedIn"] = True while True: From abbe9666a9f6e2f98b67f5a23fe7c45da9cc55bd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 12:07:30 +0700 Subject: [PATCH 02/12] docs(changelog): add the backup and restore password fragment --- apple/changelog.d/3742.security.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3742.security.md diff --git a/apple/changelog.d/3742.security.md b/apple/changelog.d/3742.security.md new file mode 100644 index 0000000000..2f91226621 --- /dev/null +++ b/apple/changelog.d/3742.security.md @@ -0,0 +1 @@ +MySQL backup and restore through MariaDB's tools sending the password in plain text to a server without TLS. From 8bd8b2d8362eebaa3de736b3a38910fa6cabfff1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 12:35:28 +0700 Subject: [PATCH 03/12] fix(mysql): link only the plugins that never send the password as typed --- .../Database/MariaDBClientPluginFolder.swift | 59 +++++++++----- .../Core/Database/NativeDumpDescriptor.swift | 18 +---- .../Core/Database/NativeDumpRegistry.swift | 13 +-- .../Core/Database/NativeDumpService.swift | 8 +- .../MySQLDumpPlaintextPasswordTests.swift | 79 ++++++++----------- .../check-mysql-dump-plaintext-password.sh | 25 ++++-- .../mariadb-tls-modes/fake-mysql-server.py | 12 +-- 7 files changed, 110 insertions(+), 104 deletions(-) diff --git a/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift b/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift index 9b12929048..a317e0a994 100644 --- a/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift +++ b/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift @@ -5,17 +5,31 @@ import Foundation -// MariaDB's client tools answer a server that asks for the password as typed (mysql_clear_password, or dialog for PAM) -// without TLS, and have no option that refuses it. Both are loaded from the plugin folder, so a run that might go -// without TLS points the tool at a folder that links every plugin but those two. Measured with mariadb-dump and mariadb -// 13.0.2 from Homebrew: they then fail with 2059 "Plugin dialog could not be loaded" before sending anything. -enum MariaDBClientPluginFolder { - static let plaintextPasswordPlugins = ["mysql_clear_password", "dialog"] +// MariaDB's client tools answer a server that asks for the password as typed (mysql_clear_password, dialog for PAM, and +// the test plugins Homebrew ships) without TLS, and have no option that refuses it. Every one of them is loaded from +// the plugin folder, so a run that might go without TLS points the tool at a private folder that links only the +// plugins that never send it. Measured with mariadb-dump and mariadb 13.0.2 from Homebrew: a server asking for any +// other plugin gets 2059 "Plugin dialog could not be loaded" before anything is sent. +struct MariaDBClientPluginFolder: Sendable, Equatable { + // The app's MySQL driver allows the same sign-in methods without TLS (MySQLPlaintextPassword.methodsWithoutTLS). + // mysql_native_password and mysql_old_password are built into the tools, and zstd is compression. + static let allowedPlugins: Set = [ + "caching_sha2_password", + "sha256_password", + "client_ed25519", + "parsec", + "auth_gssapi_client", + "zstd" + ] // The two install layouts MariaDB's build defines for this platform, relative to the prefix that holds bin. // Homebrew uses the first. private static let layouts = ["lib/plugin", "lib/mysql/plugin"] + private static let refusedPluginPattern = #/Plugin ([A-Za-z0-9_]+) could not be loaded/# + + let url: URL + static func pluginDirectory(forTool path: String) -> URL? { let tool = URL(fileURLWithPath: path).resolvingSymlinksInPath() let prefix = tool.deletingLastPathComponent().deletingLastPathComponent() @@ -24,22 +38,31 @@ enum MariaDBClientPluginFolder { .first { isDirectory($0) } } - // A tool whose folder cannot be found gets an empty one: logins built into the tool still work, and none that - // could send the password as typed. - static func make(hiding hidden: [String], from source: URL?, in parent: URL = FileManager.default.temporaryDirectory) throws -> URL { + // A tool whose folder cannot be found gets an empty one: the built-in sign-ins still work, and nothing that could + // send the password as typed. + static func make( + from source: URL?, + in parent: URL = FileManager.default.temporaryDirectory + ) throws -> MariaDBClientPluginFolder { let manager = FileManager.default let folder = parent.appendingPathComponent("tablepro-client-plugins-\(UUID().uuidString)", isDirectory: true) try manager.createDirectory(at: folder, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) - guard let source else { return folder } - let hiddenFiles = Set(hidden.map { "\($0).so" }) - let entries = (try? manager.contentsOfDirectory(atPath: source.path)) ?? [] - for entry in entries where !hiddenFiles.contains(entry) { - try manager.createSymbolicLink( - at: folder.appendingPathComponent(entry), - withDestinationURL: source.appendingPathComponent(entry) - ) + guard let source else { return MariaDBClientPluginFolder(url: folder) } + for name in allowedPlugins.sorted() { + let file = "\(name).so" + let target = source.appendingPathComponent(file) + guard manager.fileExists(atPath: target.path) else { continue } + try manager.createSymbolicLink(at: folder.appendingPathComponent(file), withDestinationURL: target) } - return folder + return MariaDBClientPluginFolder(url: folder) + } + + // A plugin left out of the folder is one that sends the password as typed. One that was allowed but could not be + // loaded keeps the tool's own words. + func refusal(in stderr: String) -> String? { + let refused = stderr.matches(of: Self.refusedPluginPattern).map { String($0.output.1) } + guard refused.contains(where: { !Self.allowedPlugins.contains($0) }) else { return nil } + return MySQLClientArguments.plaintextPasswordRefusal } private static func isDirectory(_ url: URL) -> Bool { diff --git a/apple/TablePro/Core/Database/NativeDumpDescriptor.swift b/apple/TablePro/Core/Database/NativeDumpDescriptor.swift index e42cef1596..59891d4601 100644 --- a/apple/TablePro/Core/Database/NativeDumpDescriptor.swift +++ b/apple/TablePro/Core/Database/NativeDumpDescriptor.swift @@ -81,7 +81,7 @@ struct NativeDumpDescriptor: Sendable { var serverOffersTLS: Bool? // Set by `NativeDumpService.buildCommand` before the arguments are built. - var clientPluginFolder: ClientPluginFolder? + var clientPluginFolder: MariaDBClientPluginFolder? init( connection: DatabaseConnection, @@ -115,18 +115,6 @@ struct NativeDumpDescriptor: Sendable { let value: String } - // A private folder of the tool's client plugins with some left out, removed once the process exits. - struct ClientPluginFolder: Sendable, Equatable { - let url: URL - // What the tool prints when the server asked for a hidden plugin, and what the user reads instead. - let refusalMarkers: [String] - let refusalMessage: String - - func refusal(in stderr: String) -> String? { - refusalMarkers.contains { stderr.contains($0) } ? refusalMessage : nil - } - } - /// A tool on the user's Mac that the app spawns. struct CommandLineTool: Sendable { /// The candidate names, in the order they are tried. More than one because a tool can ship @@ -171,7 +159,7 @@ struct NativeDumpDescriptor: Sendable { // Asked before the arguments are built; the answer lands in `Request.serverOffersTLS`. let serverOffersTLS: @Sendable (Request, NativeDumpResolvedTool) async -> Bool? - let clientPluginFolder: @Sendable (Request, NativeDumpResolvedTool) throws -> ClientPluginFolder? + let clientPluginFolder: @Sendable (Request, NativeDumpResolvedTool) throws -> MariaDBClientPluginFolder? init( backupBinaries: [String], @@ -189,7 +177,7 @@ struct NativeDumpDescriptor: Sendable { environment: @escaping @Sendable (Request) -> [String: String] = { _ in [:] }, configurationFileEntries: @escaping @Sendable (Request) -> [ConfigurationEntry] = { _ in [] }, serverOffersTLS: @escaping @Sendable (Request, NativeDumpResolvedTool) async -> Bool? = { _, _ in nil }, - clientPluginFolder: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> ClientPluginFolder? = { _, _ in nil } + clientPluginFolder: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> MariaDBClientPluginFolder? = { _, _ in nil } ) { self.backupBinaries = backupBinaries self.restoreBinaries = restoreBinaries diff --git a/apple/TablePro/Core/Database/NativeDumpRegistry.swift b/apple/TablePro/Core/Database/NativeDumpRegistry.swift index b8f677b554..767eee7aa8 100644 --- a/apple/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/apple/TablePro/Core/Database/NativeDumpRegistry.swift @@ -258,22 +258,13 @@ enum NativeDumpRegistry { private static func mysqlPluginFolder( _ request: NativeDumpDescriptor.Request, _ resolved: NativeDumpResolvedTool - ) throws -> NativeDumpDescriptor.ClientPluginFolder? { + ) throws -> MariaDBClientPluginFolder? { guard MySQLClientArguments.hidesPlaintextPasswordPlugins( request.connection, flavor: resolved.flavor, serverOffersTLS: request.serverOffersTLS ) else { return nil } - let hidden = MariaDBClientPluginFolder.plaintextPasswordPlugins - let folder = try MariaDBClientPluginFolder.make( - hiding: hidden, - from: MariaDBClientPluginFolder.pluginDirectory(forTool: resolved.path) - ) - return NativeDumpDescriptor.ClientPluginFolder( - url: folder, - refusalMarkers: hidden.map { "Plugin \($0) could not be loaded" }, - refusalMessage: MySQLClientArguments.plaintextPasswordRefusal - ) + return try MariaDBClientPluginFolder.make(from: MariaDBClientPluginFolder.pluginDirectory(forTool: resolved.path)) } private static func mysqlConnectionFlags( diff --git a/apple/TablePro/Core/Database/NativeDumpService.swift b/apple/TablePro/Core/Database/NativeDumpService.swift index 3c3fe3f05d..6ae7470c7b 100644 --- a/apple/TablePro/Core/Database/NativeDumpService.swift +++ b/apple/TablePro/Core/Database/NativeDumpService.swift @@ -100,7 +100,7 @@ struct NativeDumpCommand: Equatable { /// standard input. Removed once the process exits, however it exits. let temporaryCredentialsFileURL: URL? - let clientPluginFolder: NativeDumpDescriptor.ClientPluginFolder? + let clientPluginFolder: MariaDBClientPluginFolder? /// Which direction a redirected file goes: a restore feeds it in as standard input, a backup /// takes standard output and writes it out. @@ -116,7 +116,7 @@ struct NativeDumpCommand: Equatable { delivery: NativeDumpDescriptor.OutputDelivery = .toolWritesFile, redirectedFileURL: URL? = nil, temporaryCredentialsFileURL: URL? = nil, - clientPluginFolder: NativeDumpDescriptor.ClientPluginFolder? = nil, + clientPluginFolder: MariaDBClientPluginFolder? = nil, isRestore: Bool = false, exitPolicy: NativeDumpExitPolicy = .zeroExitOnly ) { @@ -155,7 +155,7 @@ internal extension NativeDumpJob { return command.exitPolicy } - var clientPluginFolder: NativeDumpDescriptor.ClientPluginFolder? { + var clientPluginFolder: MariaDBClientPluginFolder? { guard case .process(let command) = self else { return nil } return command.clientPluginFolder } @@ -629,7 +629,7 @@ final class NativeDumpService: ObservableObject { database: String, fileURL: URL, exitPolicy: NativeDumpExitPolicy, - pluginFolder: NativeDumpDescriptor.ClientPluginFolder? + pluginFolder: MariaDBClientPluginFolder? ) { byteSizeTask?.cancel() byteSizeTask = nil diff --git a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift index 7ef44f6857..dc2ec26349 100644 --- a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift +++ b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift @@ -213,70 +213,65 @@ struct MySQLDumpPlaintextPasswordTests { #expect(MariaDBClientPluginFolder.pluginDirectory(forTool: root.appendingPathComponent("nowhere/x").path) == nil) } - @Test("The private folder links every plugin but the hidden ones") - func privateFolderLeavesOutHiddenPlugins() throws { + @Test("The private folder links only the plugins that never send the password as typed") + func privateFolderLinksOnlyAllowedPlugins() throws { let source = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: source) } - let names = ["caching_sha2_password.so", "client_ed25519.so", "mysql_clear_password.so", "dialog.so", "parsec.so"] - for name in names { + let shipped = [ + "caching_sha2_password.so", "client_ed25519.so", "mysql_clear_password.so", "dialog.so", "parsec.so", + "qa_auth_client.so", "qa_auth_interface.so", "auth_test_plugin.so", "zstd.so", "ha_archive.so" + ] + for name in shipped { #expect(FileManager.default.createFile(atPath: source.appendingPathComponent(name).path, contents: Data(name.utf8))) } - let folder = try MariaDBClientPluginFolder.make( - hiding: MariaDBClientPluginFolder.plaintextPasswordPlugins, - from: source - ) - defer { try? FileManager.default.removeItem(at: folder) } - let linked = try FileManager.default.contentsOfDirectory(atPath: folder.path).sorted() - #expect(linked == ["caching_sha2_password.so", "client_ed25519.so", "parsec.so"]) + let folder = try MariaDBClientPluginFolder.make(from: source) + defer { try? FileManager.default.removeItem(at: folder.url) } + let linked = try FileManager.default.contentsOfDirectory(atPath: folder.url.path).sorted() + #expect(linked == ["caching_sha2_password.so", "client_ed25519.so", "parsec.so", "zstd.so"]) let destination = try FileManager.default.destinationOfSymbolicLink( - atPath: folder.appendingPathComponent("parsec.so").path + atPath: folder.url.appendingPathComponent("parsec.so").path ) #expect(destination == source.appendingPathComponent("parsec.so").path) - let empty = try MariaDBClientPluginFolder.make(hiding: ["dialog"], from: nil) - defer { try? FileManager.default.removeItem(at: empty) } - #expect(try FileManager.default.contentsOfDirectory(atPath: empty.path).isEmpty) + let empty = try MariaDBClientPluginFolder.make(from: nil) + defer { try? FileManager.default.removeItem(at: empty.url) } + #expect(try FileManager.default.contentsOfDirectory(atPath: empty.url.path).isEmpty) } - @Test("The refusal is recognized in both tools' wording and nothing else") - func refusalMarkers() throws { - let built = try command(connection(.disabled)) - defer { discard(built) } - let folder = try #require(built.clientPluginFolder) + @Test("A plugin left out of the folder reads as the plain-text refusal, an allowed one keeps the tool's words") + func refusalFollowsTheAllowList() throws { + let folder = MariaDBClientPluginFolder(url: URL(fileURLWithPath: "/nonexistent/folder")) + let refusal = MySQLClientArguments.plaintextPasswordRefusal let dump = #"mariadb-dump: Got error: 2059: "Plugin dialog could not be loaded: dlopen(/x/dialog.so, 0x0002)" when trying to connect"# - let client = "ERROR 2059 (HY000): Plugin mysql_clear_password could not be loaded: dlopen(/x/mysql_clear_password.so)" - #expect(folder.refusal(in: dump) == MySQLClientArguments.plaintextPasswordRefusal) - #expect(folder.refusal(in: client) == MySQLClientArguments.plaintextPasswordRefusal) - #expect(folder.refusal(in: "ERROR 2059 (HY000): Plugin caching_sha2_password could not be loaded") == nil) - #expect(folder.refusal(in: "ERROR 1045 (28000): Access denied") == nil) + #expect(folder.refusal(in: dump) == refusal) + #expect(folder.refusal(in: "ERROR 2059 (HY000): Plugin mysql_clear_password could not be loaded: dlopen(/x)") == refusal) + #expect(folder.refusal(in: "ERROR 2059 (HY000): Plugin qa_auth_client could not be loaded: dlopen(/x)") == refusal) + #expect(folder.refusal(in: "ERROR 2059 (HY000): Plugin caching_sha2_password could not be loaded: dlopen(/x)") == nil) + #expect(folder.refusal(in: "ERROR 1045 (28000): Access denied for user 'alice'") == nil) } @Test("The plugin folder is removed when the tool exits and when it cannot launch") func runnerRemovesThePluginFolder() async throws { - let ran = try MariaDBClientPluginFolder.make(hiding: [], from: nil) + let ran = try MariaDBClientPluginFolder.make(from: nil) let runner = ProcessNativeDumpRunner(command: runnerCommand("/bin/sh", folder: ran)) try runner.start() _ = await runner.result - #expect(!FileManager.default.fileExists(atPath: ran.path)) + #expect(!FileManager.default.fileExists(atPath: ran.url.path)) - let unlaunched = try MariaDBClientPluginFolder.make(hiding: [], from: nil) + let unlaunched = try MariaDBClientPluginFolder.make(from: nil) let failing = ProcessNativeDumpRunner(command: runnerCommand("/nonexistent/tool", folder: unlaunched)) #expect(throws: (any Error).self) { try failing.start() } - #expect(!FileManager.default.fileExists(atPath: unlaunched.path)) + #expect(!FileManager.default.fileExists(atPath: unlaunched.url.path)) } - private func runnerCommand(_ executable: String, folder: URL) -> NativeDumpCommand { + private func runnerCommand(_ executable: String, folder: MariaDBClientPluginFolder) -> NativeDumpCommand { NativeDumpCommand( executable: URL(fileURLWithPath: executable), arguments: ["-c", "exit 0"], environment: [:], stderrByteCap: 64_000, - clientPluginFolder: NativeDumpDescriptor.ClientPluginFolder( - url: folder, - refusalMarkers: [], - refusalMessage: "" - ) + clientPluginFolder: folder ) } @@ -326,11 +321,7 @@ struct MySQLDumpPlaintextPasswordServiceTests { arguments: [], environment: [:], stderrByteCap: 64_000, - clientPluginFolder: NativeDumpDescriptor.ClientPluginFolder( - url: folder, - refusalMarkers: ["Plugin dialog could not be loaded"], - refusalMessage: MySQLClientArguments.plaintextPasswordRefusal - ) + clientPluginFolder: MariaDBClientPluginFolder(url: folder) ) ) } @@ -365,14 +356,14 @@ struct MySQLDumpPlaintextPasswordServiceTests { @Test("A cancel before the tool runs removes the plugin folder it never used") func cancelBeforeRunRemovesTheFolder() throws { - let folder = try MariaDBClientPluginFolder.make(hiding: [], from: nil) - defer { try? FileManager.default.removeItem(at: folder) } + let folder = try MariaDBClientPluginFolder.make(from: nil) + defer { try? FileManager.default.removeItem(at: folder.url) } let runner = FinishingRunner(.init(exitCode: 0, stderr: "", wasCancelled: false)) let service = NativeDumpService(kind: .backup, runnerFactory: { _ in runner }) service.cancel() - try service.run(job: job(folder: folder), database: "sales", fileURL: URL(fileURLWithPath: "/tmp/cancelled.sql")) + try service.run(job: job(folder: folder.url), database: "sales", fileURL: URL(fileURLWithPath: "/tmp/cancelled.sql")) #expect(!runner.started) - #expect(!FileManager.default.fileExists(atPath: folder.path)) + #expect(!FileManager.default.fileExists(atPath: folder.url.path)) } } diff --git a/apple/scripts/probes/check-mysql-dump-plaintext-password.sh b/apple/scripts/probes/check-mysql-dump-plaintext-password.sh index 7c77422790..b119d1118c 100755 --- a/apple/scripts/probes/check-mysql-dump-plaintext-password.sh +++ b/apple/scripts/probes/check-mysql-dump-plaintext-password.sh @@ -2,8 +2,8 @@ # Checks that MariaDB's backup and restore tools, given the options NativeDumpRegistry builds for each SSL mode, never # send the password as typed without TLS. # -# The tools answer mysql_clear_password and dialog (PAM) without TLS and have no option that refuses them, so the app -# points them at a plugin folder without those two. Their --ssl without verification also falls back to plaintext and +# The tools answer mysql_clear_password, dialog (PAM) and the test plugins Homebrew ships without TLS, and have no +# option that refuses them, so the app points them at a plugin folder that links only the plugins that never do. Their --ssl without verification also falls back to plaintext and # then answers caching_sha2_password's full authentication with the typed password, so Preferred dials with # --skip-ssl unless the server's greeting offered TLS. Every row runs a real tool against # mariadb-tls-modes/fake-mysql-server.py. MySQL's own tools are not checked here: the app leaves them as they are. @@ -47,9 +47,9 @@ CLIENT="$(find_tool mariadb mysql)" || { exit 3 } -# The folder the app builds: every entry of the tool's own plugin folder but the two that send the password as typed. +# The folder the app builds: only the plugins of the tool's own folder that never send the password as typed. plugin_folder() { - local tool prefix source="" layout folder entry + local tool prefix source="" layout folder name tool="$(python3 -I -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$1")" prefix="$(dirname "$(dirname "$tool")")" for layout in lib/plugin lib/mysql/plugin; do @@ -61,13 +61,22 @@ plugin_folder() { } folder="$WORK/plugins-$(basename "$1")" mkdir -p "$folder" - for entry in "$source"/*; do - case "$(basename "$entry")" in mysql_clear_password.so | dialog.so) continue ;; esac - ln -s "$entry" "$folder/$(basename "$entry")" + for name in $(allowed_plugins); do + [ -e "$source/$name.so" ] && ln -s "$source/$name.so" "$folder/$name.so" done echo "$folder" } +# Read from the Swift source, so the probe checks the list the app ships. +allowed_plugins() { + sed -n '/static let allowedPlugins/,/\]/p' "$ROOT/TablePro/Core/Database/MariaDBClientPluginFolder.swift" \ + | grep -o '"[a-z0-9_]*"' | tr -d '"' +} +[ -n "$(allowed_plugins)" ] || { + echo "could not read allowedPlugins from MariaDBClientPluginFolder.swift" >&2 + exit 2 +} + openssl req -x509 -newkey rsa:2048 -nodes -keyout "$WORK/self.key" -out "$WORK/self.pem" -days 2 \ -subj "/CN=db.example.com" > /dev/null 2>&1 || { echo "could not create a test certificate" >&2 @@ -118,6 +127,8 @@ for tool in "$DUMP" "$CLIENT"; do check "Disabled, server asks for mysql_clear_password" "$tool" refused-mysql_clear_password "--auth cleartext" \ "${hidden[@]}" check "Disabled, server asks through PAM dialog" "$tool" refused-dialog "--auth dialog" "${hidden[@]}" + check "Disabled, server asks for the shipped qa_auth_client" "$tool" refused-qa_auth_client "--auth qa" \ + "${hidden[@]}" check "Disabled, caching_sha2_password full authentication" "$tool" public-key "--auth sha2-full" "${hidden[@]}" check "Disabled, mysql_native_password" "$tool" login "" "${hidden[@]}" check "Preferred, server offers TLS, mysql_clear_password" "$tool" tls \ diff --git a/apple/scripts/probes/mariadb-tls-modes/fake-mysql-server.py b/apple/scripts/probes/mariadb-tls-modes/fake-mysql-server.py index 43fe808dce..c0a46bd5f3 100644 --- a/apple/scripts/probes/mariadb-tls-modes/fake-mysql-server.py +++ b/apple/scripts/probes/mariadb-tls-modes/fake-mysql-server.py @@ -11,9 +11,9 @@ parser = argparse.ArgumentParser() parser.add_argument("--cert") parser.add_argument("--key") -# dialog is how PAM asks; sha2-full is caching_sha2_password asking for full authentication, which a client -# answers with the typed password once it thinks the session is secure. -parser.add_argument("--auth", choices=["cleartext", "dialog", "sha2-full"]) +# dialog is how PAM asks; qa is the test plugin Homebrew's MariaDB ships; sha2-full is caching_sha2_password asking +# for full authentication, which a client answers with the typed password once it thinks the session is secure. +parser.add_argument("--auth", choices=["cleartext", "dialog", "qa", "sha2-full"]) parser.add_argument("--secret", default="") args = parser.parse_args() @@ -78,8 +78,10 @@ def send(payload, sequence): send(b"\xfemysql_clear_password\0", (sequence + 1) & 0xFF) sequence, answer = read_packet() state["answer"] = answer.split(b"\0")[0].decode(errors="replace") - elif args.auth == "dialog": - send(b"\xfedialog\0\x05Password: ", (sequence + 1) & 0xFF) + elif args.auth in ("dialog", "qa"): + # 0x05 asks for the password as the last question; qa_auth_client answers it only with an empty prompt. + request = b"dialog\0\x05Password: " if args.auth == "dialog" else b"qa_auth_client\0\x05" + send(b"\xfe" + request, (sequence + 1) & 0xFF) sequence, answer = read_packet() state["answer"] = answer.split(b"\0")[0].decode(errors="replace") elif args.auth == "sha2-full": From 3c93c46fe4c4e00b1caa4a7f9ba4394534ac7f53 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 12:40:42 +0700 Subject: [PATCH 04/12] fix(mysql): stop a backup whose server greeting cannot be read --- .../Core/Database/MySQLServerGreeting.swift | 64 +++++++++++------- .../Core/Database/NativeDumpDescriptor.swift | 4 +- .../Core/Database/NativeDumpRegistry.swift | 30 +++++++-- .../Core/Database/NativeDumpService.swift | 5 +- .../MySQLDumpPlaintextPasswordTests.swift | 67 +++++++++++++------ 5 files changed, 116 insertions(+), 54 deletions(-) diff --git a/apple/TablePro/Core/Database/MySQLServerGreeting.swift b/apple/TablePro/Core/Database/MySQLServerGreeting.swift index cd6770efaf..d990960163 100644 --- a/apple/TablePro/Core/Database/MySQLServerGreeting.swift +++ b/apple/TablePro/Core/Database/MySQLServerGreeting.swift @@ -11,17 +11,22 @@ import os // password when caching_sha2_password asks for full authentication. Reading the server's greeting first, which carries // no credentials, lets the dump pick TLS or plaintext up front instead. enum MySQLServerGreeting { + enum Answer: Equatable, Sendable { + case offersTLS(Bool) + case serverError(String) + case unreadable + } + private static let clientSSL: UInt16 = 0x0800 private static let errorPacket: UInt8 = 0xFF private static let queue = DispatchQueue(label: "com.TablePro.MySQLServerGreeting", qos: .userInitiated) - // Nil when the server cannot be read, which a caller treats as no TLS. - static func offersTLS(host: String, port: Int, deadline: Duration = .seconds(10)) async -> Bool? { - guard let nwPort = NWEndpoint.Port(rawValue: UInt16(clamping: port)), port > 0 else { return nil } + static func read(host: String, port: Int, deadline: Duration = .seconds(10)) async -> Answer { + guard port > 0, let nwPort = NWEndpoint.Port(rawValue: UInt16(clamping: port)) else { return .unreadable } let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp) - let answer: Bool? = await withCheckedContinuation { continuation in + return await withCheckedContinuation { continuation in let resumed = OSAllocatedUnfairLock(initialState: false) - let complete: @Sendable (Bool?) -> Void = { value in + let complete: @Sendable (Answer) -> Void = { value in let shouldResume = resumed.withLock { done -> Bool in guard !done else { return false } done = true @@ -36,54 +41,63 @@ enum MySQLServerGreeting { case .ready: readGreeting(from: connection, complete: complete) case .failed, .cancelled, .waiting: - complete(nil) + complete(.unreadable) default: break } } connection.start(queue: queue) - queue.asyncAfter(deadline: .now() + deadline.timeInterval) { complete(nil) } + let parts = deadline.components + let seconds = Double(parts.seconds) + Double(parts.attoseconds) / 1e18 + queue.asyncAfter(deadline: .now() + seconds) { complete(.unreadable) } } - return answer } - static func offersTLS(greeting payload: [UInt8]) -> Bool? { - guard let protocolVersion = payload.first, protocolVersion != errorPacket else { return nil } - guard let versionEnd = payload.dropFirst().firstIndex(of: 0) else { return nil } + static func answer(greeting payload: [UInt8]) -> Answer { + guard let first = payload.first else { return .unreadable } + if first == errorPacket { + return serverError(payload).map { .serverError($0) } ?? .unreadable + } + guard let versionEnd = payload.dropFirst().firstIndex(of: 0) else { return .unreadable } // Thread id (4), the first part of the scramble (8) and a filler byte sit before the capability flags. let flagsStart = versionEnd + 1 + 4 + 8 + 1 - guard payload.count >= flagsStart + 2 else { return nil } + guard payload.count >= flagsStart + 2 else { return .unreadable } let flags = UInt16(payload[flagsStart]) | UInt16(payload[flagsStart + 1]) << 8 - return flags & clientSSL != 0 + return .offersTLS(flags & clientSSL != 0) } - private static func readGreeting(from connection: NWConnection, complete: @escaping @Sendable (Bool?) -> Void) { + // A server refusing the connection outright, such as "Too many connections", answers with an error packet in place + // of the greeting. Before the handshake it carries no SQL state marker. + private static func serverError(_ payload: [UInt8]) -> String? { + guard payload.count > 3 else { return nil } + var message = payload.dropFirst(3) + if message.first == UInt8(ascii: "#"), message.count >= 6 { + message = message.dropFirst(6) + } + let text = String(bytes: message, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + return text?.isEmpty == false ? text : nil + } + + private static func readGreeting(from connection: NWConnection, complete: @escaping @Sendable (Answer) -> Void) { connection.receive(minimumIncompleteLength: 4, maximumLength: 4) { header, _, _, error in guard error == nil, let header, header.count == 4 else { - complete(nil) + complete(.unreadable) return } let length = Int(header[header.startIndex]) | Int(header[header.startIndex + 1]) << 8 | Int(header[header.startIndex + 2]) << 16 guard length > 0 else { - complete(nil) + complete(.unreadable) return } connection.receive(minimumIncompleteLength: length, maximumLength: length) { payload, _, _, error in guard error == nil, let payload, payload.count == length else { - complete(nil) + complete(.unreadable) return } - complete(offersTLS(greeting: Array(payload))) + complete(answer(greeting: Array(payload))) } } } } - -private extension Duration { - var timeInterval: TimeInterval { - let parts = components - return TimeInterval(parts.seconds) + TimeInterval(parts.attoseconds) / 1e18 - } -} diff --git a/apple/TablePro/Core/Database/NativeDumpDescriptor.swift b/apple/TablePro/Core/Database/NativeDumpDescriptor.swift index 59891d4601..1158cad227 100644 --- a/apple/TablePro/Core/Database/NativeDumpDescriptor.swift +++ b/apple/TablePro/Core/Database/NativeDumpDescriptor.swift @@ -157,7 +157,7 @@ struct NativeDumpDescriptor: Sendable { let environment: @Sendable (Request) -> [String: String] // Asked before the arguments are built; the answer lands in `Request.serverOffersTLS`. - let serverOffersTLS: @Sendable (Request, NativeDumpResolvedTool) async -> Bool? + let serverOffersTLS: @Sendable (Request, NativeDumpResolvedTool) async throws -> Bool? let clientPluginFolder: @Sendable (Request, NativeDumpResolvedTool) throws -> MariaDBClientPluginFolder? @@ -176,7 +176,7 @@ struct NativeDumpDescriptor: Sendable { restoreArguments: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> [String], environment: @escaping @Sendable (Request) -> [String: String] = { _ in [:] }, configurationFileEntries: @escaping @Sendable (Request) -> [ConfigurationEntry] = { _ in [] }, - serverOffersTLS: @escaping @Sendable (Request, NativeDumpResolvedTool) async -> Bool? = { _, _ in nil }, + serverOffersTLS: @escaping @Sendable (Request, NativeDumpResolvedTool) async throws -> Bool? = { _, _ in nil }, clientPluginFolder: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> MariaDBClientPluginFolder? = { _, _ in nil } ) { self.backupBinaries = backupBinaries diff --git a/apple/TablePro/Core/Database/NativeDumpRegistry.swift b/apple/TablePro/Core/Database/NativeDumpRegistry.swift index 767eee7aa8..e32474fa56 100644 --- a/apple/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/apple/TablePro/Core/Database/NativeDumpRegistry.swift @@ -219,11 +219,7 @@ enum NativeDumpRegistry { return ["MYSQL_PWD": password] }, serverOffersTLS: { request, resolved in - guard MySQLClientArguments.needsServerGreeting(request.connection, flavor: resolved.flavor) else { - return nil - } - return await MySQLServerGreeting.offersTLS(host: request.host, port: request.connection.port) - ?? false + try await mysqlServerOffersTLS(request, resolved) }, clientPluginFolder: { request, resolved in try mysqlPluginFolder(request, resolved) @@ -255,6 +251,30 @@ enum NativeDumpRegistry { ["--", request.database] + request.scope.objects.map(\.name) } + // Unreadable fails the run: guessing no TLS would dump in plaintext from a server that has it, and guessing TLS + // brings back the fallback that sends the typed password. + private static func mysqlServerOffersTLS( + _ request: NativeDumpDescriptor.Request, + _ resolved: NativeDumpResolvedTool + ) async throws -> Bool? { + guard MySQLClientArguments.needsServerGreeting(request.connection, flavor: resolved.flavor) else { return nil } + switch await MySQLServerGreeting.read(host: request.host, port: request.connection.port) { + case .offersTLS(let offered): + return offered + case .serverError(let message): + throw NativeDumpError.unreachableServer(message: message) + case .unreadable: + throw NativeDumpError.unreachableServer( + message: String( + format: String( + localized: "TablePro could not reach %@ to see whether it offers TLS. Check that the server is running and reachable, then try again." + ), + "\(request.host):\(request.connection.port)" + ) + ) + } + } + private static func mysqlPluginFolder( _ request: NativeDumpDescriptor.Request, _ resolved: NativeDumpResolvedTool diff --git a/apple/TablePro/Core/Database/NativeDumpService.swift b/apple/TablePro/Core/Database/NativeDumpService.swift index 6ae7470c7b..69763c9d75 100644 --- a/apple/TablePro/Core/Database/NativeDumpService.swift +++ b/apple/TablePro/Core/Database/NativeDumpService.swift @@ -42,6 +42,7 @@ enum NativeDumpError: LocalizedError, Equatable { case sourceUnreadable case engineStatementUnavailable case incompatibleTool(message: String) + case unreachableServer(message: String) var errorDescription: String? { switch self { @@ -61,7 +62,7 @@ enum NativeDumpError: LocalizedError, Equatable { return String(localized: "The selected backup file is not readable.") case .engineStatementUnavailable: return String(localized: "TablePro could not read the database's own name from the connection.") - case .incompatibleTool(let message): + case .incompatibleTool(let message), .unreachableServer(let message): return message } } @@ -309,7 +310,7 @@ final class NativeDumpService: ObservableObject { serverVersion: serverVersion ) toolName = resolved.name - request.serverOffersTLS = await tool.serverOffersTLS(request, resolved) + request.serverOffersTLS = try await tool.serverOffersTLS(request, resolved) let command = try Self.buildCommand( kind: kind, tool: tool, diff --git a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift index dc2ec26349..943067a0ea 100644 --- a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift +++ b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift @@ -159,39 +159,66 @@ struct MySQLDumpPlaintextPasswordTests { password: nil ) let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: "/t", flavor: flavor) - #expect(await tool.serverOffersTLS(request, resolved) == nil, "\(mode.rawValue) \(flavor)") + #expect(try await tool.serverOffersTLS(request, resolved) == nil, "\(mode.rawValue) \(flavor)") } } - @Test("A server that cannot be read counts as one without TLS") - func unreadableServerCountsAsNoTLS() async throws { - let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) - var closed = connection(.preferred) - closed.host = "127.0.0.1" - closed.port = try #require(LoopbackPort.allocateFree()) - let request = NativeDumpDescriptor.Request( - connection: closed, + private func preferredRequest(port: Int) -> NativeDumpDescriptor.Request { + var preferred = connection(.preferred) + preferred.host = "127.0.0.1" + preferred.port = port + return NativeDumpDescriptor.Request( + connection: preferred, database: "sales", fileURL: URL(fileURLWithPath: "/tmp/out.sql"), password: nil ) - let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: "/t", flavor: .mariadb) - #expect(await tool.serverOffersTLS(request, resolved) == false) } - @Test("The greeting's capability flags say whether the server offers TLS", arguments: [true, false]) - func greetingFromAServer(offersTLS: Bool) async throws { + @Test("Preferred takes the server's own answer from its greeting", arguments: [true, false]) + func preferredReadsTheGreeting(offersTLS: Bool) async throws { let server = try await FakeGreetingServer.start(payload: Self.greeting(offersTLS: offersTLS)) defer { server.stop() } - #expect(await MySQLServerGreeting.offersTLS(host: "127.0.0.1", port: server.port) == offersTLS) + let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) + let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: "/t", flavor: .mariadb) + #expect(try await tool.serverOffersTLS(preferredRequest(port: server.port), resolved) == offersTLS) + #expect(await MySQLServerGreeting.read(host: "127.0.0.1", port: server.port) == .offersTLS(offersTLS)) } - @Test("An error packet or a short greeting tells nothing") - func unreadableGreetings() { - #expect(MySQLServerGreeting.offersTLS(greeting: [0xFF, 0x69, 0x04] + Array("Host blocked".utf8)) == nil) - #expect(MySQLServerGreeting.offersTLS(greeting: [10] + Array("8.0.36".utf8)) == nil) - #expect(MySQLServerGreeting.offersTLS(greeting: Array(Self.greeting(offersTLS: true).prefix(20))) == nil) - #expect(MySQLServerGreeting.offersTLS(greeting: []) == nil) + // Guessing no TLS would dump in plaintext from a server that has it; guessing TLS brings back the fallback. + @Test("A server that cannot be read fails the run instead of a guess") + func unreadableServerFailsTheRun() async throws { + let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) + let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: "/t", flavor: .mariadb) + let closed = try #require(LoopbackPort.allocateFree()) + await #expect(throws: NativeDumpError.self) { + try await tool.serverOffersTLS(preferredRequest(port: closed), resolved) + } + + let busy = [0xFF, 0x10, 0x04] + Array("Too many connections".utf8) + let server = try await FakeGreetingServer.start(payload: busy) + defer { server.stop() } + await #expect(throws: NativeDumpError.unreachableServer(message: "Too many connections")) { + try await tool.serverOffersTLS(preferredRequest(port: server.port), resolved) + } + } + + @Test("An error packet carries the server's words, and a short greeting tells nothing") + func greetingParsing() { + #expect(MySQLServerGreeting.answer(greeting: Self.greeting(offersTLS: true)) == .offersTLS(true)) + #expect(MySQLServerGreeting.answer(greeting: Self.greeting(offersTLS: false)) == .offersTLS(false)) + #expect( + MySQLServerGreeting.answer(greeting: [0xFF, 0x69, 0x04] + Array("Host 'x' is blocked".utf8)) + == .serverError("Host 'x' is blocked") + ) + #expect( + MySQLServerGreeting.answer(greeting: [0xFF, 0x10, 0x04] + Array("#08004Too many connections".utf8)) + == .serverError("Too many connections") + ) + #expect(MySQLServerGreeting.answer(greeting: [0xFF, 0x10, 0x04]) == .unreadable) + #expect(MySQLServerGreeting.answer(greeting: [10] + Array("8.0.36".utf8)) == .unreadable) + #expect(MySQLServerGreeting.answer(greeting: Array(Self.greeting(offersTLS: true).prefix(20))) == .unreadable) + #expect(MySQLServerGreeting.answer(greeting: []) == .unreadable) } @Test("The plugin folder is found beside a Homebrew tool, through its link") From 4bcd2d2c50395f159a7b2ad8abb152a1d263934f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 13:07:10 +0700 Subject: [PATCH 05/12] fix(mysql): keep the refusal pattern local, it is not Sendable --- .../Database/MariaDBClientPluginFolder.swift | 4 +-- .../Core/Database/MySQLClientArguments.swift | 3 ++ .../MySQLDumpPlaintextPasswordTests.swift | 30 +++++++++---------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift b/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift index a317e0a994..788aa744b3 100644 --- a/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift +++ b/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift @@ -26,8 +26,6 @@ struct MariaDBClientPluginFolder: Sendable, Equatable { // Homebrew uses the first. private static let layouts = ["lib/plugin", "lib/mysql/plugin"] - private static let refusedPluginPattern = #/Plugin ([A-Za-z0-9_]+) could not be loaded/# - let url: URL static func pluginDirectory(forTool path: String) -> URL? { @@ -60,7 +58,7 @@ struct MariaDBClientPluginFolder: Sendable, Equatable { // A plugin left out of the folder is one that sends the password as typed. One that was allowed but could not be // loaded keeps the tool's own words. func refusal(in stderr: String) -> String? { - let refused = stderr.matches(of: Self.refusedPluginPattern).map { String($0.output.1) } + let refused = stderr.matches(of: #/Plugin ([A-Za-z0-9_]+) could not be loaded/#).map { String($0.output.1) } guard refused.contains(where: { !Self.allowedPlugins.contains($0) }) else { return nil } return MySQLClientArguments.plaintextPasswordRefusal } diff --git a/apple/TablePro/Core/Database/MySQLClientArguments.swift b/apple/TablePro/Core/Database/MySQLClientArguments.swift index f3d55556a1..185ca3aadd 100644 --- a/apple/TablePro/Core/Database/MySQLClientArguments.swift +++ b/apple/TablePro/Core/Database/MySQLClientArguments.swift @@ -57,6 +57,9 @@ enum MySQLClientArguments { guard flavor == .mariadb, reachesServerOverNetwork(connection) else { return false } switch connection.transportSSLConfiguration.mode { case .disabled: return true + // The greeting comes from a connection of its own. Someone on the path who answers the two differently can as + // well end the unverified TLS Preferred accepts, in the app's driver too; keeping the password from them is + // what Required is for. case .preferred: return serverOffersTLS != true case .required, .verifyCa, .verifyIdentity: return false } diff --git a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift index 943067a0ea..409cbfaed8 100644 --- a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift +++ b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift @@ -320,27 +320,27 @@ struct MySQLDumpPlaintextPasswordTests { } } -@MainActor -struct MySQLDumpPlaintextPasswordServiceTests { - private final class FinishingRunner: NativeDumpRunner, @unchecked Sendable { - private let outcome: NativeDumpRunResult - private(set) var started = false +private final class FinishingRunner: NativeDumpRunner, @unchecked Sendable { + private let outcome: NativeDumpRunResult + private(set) var started = false - init(_ outcome: NativeDumpRunResult) { - self.outcome = outcome - } + init(_ outcome: NativeDumpRunResult) { + self.outcome = outcome + } - func start() throws { - started = true - } + func start() throws { + started = true + } - func cancel() {} + func cancel() {} - var result: NativeDumpRunResult { - get async { outcome } - } + var result: NativeDumpRunResult { + get async { outcome } } +} +@MainActor +struct MySQLDumpPlaintextPasswordServiceTests { private func job(folder: URL) -> NativeDumpJob { .process( NativeDumpCommand( From 0a17c40f4b72990c6ab7af5757710ab91b57ae74 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 13:23:58 +0700 Subject: [PATCH 06/12] fix(mysql): qualify SSLMode in the test arguments and add the catalog key --- apple/TablePro/Resources/Localizable.xcstrings | 3 +++ .../Database/MySQLDumpPlaintextPasswordTests.swift | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index d1d10ab57d..c9af0cbc08 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -176890,6 +176890,9 @@ } } } + }, + "TablePro could not reach %@ to see whether it offers TLS. Check that the server is running and reachable, then try again." : { + }, "TablePro could not reach the SAP HANA server within %lld seconds." : { "extractionState" : "manual", diff --git a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift index 409cbfaed8..010a9c6092 100644 --- a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift +++ b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift @@ -108,8 +108,11 @@ struct MySQLDumpPlaintextPasswordTests { #expect(!built.arguments.contains { $0.hasPrefix("--ssl") }) } - @Test("Modes that require TLS keep every plugin", arguments: [SSLMode.required, .verifyCa, .verifyIdentity]) - func requiredKeepsPlugins(mode: SSLMode) throws { + @Test( + "Modes that require TLS keep every plugin", + arguments: [TableProPluginKit.SSLMode.required, .verifyCa, .verifyIdentity] + ) + func requiredKeepsPlugins(mode: TableProPluginKit.SSLMode) throws { let built = try command(connection(mode)) #expect(built.clientPluginFolder == nil) #expect(pluginDirectoryArgument(built) == nil) From a75f3bf51c29298ad9acf4f481e65207acb6540e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 13:42:07 +0700 Subject: [PATCH 07/12] fix(mysql): refuse a client with a plain-text plugin built in, and find MacPorts' plugin folder --- .../MariaDBBuiltInPlaintextCheck.swift | 178 ++++++++++++++++++ .../Database/MariaDBClientPluginFolder.swift | 6 +- .../Core/Database/NativeDumpDescriptor.swift | 8 +- .../Core/Database/NativeDumpRegistry.swift | 31 ++- .../Core/Database/NativeDumpService.swift | 2 +- .../MySQLDumpPlaintextPasswordTests.swift | 86 ++++++++- 6 files changed, 297 insertions(+), 14 deletions(-) create mode 100644 apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift diff --git a/apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift b/apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift new file mode 100644 index 0000000000..6e038896c8 --- /dev/null +++ b/apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift @@ -0,0 +1,178 @@ +// +// MariaDBBuiltInPlaintextCheck.swift +// TablePro +// + +import Darwin +import Foundation +import os +import TableProUtilities + +// A MariaDB client built with mysql_clear_password or dialog inside it finds them before it looks at the plugin folder, +// so the private folder cannot hold them back. Asking the tool itself, with an empty folder, against a loopback server +// that wants each of them in turn holds for every build. Homebrew's tools load both from the folder and pass. +enum MariaDBBuiltInPlaintextCheck { + private static let logger = Logger(subsystem: "com.TablePro", category: "MariaDBBuiltInPlaintextCheck") + private static let plugins = ["mysql_clear_password", "dialog"] + private static let verdicts = OSAllocatedUnfairLock<[String: Bool]>(initialState: [:]) + private static let timeout: Int32 = 10 + + // True when the tool sent the password to a server that asked for it in plain text. A tool that cannot be run here + // cannot run the dump either, which reports that instead. + static func sendsPlaintext(toolPath: String) async -> Bool { + let key = cacheKey(toolPath) + if let key, let known = verdicts.withLock({ $0[key] }) { + return known + } + let sends = await Task.detached(priority: .userInitiated) { + Self.plugins.contains { Self.sendsPassword(toolPath: toolPath, plugin: $0) } + }.value + if let key { + verdicts.withLock { $0[key] = sends } + } + return sends + } + + private static func cacheKey(_ toolPath: String) -> String? { + let resolved = URL(fileURLWithPath: toolPath).resolvingSymlinksInPath().path + guard let attributes = try? FileManager.default.attributesOfItem(atPath: resolved), + let modified = attributes[.modificationDate] as? Date, + let size = attributes[.size] as? NSNumber + else { return nil } + return "\(resolved)|\(modified.timeIntervalSince1970)|\(size)" + } + + private static func sendsPassword(toolPath: String, plugin: String) -> Bool { + let manager = FileManager.default + let folder = manager.temporaryDirectory.appendingPathComponent("tablepro-plugin-check-\(UUID().uuidString)") + guard (try? manager.createDirectory(at: folder, withIntermediateDirectories: false)) != nil else { return false } + defer { try? manager.removeItem(at: folder) } + guard let listener = PluginCheckListener() else { return false } + defer { close(listener.descriptor) } + + let token = "tablepro-check-\(UUID().uuidString)" + let process = Process() + process.executableURL = URL(fileURLWithPath: toolPath) + process.arguments = [ + "--no-defaults", "--protocol=TCP", "-h", "127.0.0.1", "-P", String(listener.port), "-u", "tablepro", + "--skip-ssl", "--plugin-dir=\(folder.path)", "--", "tablepro" + ] + process.environment = ["MYSQL_PWD": token] + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + logger.notice("Could not run the client to check its plugins: \(error.publicLogShape, privacy: .public)") + return false + } + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + + guard let client = listener.accept(within: timeout) else { return false } + defer { close(client) } + var wait = timeval(tv_sec: Int(timeout), tv_usec: 0) + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &wait, socklen_t(MemoryLayout.size)) + var one: Int32 = 1 + setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + + guard send(packet: greeting, sequence: 0, to: client), + let login = receivePacket(from: client) + else { return false } + var request = [0xFE] + Array(plugin.utf8) + [0] + if plugin == "dialog" { + request += [0x05] + Array("Password: ".utf8) + } + guard send(packet: request, sequence: login.sequence &+ 1, to: client), + let answer = receivePacket(from: client) + else { return false } + let sent = contains(answer.payload, Array(token.utf8)) + if sent { + logger.error("\(toolPath, privacy: .private(mask: .hash)) sends the password for \(plugin, privacy: .public) without TLS") + } + return sent + } + + private static let greeting: [UInt8] = { + let capabilities: UInt32 = 0x1 | 0x8 | 0x200 | 0x2000 | 0x8000 | 0x20000 | 0x80000 + var payload: [UInt8] = [10] + Array("8.0.36".utf8) + [0] + payload += [1, 0, 0, 0] + Array("abcdefgh".utf8) + [0] + payload += [UInt8(capabilities & 0xFF), UInt8((capabilities >> 8) & 0xFF), 45, 2, 0] + payload += [UInt8((capabilities >> 16) & 0xFF), UInt8((capabilities >> 24) & 0xFF), 21] + payload += [UInt8](repeating: 0, count: 10) + payload += Array("ijklmnopqrst".utf8) + [0] + Array("mysql_native_password".utf8) + [0] + return payload + }() + + private static func send(packet payload: [UInt8], sequence: UInt8, to descriptor: Int32) -> Bool { + let length = payload.count + let bytes = [UInt8(length & 0xFF), UInt8((length >> 8) & 0xFF), UInt8((length >> 16) & 0xFF), sequence] + payload + return bytes.withUnsafeBytes { buffer in + Darwin.send(descriptor, buffer.baseAddress, buffer.count, 0) == buffer.count + } + } + + private static func receivePacket(from descriptor: Int32) -> (sequence: UInt8, payload: [UInt8])? { + guard let header = receive(4, from: descriptor) else { return nil } + let length = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16 + guard length > 0 else { return (header[3], []) } + guard let payload = receive(length, from: descriptor) else { return nil } + return (header[3], payload) + } + + private static func receive(_ count: Int, from descriptor: Int32) -> [UInt8]? { + var bytes = [UInt8](repeating: 0, count: count) + var filled = 0 + while filled < count { + let read = bytes.withUnsafeMutableBytes { buffer in + recv(descriptor, buffer.baseAddress?.advanced(by: filled), count - filled, 0) + } + guard read > 0 else { return nil } + filled += read + } + return bytes + } + + private static func contains(_ haystack: [UInt8], _ needle: [UInt8]) -> Bool { + guard !needle.isEmpty, haystack.count >= needle.count else { return false } + return (0...(haystack.count - needle.count)).contains { start in + haystack[start..<(start + needle.count)].elementsEqual(needle) + } + } +} + +private struct PluginCheckListener { + let descriptor: Int32 + let port: Int + + init?() { + let descriptor = socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { return nil } + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_port = 0 + address.sin_addr.s_addr = inet_addr("127.0.0.1") + var length = socklen_t(MemoryLayout.size) + let bound = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(descriptor, $0, length) == 0 && listen(descriptor, 1) == 0 && getsockname(descriptor, $0, &length) == 0 + } + } + guard bound else { + close(descriptor) + return nil + } + self.descriptor = descriptor + port = Int(UInt16(bigEndian: address.sin_port)) + } + + func accept(within seconds: Int32) -> Int32? { + var poller = pollfd(fd: descriptor, events: Int16(POLLIN), revents: 0) + guard poll(&poller, 1, seconds * 1_000) == 1 else { return nil } + let client = Darwin.accept(descriptor, nil, nil) + return client >= 0 ? client : nil + } +} diff --git a/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift b/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift index 788aa744b3..018cad23e1 100644 --- a/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift +++ b/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift @@ -22,9 +22,9 @@ struct MariaDBClientPluginFolder: Sendable, Equatable { "zstd" ] - // The two install layouts MariaDB's build defines for this platform, relative to the prefix that holds bin. - // Homebrew uses the first. - private static let layouts = ["lib/plugin", "lib/mysql/plugin"] + // Relative to the folder that holds bin: MariaDB's own layouts (Homebrew uses the first), then MacPorts', which + // keeps bin and plugin side by side under lib/mariadb-. + private static let layouts = ["lib/plugin", "lib/mysql/plugin", "plugin"] let url: URL diff --git a/apple/TablePro/Core/Database/NativeDumpDescriptor.swift b/apple/TablePro/Core/Database/NativeDumpDescriptor.swift index 1158cad227..8a9d301550 100644 --- a/apple/TablePro/Core/Database/NativeDumpDescriptor.swift +++ b/apple/TablePro/Core/Database/NativeDumpDescriptor.swift @@ -156,8 +156,8 @@ struct NativeDumpDescriptor: Sendable { let restoreArguments: @Sendable (Request, NativeDumpResolvedTool) throws -> [String] let environment: @Sendable (Request) -> [String: String] - // Asked before the arguments are built; the answer lands in `Request.serverOffersTLS`. - let serverOffersTLS: @Sendable (Request, NativeDumpResolvedTool) async throws -> Bool? + // Runs once the tool is resolved, before the arguments are built, for checks that need the network or the tool. + let prepare: @Sendable (Request, NativeDumpResolvedTool) async throws -> Request let clientPluginFolder: @Sendable (Request, NativeDumpResolvedTool) throws -> MariaDBClientPluginFolder? @@ -176,7 +176,7 @@ struct NativeDumpDescriptor: Sendable { restoreArguments: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> [String], environment: @escaping @Sendable (Request) -> [String: String] = { _ in [:] }, configurationFileEntries: @escaping @Sendable (Request) -> [ConfigurationEntry] = { _ in [] }, - serverOffersTLS: @escaping @Sendable (Request, NativeDumpResolvedTool) async throws -> Bool? = { _, _ in nil }, + prepare: @escaping @Sendable (Request, NativeDumpResolvedTool) async throws -> Request = { request, _ in request }, clientPluginFolder: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> MariaDBClientPluginFolder? = { _, _ in nil } ) { self.backupBinaries = backupBinaries @@ -193,7 +193,7 @@ struct NativeDumpDescriptor: Sendable { self.restoreArguments = restoreArguments self.environment = environment self.configurationFileEntries = configurationFileEntries - self.serverOffersTLS = serverOffersTLS + self.prepare = prepare self.clientPluginFolder = clientPluginFolder } diff --git a/apple/TablePro/Core/Database/NativeDumpRegistry.swift b/apple/TablePro/Core/Database/NativeDumpRegistry.swift index e32474fa56..1e1c1e808b 100644 --- a/apple/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/apple/TablePro/Core/Database/NativeDumpRegistry.swift @@ -218,8 +218,8 @@ enum NativeDumpRegistry { guard let password = request.password, !password.isEmpty else { return [:] } return ["MYSQL_PWD": password] }, - serverOffersTLS: { request, resolved in - try await mysqlServerOffersTLS(request, resolved) + prepare: { request, resolved in + try await mysqlPrepare(request, resolved) }, clientPluginFolder: { request, resolved in try mysqlPluginFolder(request, resolved) @@ -251,6 +251,33 @@ enum NativeDumpRegistry { ["--", request.database] + request.scope.objects.map(\.name) } + private static func mysqlPrepare( + _ request: NativeDumpDescriptor.Request, + _ resolved: NativeDumpResolvedTool + ) async throws -> NativeDumpDescriptor.Request { + var request = request + request.serverOffersTLS = try await mysqlServerOffersTLS(request, resolved) + let hidesPlugins = MySQLClientArguments.hidesPlaintextPasswordPlugins( + request.connection, + flavor: resolved.flavor, + serverOffersTLS: request.serverOffersTLS + ) + if hidesPlugins, await MariaDBBuiltInPlaintextCheck.sendsPlaintext(toolPath: resolved.path) { + throw NativeDumpError.incompatibleTool( + message: String( + format: String( + localized: """ + %@ sends the password in plain text when a server asks, even without TLS, and TablePro \ + cannot turn that off. Set SSL Mode to Required, or install another build of the client tools. + """ + ), + resolved.path + ) + ) + } + return request + } + // Unreadable fails the run: guessing no TLS would dump in plaintext from a server that has it, and guessing TLS // brings back the fallback that sends the typed password. private static func mysqlServerOffersTLS( diff --git a/apple/TablePro/Core/Database/NativeDumpService.swift b/apple/TablePro/Core/Database/NativeDumpService.swift index 69763c9d75..1916dca896 100644 --- a/apple/TablePro/Core/Database/NativeDumpService.swift +++ b/apple/TablePro/Core/Database/NativeDumpService.swift @@ -310,7 +310,7 @@ final class NativeDumpService: ObservableObject { serverVersion: serverVersion ) toolName = resolved.name - request.serverOffersTLS = try await tool.serverOffersTLS(request, resolved) + request = try await tool.prepare(request, resolved) let command = try Self.buildCommand( kind: kind, tool: tool, diff --git a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift index 010a9c6092..13920cad17 100644 --- a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift +++ b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift @@ -162,7 +162,7 @@ struct MySQLDumpPlaintextPasswordTests { password: nil ) let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: "/t", flavor: flavor) - #expect(try await tool.serverOffersTLS(request, resolved) == nil, "\(mode.rawValue) \(flavor)") + #expect(try await tool.prepare(request, resolved).serverOffersTLS == nil, "\(mode.rawValue) \(flavor)") } } @@ -184,7 +184,7 @@ struct MySQLDumpPlaintextPasswordTests { defer { server.stop() } let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: "/t", flavor: .mariadb) - #expect(try await tool.serverOffersTLS(preferredRequest(port: server.port), resolved) == offersTLS) + #expect(try await tool.prepare(preferredRequest(port: server.port), resolved).serverOffersTLS == offersTLS) #expect(await MySQLServerGreeting.read(host: "127.0.0.1", port: server.port) == .offersTLS(offersTLS)) } @@ -195,14 +195,14 @@ struct MySQLDumpPlaintextPasswordTests { let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: "/t", flavor: .mariadb) let closed = try #require(LoopbackPort.allocateFree()) await #expect(throws: NativeDumpError.self) { - try await tool.serverOffersTLS(preferredRequest(port: closed), resolved) + try await tool.prepare(preferredRequest(port: closed), resolved) } let busy = [0xFF, 0x10, 0x04] + Array("Too many connections".utf8) let server = try await FakeGreetingServer.start(payload: busy) defer { server.stop() } await #expect(throws: NativeDumpError.unreachableServer(message: "Too many connections")) { - try await tool.serverOffersTLS(preferredRequest(port: server.port), resolved) + try await tool.prepare(preferredRequest(port: server.port), resolved) } } @@ -243,6 +243,20 @@ struct MySQLDumpPlaintextPasswordTests { #expect(MariaDBClientPluginFolder.pluginDirectory(forTool: root.appendingPathComponent("nowhere/x").path) == nil) } + @Test("MacPorts keeps the plugin folder beside bin") + func pluginDirectoryInMacPortsLayout() throws { + let root = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let version = root.appendingPathComponent("lib/mariadb-11.4") + try FileManager.default.createDirectory(at: version.appendingPathComponent("bin"), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: version.appendingPathComponent("plugin"), withIntermediateDirectories: true) + let binary = version.appendingPathComponent("bin/mariadb-dump") + #expect(FileManager.default.createFile(atPath: binary.path, contents: nil)) + + let found = try #require(MariaDBClientPluginFolder.pluginDirectory(forTool: binary.path)) + #expect(found.resolvingSymlinksInPath().path == version.appendingPathComponent("plugin").resolvingSymlinksInPath().path) + } + @Test("The private folder links only the plugins that never send the password as typed") func privateFolderLinksOnlyAllowedPlugins() throws { let source = try temporaryDirectory() @@ -281,6 +295,70 @@ struct MySQLDumpPlaintextPasswordTests { #expect(folder.refusal(in: "ERROR 1045 (28000): Access denied for user 'alice'") == nil) } + // Stand-ins for a client build: one that answers a plain-text request from a plugin built into it, and one that + // hangs up because the plugin is not in the folder. + private func fakeClient(sendsPassword: Bool) throws -> URL { + let script = """ + #!/usr/bin/python3 + import os, socket, struct, sys + args = sys.argv[1:] + connection = socket.create_connection(("127.0.0.1", int(args[args.index("-P") + 1]))) + def read(): + header = b"" + while len(header) < 4: + header += connection.recv(4 - len(header)) + length = header[0] | header[1] << 8 | header[2] << 16 + payload = b"" + while len(payload) < length: + payload += connection.recv(length - len(payload)) + return header[3], payload + def write(sequence, payload): + connection.sendall(struct.pack(" Date: Wed, 21 Oct 2026 13:56:01 +0700 Subject: [PATCH 08/12] fix(mysql): fail closed when the built-in plugin check gets no answer --- .../MariaDBBuiltInPlaintextCheck.swift | 96 ++++++++++++------- .../Core/Database/NativeDumpRegistry.swift | 18 +++- .../MySQLDumpPlaintextPasswordTests.swift | 80 +++++++++++----- 3 files changed, 136 insertions(+), 58 deletions(-) diff --git a/apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift b/apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift index 6e038896c8..f0d7b24b21 100644 --- a/apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift +++ b/apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift @@ -12,25 +12,38 @@ import TableProUtilities // so the private folder cannot hold them back. Asking the tool itself, with an empty folder, against a loopback server // that wants each of them in turn holds for every build. Homebrew's tools load both from the folder and pass. enum MariaDBBuiltInPlaintextCheck { + enum Verdict: Equatable, Sendable { + case refusesPlaintext + case sendsPlaintext + // The tool never showed either: it did not run, connect or answer in time. + case unknown + } + private static let logger = Logger(subsystem: "com.TablePro", category: "MariaDBBuiltInPlaintextCheck") private static let plugins = ["mysql_clear_password", "dialog"] - private static let verdicts = OSAllocatedUnfairLock<[String: Bool]>(initialState: [:]) - private static let timeout: Int32 = 10 + private static let verdicts = OSAllocatedUnfairLock<[String: Verdict]>(initialState: [:]) - // True when the tool sent the password to a server that asked for it in plain text. A tool that cannot be run here - // cannot run the dump either, which reports that instead. - static func sendsPlaintext(toolPath: String) async -> Bool { + // Only an answer the tool gave is kept, so an unknown one is asked again next time. + static func verdict(toolPath: String, timeout: Int32 = 10) async -> Verdict { let key = cacheKey(toolPath) if let key, let known = verdicts.withLock({ $0[key] }) { return known } - let sends = await Task.detached(priority: .userInitiated) { - Self.plugins.contains { Self.sendsPassword(toolPath: toolPath, plugin: $0) } + let verdict = await Task.detached(priority: .userInitiated) { + var combined = Verdict.refusesPlaintext + for plugin in Self.plugins { + switch Self.ask(toolPath: toolPath, plugin: plugin, timeout: timeout) { + case .sendsPlaintext: return Verdict.sendsPlaintext + case .unknown: combined = .unknown + case .refusesPlaintext: break + } + } + return combined }.value - if let key { - verdicts.withLock { $0[key] = sends } + if let key, verdict != .unknown { + verdicts.withLock { $0[key] = verdict } } - return sends + return verdict } private static func cacheKey(_ toolPath: String) -> String? { @@ -42,12 +55,12 @@ enum MariaDBBuiltInPlaintextCheck { return "\(resolved)|\(modified.timeIntervalSince1970)|\(size)" } - private static func sendsPassword(toolPath: String, plugin: String) -> Bool { + private static func ask(toolPath: String, plugin: String, timeout: Int32) -> Verdict { let manager = FileManager.default let folder = manager.temporaryDirectory.appendingPathComponent("tablepro-plugin-check-\(UUID().uuidString)") - guard (try? manager.createDirectory(at: folder, withIntermediateDirectories: false)) != nil else { return false } + guard (try? manager.createDirectory(at: folder, withIntermediateDirectories: false)) != nil else { return .unknown } defer { try? manager.removeItem(at: folder) } - guard let listener = PluginCheckListener() else { return false } + guard let listener = PluginCheckListener() else { return .unknown } defer { close(listener.descriptor) } let token = "tablepro-check-\(UUID().uuidString)" @@ -65,14 +78,14 @@ enum MariaDBBuiltInPlaintextCheck { try process.run() } catch { logger.notice("Could not run the client to check its plugins: \(error.publicLogShape, privacy: .public)") - return false + return .unknown } defer { if process.isRunning { process.terminate() } process.waitUntilExit() } - guard let client = listener.accept(within: timeout) else { return false } + guard let client = listener.accept(within: timeout) else { return .unknown } defer { close(client) } var wait = timeval(tv_sec: Int(timeout), tv_usec: 0) setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &wait, socklen_t(MemoryLayout.size)) @@ -80,20 +93,22 @@ enum MariaDBBuiltInPlaintextCheck { setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) guard send(packet: greeting, sequence: 0, to: client), - let login = receivePacket(from: client) - else { return false } + case .packet(let login, _) = receivePacket(from: client) + else { return .unknown } var request = [0xFE] + Array(plugin.utf8) + [0] if plugin == "dialog" { request += [0x05] + Array("Password: ".utf8) } - guard send(packet: request, sequence: login.sequence &+ 1, to: client), - let answer = receivePacket(from: client) - else { return false } - let sent = contains(answer.payload, Array(token.utf8)) - if sent { + guard send(packet: request, sequence: login &+ 1, to: client) else { return .unknown } + switch receivePacket(from: client) { + case .packet(_, let answer) where contains(answer, Array(token.utf8)): logger.error("\(toolPath, privacy: .private(mask: .hash)) sends the password for \(plugin, privacy: .public) without TLS") + return .sendsPlaintext + case .packet, .closed: + return .refusesPlaintext + case .silent: + return .unknown } - return sent } private static let greeting: [UInt8] = { @@ -107,6 +122,12 @@ enum MariaDBBuiltInPlaintextCheck { return payload }() + private enum Received { + case packet(sequence: UInt8, payload: [UInt8]) + case closed + case silent + } + private static func send(packet payload: [UInt8], sequence: UInt8, to descriptor: Int32) -> Bool { let length = payload.count let bytes = [UInt8(length & 0xFF), UInt8((length >> 8) & 0xFF), UInt8((length >> 16) & 0xFF), sequence] + payload @@ -115,25 +136,36 @@ enum MariaDBBuiltInPlaintextCheck { } } - private static func receivePacket(from descriptor: Int32) -> (sequence: UInt8, payload: [UInt8])? { - guard let header = receive(4, from: descriptor) else { return nil } + private static func receivePacket(from descriptor: Int32) -> Received { + let header: [UInt8] + switch receive(4, from: descriptor) { + case .packet(_, let bytes): header = bytes + case let other: return other + } let length = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16 - guard length > 0 else { return (header[3], []) } - guard let payload = receive(length, from: descriptor) else { return nil } - return (header[3], payload) + guard length > 0 else { return .packet(sequence: header[3], payload: []) } + switch receive(length, from: descriptor) { + case .packet(_, let payload): return .packet(sequence: header[3], payload: payload) + case let other: return other + } } - private static func receive(_ count: Int, from descriptor: Int32) -> [UInt8]? { + private static func receive(_ count: Int, from descriptor: Int32) -> Received { var bytes = [UInt8](repeating: 0, count: count) var filled = 0 while filled < count { let read = bytes.withUnsafeMutableBytes { buffer in recv(descriptor, buffer.baseAddress?.advanced(by: filled), count - filled, 0) } - guard read > 0 else { return nil } - filled += read + if read > 0 { + filled += read + } else if read == 0 || (errno != EAGAIN && errno != EWOULDBLOCK) { + return .closed + } else { + return .silent + } } - return bytes + return .packet(sequence: 0, payload: bytes) } private static func contains(_ haystack: [UInt8], _ needle: [UInt8]) -> Bool { diff --git a/apple/TablePro/Core/Database/NativeDumpRegistry.swift b/apple/TablePro/Core/Database/NativeDumpRegistry.swift index 1e1c1e808b..66c6de3a34 100644 --- a/apple/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/apple/TablePro/Core/Database/NativeDumpRegistry.swift @@ -262,7 +262,11 @@ enum NativeDumpRegistry { flavor: resolved.flavor, serverOffersTLS: request.serverOffersTLS ) - if hidesPlugins, await MariaDBBuiltInPlaintextCheck.sendsPlaintext(toolPath: resolved.path) { + guard hidesPlugins else { return request } + switch await MariaDBBuiltInPlaintextCheck.verdict(toolPath: resolved.path) { + case .refusesPlaintext: + break + case .sendsPlaintext: throw NativeDumpError.incompatibleTool( message: String( format: String( @@ -274,6 +278,18 @@ enum NativeDumpRegistry { resolved.path ) ) + case .unknown: + throw NativeDumpError.incompatibleTool( + message: String( + format: String( + localized: """ + TablePro could not check whether %@ sends the password in plain text without TLS. Try \ + again, or set SSL Mode to Required. + """ + ), + resolved.path + ) + ) } return request } diff --git a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift index 13920cad17..a674037d9f 100644 --- a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift +++ b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift @@ -154,6 +154,8 @@ struct MySQLDumpPlaintextPasswordTests { @Test("Only a MariaDB tool on Preferred reads the server's greeting") func greetingIsReadOnlyForPreferred() async throws { let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) + let client = try fakeClient(sendsPassword: false) + defer { try? FileManager.default.removeItem(at: client.deletingLastPathComponent()) } for (mode, flavor) in [(SSLMode.disabled, NativeDumpToolFlavor.mariadb), (.required, .mariadb), (.preferred, .mysql)] { let request = NativeDumpDescriptor.Request( connection: connection(mode), @@ -161,7 +163,7 @@ struct MySQLDumpPlaintextPasswordTests { fileURL: URL(fileURLWithPath: "/tmp/out.sql"), password: nil ) - let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: "/t", flavor: flavor) + let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: client.path, flavor: flavor) #expect(try await tool.prepare(request, resolved).serverOffersTLS == nil, "\(mode.rawValue) \(flavor)") } } @@ -183,7 +185,9 @@ struct MySQLDumpPlaintextPasswordTests { let server = try await FakeGreetingServer.start(payload: Self.greeting(offersTLS: offersTLS)) defer { server.stop() } let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) - let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: "/t", flavor: .mariadb) + let client = try fakeClient(sendsPassword: false) + defer { try? FileManager.default.removeItem(at: client.deletingLastPathComponent()) } + let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: client.path, flavor: .mariadb) #expect(try await tool.prepare(preferredRequest(port: server.port), resolved).serverOffersTLS == offersTLS) #expect(await MySQLServerGreeting.read(host: "127.0.0.1", port: server.port) == .offersTLS(offersTLS)) } @@ -296,30 +300,27 @@ struct MySQLDumpPlaintextPasswordTests { } // Stand-ins for a client build: one that answers a plain-text request from a plugin built into it, and one that - // hangs up because the plugin is not in the folder. + // hangs up because the plugin is not in the folder. Bash, dd and od, because the check passes the tool no PATH. private func fakeClient(sendsPassword: Bool) throws -> URL { let script = """ - #!/usr/bin/python3 - import os, socket, struct, sys - args = sys.argv[1:] - connection = socket.create_connection(("127.0.0.1", int(args[args.index("-P") + 1]))) - def read(): - header = b"" - while len(header) < 4: - header += connection.recv(4 - len(header)) - length = header[0] | header[1] << 8 | header[2] << 16 - payload = b"" - while len(payload) < length: - payload += connection.recv(length - len(payload)) - return header[3], payload - def write(sequence, payload): - connection.sendall(struct.pack(""/dev/tcp/127.0.0.1/$port" + skip_packet() { + set -- $(/bin/dd bs=1 count=4 2> /dev/null <&3 | /usr/bin/od -An -tu1) + /bin/dd bs=1 count=$(($1 + ($2 << 8) + ($3 << 16))) 2> /dev/null <&3 > /dev/null + } + skip_packet + { printf '\\051\\000\\000\\001'; /bin/dd bs=32 count=1 if=/dev/zero 2> /dev/null; printf 'tablepro\\000'; } >&3 + skip_packet + if [ \(sendsPassword ? 1 : 0) = 1 ]; then + printf "\\\\$(printf '%03o' $((${#MYSQL_PWD} + 1)))\\\\000\\\\000\\\\003" >&3 + printf '%s\\000' "$MYSQL_PWD" >&3 + fi + exec 3>&- """ let url = try temporaryDirectory().appendingPathComponent("mariadb-dump") try script.write(to: url, atomically: true, encoding: .utf8) @@ -331,7 +332,32 @@ struct MySQLDumpPlaintextPasswordTests { func builtInPlaintextPluginIsCaught(sendsPassword: Bool) async throws { let client = try fakeClient(sendsPassword: sendsPassword) defer { try? FileManager.default.removeItem(at: client.deletingLastPathComponent()) } - #expect(await MariaDBBuiltInPlaintextCheck.sendsPlaintext(toolPath: client.path) == sendsPassword) + #expect( + await MariaDBBuiltInPlaintextCheck.verdict(toolPath: client.path) + == (sendsPassword ? .sendsPlaintext : .refusesPlaintext) + ) + } + + @Test("A client that never answers is unknown, and asked again next time") + func silentClientIsAskedAgain() async throws { + let refusing = try fakeClient(sendsPassword: false) + defer { try? FileManager.default.removeItem(at: refusing.deletingLastPathComponent()) } + let folder = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: folder) } + let ready = folder.appendingPathComponent("ready") + let client = folder.appendingPathComponent("mariadb") + let script = """ + #!/bin/sh + [ -e '\(ready.path)' ] || exit 0 + exec '\(refusing.path)' "$@" + """ + try script.write(to: client, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: client.path) + + #expect(await MariaDBBuiltInPlaintextCheck.verdict(toolPath: client.path, timeout: 1) == .unknown) + #expect(FileManager.default.createFile(atPath: ready.path, contents: nil)) + #expect(await MariaDBBuiltInPlaintextCheck.verdict(toolPath: client.path, timeout: 5) == .refusesPlaintext) + #expect(await MariaDBBuiltInPlaintextCheck.verdict(toolPath: "/nonexistent/bin/mariadb-dump") == .unknown) } @Test("A dump that would hide the plugins refuses a client that has them built in") @@ -349,6 +375,10 @@ struct MySQLDumpPlaintextPasswordTests { await #expect(throws: NativeDumpError.self) { try await tool.prepare(request, resolved) } + let missing = NativeDumpResolvedTool(name: "mariadb-dump", path: "/nonexistent/bin/mariadb-dump", flavor: .mariadb) + await #expect(throws: NativeDumpError.self) { + try await tool.prepare(request, missing) + } let required = NativeDumpDescriptor.Request( connection: connection(.required), From 9ebfb4ddb453df99c4f94e70a170c8e0e43c6502 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 14:09:44 +0700 Subject: [PATCH 09/12] fix(mysql): ask the app's session whether the server offers TLS, not a handshake of its own --- .../Core/Database/MySQLClientArguments.swift | 12 +- .../Core/Database/MySQLServerGreeting.swift | 103 ----------- .../Core/Database/NativeDumpDescriptor.swift | 11 +- .../Core/Database/NativeDumpRegistry.swift | 37 ++-- .../Core/Database/NativeDumpService.swift | 14 +- .../TablePro/Resources/Localizable.xcstrings | 3 - .../Database/MySQLClientArgumentsTests.swift | 4 +- .../MySQLDumpPlaintextPasswordTests.swift | 164 +++++------------- .../check-mysql-dump-plaintext-password.sh | 13 +- 9 files changed, 93 insertions(+), 268 deletions(-) delete mode 100644 apple/TablePro/Core/Database/MySQLServerGreeting.swift diff --git a/apple/TablePro/Core/Database/MySQLClientArguments.swift b/apple/TablePro/Core/Database/MySQLClientArguments.swift index 185ca3aadd..5cd983f30b 100644 --- a/apple/TablePro/Core/Database/MySQLClientArguments.swift +++ b/apple/TablePro/Core/Database/MySQLClientArguments.swift @@ -33,8 +33,8 @@ enum MySQLClientArguments { /// /// MariaDB's `--ssl` without verification also sends the typed password when the server offers no /// TLS and caching_sha2_password asks for full authentication, so Preferred dials in plaintext - /// when the server's greeting offered no TLS. Measured with mariadb-dump 13.0.2: `--skip-ssl` - /// asks for the server's RSA key instead. Nil means the greeting was not read. + /// when the server offers no TLS. Measured with mariadb-dump 13.0.2: `--skip-ssl` asks for the + /// server's RSA key instead. Nil means the server was not asked. static func tls( _ ssl: SSLConfiguration, flavor: NativeDumpToolFlavor, @@ -57,15 +57,15 @@ enum MySQLClientArguments { guard flavor == .mariadb, reachesServerOverNetwork(connection) else { return false } switch connection.transportSSLConfiguration.mode { case .disabled: return true - // The greeting comes from a connection of its own. Someone on the path who answers the two differently can as - // well end the unverified TLS Preferred accepts, in the app's driver too; keeping the password from them is - // what Required is for. + // The answer comes from the app's own session, not the tool's connection. Someone on the path who answers the + // two differently can as well end the unverified TLS Preferred accepts, in the app's driver too; keeping the + // password from them is what Required is for. case .preferred: return serverOffersTLS != true case .required, .verifyCa, .verifyIdentity: return false } } - static func needsServerGreeting(_ connection: DatabaseConnection, flavor: NativeDumpToolFlavor) -> Bool { + static func needsServerTLSAnswer(_ connection: DatabaseConnection, flavor: NativeDumpToolFlavor) -> Bool { flavor == .mariadb && connection.transportSSLConfiguration.mode == .preferred && reachesServerOverNetwork(connection) diff --git a/apple/TablePro/Core/Database/MySQLServerGreeting.swift b/apple/TablePro/Core/Database/MySQLServerGreeting.swift deleted file mode 100644 index d990960163..0000000000 --- a/apple/TablePro/Core/Database/MySQLServerGreeting.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// MySQLServerGreeting.swift -// TablePro -// - -import Foundation -import Network -import os - -// MariaDB's client tools on Preferred fall back to plaintext when the server offers no TLS, and still send the typed -// password when caching_sha2_password asks for full authentication. Reading the server's greeting first, which carries -// no credentials, lets the dump pick TLS or plaintext up front instead. -enum MySQLServerGreeting { - enum Answer: Equatable, Sendable { - case offersTLS(Bool) - case serverError(String) - case unreadable - } - - private static let clientSSL: UInt16 = 0x0800 - private static let errorPacket: UInt8 = 0xFF - private static let queue = DispatchQueue(label: "com.TablePro.MySQLServerGreeting", qos: .userInitiated) - - static func read(host: String, port: Int, deadline: Duration = .seconds(10)) async -> Answer { - guard port > 0, let nwPort = NWEndpoint.Port(rawValue: UInt16(clamping: port)) else { return .unreadable } - let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp) - return await withCheckedContinuation { continuation in - let resumed = OSAllocatedUnfairLock(initialState: false) - let complete: @Sendable (Answer) -> Void = { value in - let shouldResume = resumed.withLock { done -> Bool in - guard !done else { return false } - done = true - return true - } - guard shouldResume else { return } - connection.cancel() - continuation.resume(returning: value) - } - connection.stateUpdateHandler = { state in - switch state { - case .ready: - readGreeting(from: connection, complete: complete) - case .failed, .cancelled, .waiting: - complete(.unreadable) - default: - break - } - } - connection.start(queue: queue) - let parts = deadline.components - let seconds = Double(parts.seconds) + Double(parts.attoseconds) / 1e18 - queue.asyncAfter(deadline: .now() + seconds) { complete(.unreadable) } - } - } - - static func answer(greeting payload: [UInt8]) -> Answer { - guard let first = payload.first else { return .unreadable } - if first == errorPacket { - return serverError(payload).map { .serverError($0) } ?? .unreadable - } - guard let versionEnd = payload.dropFirst().firstIndex(of: 0) else { return .unreadable } - // Thread id (4), the first part of the scramble (8) and a filler byte sit before the capability flags. - let flagsStart = versionEnd + 1 + 4 + 8 + 1 - guard payload.count >= flagsStart + 2 else { return .unreadable } - let flags = UInt16(payload[flagsStart]) | UInt16(payload[flagsStart + 1]) << 8 - return .offersTLS(flags & clientSSL != 0) - } - - // A server refusing the connection outright, such as "Too many connections", answers with an error packet in place - // of the greeting. Before the handshake it carries no SQL state marker. - private static func serverError(_ payload: [UInt8]) -> String? { - guard payload.count > 3 else { return nil } - var message = payload.dropFirst(3) - if message.first == UInt8(ascii: "#"), message.count >= 6 { - message = message.dropFirst(6) - } - let text = String(bytes: message, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) - return text?.isEmpty == false ? text : nil - } - - private static func readGreeting(from connection: NWConnection, complete: @escaping @Sendable (Answer) -> Void) { - connection.receive(minimumIncompleteLength: 4, maximumLength: 4) { header, _, _, error in - guard error == nil, let header, header.count == 4 else { - complete(.unreadable) - return - } - let length = Int(header[header.startIndex]) - | Int(header[header.startIndex + 1]) << 8 - | Int(header[header.startIndex + 2]) << 16 - guard length > 0 else { - complete(.unreadable) - return - } - connection.receive(minimumIncompleteLength: length, maximumLength: length) { payload, _, _, error in - guard error == nil, let payload, payload.count == length else { - complete(.unreadable) - return - } - complete(answer(greeting: Array(payload))) - } - } - } -} diff --git a/apple/TablePro/Core/Database/NativeDumpDescriptor.swift b/apple/TablePro/Core/Database/NativeDumpDescriptor.swift index 8a9d301550..7f71882cd6 100644 --- a/apple/TablePro/Core/Database/NativeDumpDescriptor.swift +++ b/apple/TablePro/Core/Database/NativeDumpDescriptor.swift @@ -77,7 +77,7 @@ struct NativeDumpDescriptor: Sendable { /// flags that server needs. `mysqldump` 8.0 reads a table no MariaDB server has. let serverVersion: String? - // Whether the server's greeting offered TLS, for a tool whose SSL options depend on it. Nil when not read. + // Whether the server offers TLS, for a tool whose SSL options depend on it. Nil when not asked. var serverOffersTLS: Bool? // Set by `NativeDumpService.buildCommand` before the arguments are built. @@ -115,6 +115,9 @@ struct NativeDumpDescriptor: Sendable { let value: String } + // A query on the app's own session to the server being dumped, answered as text. + typealias SessionQuery = @Sendable (String) async throws -> [[String?]] + /// A tool on the user's Mac that the app spawns. struct CommandLineTool: Sendable { /// The candidate names, in the order they are tried. More than one because a tool can ship @@ -156,8 +159,8 @@ struct NativeDumpDescriptor: Sendable { let restoreArguments: @Sendable (Request, NativeDumpResolvedTool) throws -> [String] let environment: @Sendable (Request) -> [String: String] - // Runs once the tool is resolved, before the arguments are built, for checks that need the network or the tool. - let prepare: @Sendable (Request, NativeDumpResolvedTool) async throws -> Request + // Runs once the tool is resolved, before the arguments are built, for checks that need the session or the tool. + let prepare: @Sendable (Request, NativeDumpResolvedTool, SessionQuery) async throws -> Request let clientPluginFolder: @Sendable (Request, NativeDumpResolvedTool) throws -> MariaDBClientPluginFolder? @@ -176,7 +179,7 @@ struct NativeDumpDescriptor: Sendable { restoreArguments: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> [String], environment: @escaping @Sendable (Request) -> [String: String] = { _ in [:] }, configurationFileEntries: @escaping @Sendable (Request) -> [ConfigurationEntry] = { _ in [] }, - prepare: @escaping @Sendable (Request, NativeDumpResolvedTool) async throws -> Request = { request, _ in request }, + prepare: @escaping @Sendable (Request, NativeDumpResolvedTool, SessionQuery) async throws -> Request = { request, _, _ in request }, clientPluginFolder: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> MariaDBClientPluginFolder? = { _, _ in nil } ) { self.backupBinaries = backupBinaries diff --git a/apple/TablePro/Core/Database/NativeDumpRegistry.swift b/apple/TablePro/Core/Database/NativeDumpRegistry.swift index 66c6de3a34..a5e6d2c560 100644 --- a/apple/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/apple/TablePro/Core/Database/NativeDumpRegistry.swift @@ -218,8 +218,8 @@ enum NativeDumpRegistry { guard let password = request.password, !password.isEmpty else { return [:] } return ["MYSQL_PWD": password] }, - prepare: { request, resolved in - try await mysqlPrepare(request, resolved) + prepare: { request, resolved, query in + try await mysqlPrepare(request, resolved, query) }, clientPluginFolder: { request, resolved in try mysqlPluginFolder(request, resolved) @@ -253,10 +253,13 @@ enum NativeDumpRegistry { private static func mysqlPrepare( _ request: NativeDumpDescriptor.Request, - _ resolved: NativeDumpResolvedTool + _ resolved: NativeDumpResolvedTool, + _ query: NativeDumpDescriptor.SessionQuery ) async throws -> NativeDumpDescriptor.Request { var request = request - request.serverOffersTLS = try await mysqlServerOffersTLS(request, resolved) + if MySQLClientArguments.needsServerTLSAnswer(request.connection, flavor: resolved.flavor) { + request.serverOffersTLS = try await mysqlServerOffersTLS(query) + } let hidesPlugins = MySQLClientArguments.hidesPlaintextPasswordPlugins( request.connection, flavor: resolved.flavor, @@ -294,28 +297,20 @@ enum NativeDumpRegistry { return request } - // Unreadable fails the run: guessing no TLS would dump in plaintext from a server that has it, and guessing TLS - // brings back the fallback that sends the typed password. - private static func mysqlServerOffersTLS( - _ request: NativeDumpDescriptor.Request, - _ resolved: NativeDumpResolvedTool - ) async throws -> Bool? { - guard MySQLClientArguments.needsServerGreeting(request.connection, flavor: resolved.flavor) else { return nil } - switch await MySQLServerGreeting.read(host: request.host, port: request.connection.port) { - case .offersTLS(let offered): - return offered - case .serverError(let message): - throw NativeDumpError.unreachableServer(message: message) - case .unreadable: + // The app's own session on Preferred takes TLS whenever the server offers it, so its cipher answers the question + // without a connection of TablePro's own that a server could count against max_connect_errors. No answer fails + // the run: guessing no TLS would dump in plaintext from a server that has it, and guessing TLS brings back the + // fallback that sends the typed password. + private static func mysqlServerOffersTLS(_ query: NativeDumpDescriptor.SessionQuery) async throws -> Bool { + let rows = try? await query("SHOW SESSION STATUS LIKE 'Ssl_cipher'") + guard let row = rows?.first, row.count >= 2 else { throw NativeDumpError.unreachableServer( message: String( - format: String( - localized: "TablePro could not reach %@ to see whether it offers TLS. Check that the server is running and reachable, then try again." - ), - "\(request.host):\(request.connection.port)" + localized: "TablePro could not ask the server whether it offers TLS. Reconnect, then try again." ) ) } + return !(row[1] ?? "").isEmpty } private static func mysqlPluginFolder( diff --git a/apple/TablePro/Core/Database/NativeDumpService.swift b/apple/TablePro/Core/Database/NativeDumpService.swift index 1916dca896..deee69b2a4 100644 --- a/apple/TablePro/Core/Database/NativeDumpService.swift +++ b/apple/TablePro/Core/Database/NativeDumpService.swift @@ -310,7 +310,10 @@ final class NativeDumpService: ObservableObject { serverVersion: serverVersion ) toolName = resolved.name - request = try await tool.prepare(request, resolved) + let connectionId = connection.id + request = try await tool.prepare(request, resolved) { sql in + try await Self.querySession(sql, connectionId: connectionId, database: database) + } let command = try Self.buildCommand( kind: kind, tool: tool, @@ -492,6 +495,15 @@ final class NativeDumpService: ObservableObject { } } + static func querySession(_ sql: String, connectionId: UUID, database: String) async throws -> [[String?]] { + guard let scope = DatabaseManager.shared.resolvedScope(database: database, schema: nil, for: connectionId) else { + throw DatabaseError.notConnected + } + return try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + try await driver.execute(query: sql).rows.map { row in row.map(\.asText) } + } + } + /// Pure so the choice is testable without an engine. The picked name wins when the engine says /// it exists, because a connection with two catalogs attached has to dump the one that was /// ticked rather than whichever the session is parked on. diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index c9af0cbc08..d1d10ab57d 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -176890,9 +176890,6 @@ } } } - }, - "TablePro could not reach %@ to see whether it offers TLS. Check that the server is running and reachable, then try again." : { - }, "TablePro could not reach the SAP HANA server within %lld seconds." : { "extractionState" : "manual", diff --git a/apple/TableProTests/Database/MySQLClientArgumentsTests.swift b/apple/TableProTests/Database/MySQLClientArgumentsTests.swift index 75db4b4f4a..09faa76825 100644 --- a/apple/TableProTests/Database/MySQLClientArgumentsTests.swift +++ b/apple/TableProTests/Database/MySQLClientArgumentsTests.swift @@ -73,8 +73,8 @@ struct MySQLClientArgumentsTests { // Measured with mariadb-dump 13.0.2: --ssl falls back to plaintext and answers caching_sha2_password's full // authentication with the typed password, where --skip-ssl asks for the server's RSA key. - @Test("MariaDB's Preferred follows the server's greeting, MySQL's does not need to") - func preferredFollowsTheGreeting() throws { + @Test("MariaDB's Preferred follows whether the server offers TLS, MySQL's does not need to") + func preferredFollowsTheServer() throws { #expect( try MySQLClientArguments.tls(ssl(.preferred), flavor: .mariadb, toolPath: "/t", serverOffersTLS: false) == ["--skip-ssl"] diff --git a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift index a674037d9f..2b6f54634b 100644 --- a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift +++ b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift @@ -4,8 +4,6 @@ // import Foundation -import Network -import os import TableProPluginKit import TableProSSHConfig import Testing @@ -132,12 +130,12 @@ struct MySQLDumpPlaintextPasswordTests { var socket = connection(.disabled) socket.additionalFields[MySQLLocalSocket.fieldKey] = "/tmp/mysql.sock" #expect(try command(socket).clientPluginFolder == nil) - #expect(!MySQLClientArguments.needsServerGreeting(socket, flavor: .mariadb)) + #expect(!MySQLClientArguments.needsServerTLSAnswer(socket, flavor: .mariadb)) var proxied = connection(.preferred) proxied.additionalFields[DatabaseConnection.tunnelSecuresTransportKey] = "true" #expect(try command(proxied).clientPluginFolder == nil) - #expect(!MySQLClientArguments.needsServerGreeting(proxied, flavor: .mariadb)) + #expect(!MySQLClientArguments.needsServerTLSAnswer(proxied, flavor: .mariadb)) } @Test("MySQL's own tools are left as they are") @@ -146,88 +144,70 @@ struct MySQLDumpPlaintextPasswordTests { let built = try command(connection(mode), flavor: .mysql, serverOffersTLS: false) #expect(built.clientPluginFolder == nil) #expect(pluginDirectoryArgument(built) == nil) - #expect(!MySQLClientArguments.needsServerGreeting(connection(mode), flavor: .mysql)) + #expect(!MySQLClientArguments.needsServerTLSAnswer(connection(mode), flavor: .mysql)) } #expect(try command(connection(.preferred), flavor: .mysql).arguments.contains("--ssl-mode=PREFERRED")) } - @Test("Only a MariaDB tool on Preferred reads the server's greeting") - func greetingIsReadOnlyForPreferred() async throws { - let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) - let client = try fakeClient(sendsPassword: false) - defer { try? FileManager.default.removeItem(at: client.deletingLastPathComponent()) } - for (mode, flavor) in [(SSLMode.disabled, NativeDumpToolFlavor.mariadb), (.required, .mariadb), (.preferred, .mysql)] { - let request = NativeDumpDescriptor.Request( - connection: connection(mode), - database: "sales", - fileURL: URL(fileURLWithPath: "/tmp/out.sql"), - password: nil - ) - let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: client.path, flavor: flavor) - #expect(try await tool.prepare(request, resolved).serverOffersTLS == nil, "\(mode.rawValue) \(flavor)") + private func answering(_ cipher: String?) -> NativeDumpDescriptor.SessionQuery { + { sql in + guard sql == "SHOW SESSION STATUS LIKE 'Ssl_cipher'", let cipher else { throw DatabaseError.notConnected } + return [["Ssl_cipher", cipher]] } } - private func preferredRequest(port: Int) -> NativeDumpDescriptor.Request { - var preferred = connection(.preferred) - preferred.host = "127.0.0.1" - preferred.port = port - return NativeDumpDescriptor.Request( - connection: preferred, + private func request(_ mode: SSLMode) -> NativeDumpDescriptor.Request { + NativeDumpDescriptor.Request( + connection: connection(mode), database: "sales", fileURL: URL(fileURLWithPath: "/tmp/out.sql"), password: nil ) } - @Test("Preferred takes the server's own answer from its greeting", arguments: [true, false]) - func preferredReadsTheGreeting(offersTLS: Bool) async throws { - let server = try await FakeGreetingServer.start(payload: Self.greeting(offersTLS: offersTLS)) - defer { server.stop() } + @Test("Only a MariaDB tool on Preferred asks the session whether the server offers TLS") + func sessionIsAskedOnlyForPreferred() async throws { + let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) + let client = try fakeClient(sendsPassword: false) + defer { try? FileManager.default.removeItem(at: client.deletingLastPathComponent()) } + let unanswered: NativeDumpDescriptor.SessionQuery = { _ in + Issue.record("the session should not be asked") + return [] + } + for (mode, flavor) in [(SSLMode.disabled, NativeDumpToolFlavor.mariadb), (.required, .mariadb), (.preferred, .mysql)] { + let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: client.path, flavor: flavor) + let prepared = try await tool.prepare(request(mode), resolved, unanswered) + #expect(prepared.serverOffersTLS == nil, "\(mode.rawValue) \(flavor)") + } + } + + // The app's own session on Preferred takes TLS whenever the server offers it. + @Test("Preferred takes the session's cipher as the server's answer") + func preferredReadsTheSessionCipher() async throws { let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) let client = try fakeClient(sendsPassword: false) defer { try? FileManager.default.removeItem(at: client.deletingLastPathComponent()) } let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: client.path, flavor: .mariadb) - #expect(try await tool.prepare(preferredRequest(port: server.port), resolved).serverOffersTLS == offersTLS) - #expect(await MySQLServerGreeting.read(host: "127.0.0.1", port: server.port) == .offersTLS(offersTLS)) + + let encrypted = try await tool.prepare(request(.preferred), resolved, answering("TLS_AES_256_GCM_SHA384")) + #expect(encrypted.serverOffersTLS == true) + let plain = try await tool.prepare(request(.preferred), resolved, answering("")) + #expect(plain.serverOffersTLS == false) } // Guessing no TLS would dump in plaintext from a server that has it; guessing TLS brings back the fallback. - @Test("A server that cannot be read fails the run instead of a guess") - func unreadableServerFailsTheRun() async throws { + @Test("A session that cannot answer fails the run instead of a guess") + func unansweredSessionFailsTheRun() async throws { let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: "/t", flavor: .mariadb) - let closed = try #require(LoopbackPort.allocateFree()) await #expect(throws: NativeDumpError.self) { - try await tool.prepare(preferredRequest(port: closed), resolved) + try await tool.prepare(request(.preferred), resolved, answering(nil)) } - - let busy = [0xFF, 0x10, 0x04] + Array("Too many connections".utf8) - let server = try await FakeGreetingServer.start(payload: busy) - defer { server.stop() } - await #expect(throws: NativeDumpError.unreachableServer(message: "Too many connections")) { - try await tool.prepare(preferredRequest(port: server.port), resolved) + await #expect(throws: NativeDumpError.self) { + try await tool.prepare(request(.preferred), resolved) { _ in [] } } } - @Test("An error packet carries the server's words, and a short greeting tells nothing") - func greetingParsing() { - #expect(MySQLServerGreeting.answer(greeting: Self.greeting(offersTLS: true)) == .offersTLS(true)) - #expect(MySQLServerGreeting.answer(greeting: Self.greeting(offersTLS: false)) == .offersTLS(false)) - #expect( - MySQLServerGreeting.answer(greeting: [0xFF, 0x69, 0x04] + Array("Host 'x' is blocked".utf8)) - == .serverError("Host 'x' is blocked") - ) - #expect( - MySQLServerGreeting.answer(greeting: [0xFF, 0x10, 0x04] + Array("#08004Too many connections".utf8)) - == .serverError("Too many connections") - ) - #expect(MySQLServerGreeting.answer(greeting: [0xFF, 0x10, 0x04]) == .unreadable) - #expect(MySQLServerGreeting.answer(greeting: [10] + Array("8.0.36".utf8)) == .unreadable) - #expect(MySQLServerGreeting.answer(greeting: Array(Self.greeting(offersTLS: true).prefix(20))) == .unreadable) - #expect(MySQLServerGreeting.answer(greeting: []) == .unreadable) - } - @Test("The plugin folder is found beside a Homebrew tool, through its link") func pluginDirectoryBesideTheTool() throws { let root = try temporaryDirectory() @@ -373,11 +353,11 @@ struct MySQLDumpPlaintextPasswordTests { password: nil ) await #expect(throws: NativeDumpError.self) { - try await tool.prepare(request, resolved) + try await tool.prepare(request, resolved, answering("")) } let missing = NativeDumpResolvedTool(name: "mariadb-dump", path: "/nonexistent/bin/mariadb-dump", flavor: .mariadb) await #expect(throws: NativeDumpError.self) { - try await tool.prepare(request, missing) + try await tool.prepare(request, missing, answering("")) } let required = NativeDumpDescriptor.Request( @@ -386,7 +366,7 @@ struct MySQLDumpPlaintextPasswordTests { fileURL: URL(fileURLWithPath: "/tmp/out.sql"), password: nil ) - _ = try await tool.prepare(required, resolved) + _ = try await tool.prepare(required, resolved, answering("")) } @Test("The plugin folder is removed when the tool exits and when it cannot launch") @@ -419,16 +399,6 @@ struct MySQLDumpPlaintextPasswordTests { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url } - - static func greeting(offersTLS: Bool) -> [UInt8] { - let capabilities: UInt32 = 0x1 | 0x8 | 0x200 | 0x2000 | 0x8000 | 0x80000 | (offersTLS ? 0x800 : 0) - var payload: [UInt8] = [10] + Array("8.0.36".utf8) + [0] - payload += [1, 0, 0, 0] + Array("abcdefgh".utf8) + [0] - payload += [UInt8(capabilities & 0xFF), UInt8((capabilities >> 8) & 0xFF), 45, 2, 0] - payload += [UInt8((capabilities >> 16) & 0xFF), UInt8((capabilities >> 24) & 0xFF), 21] + [UInt8](repeating: 0, count: 10) - payload += Array("ijklmnopqrst".utf8) + [0] + Array("caching_sha2_password".utf8) + [0] - return payload - } } private final class FinishingRunner: NativeDumpRunner, @unchecked Sendable { @@ -504,53 +474,3 @@ struct MySQLDumpPlaintextPasswordServiceTests { #expect(!FileManager.default.fileExists(atPath: folder.url.path)) } } - -private final class FakeGreetingServer: @unchecked Sendable { - private let listener: NWListener - let port: Int - - private init(listener: NWListener, port: Int) { - self.listener = listener - self.port = port - } - - static func start(payload: [UInt8]) async throws -> FakeGreetingServer { - let parameters = NWParameters.tcp - parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: .any) - let listener = try NWListener(using: parameters) - let queue = DispatchQueue(label: "com.TablePro.tests.FakeGreetingServer") - let packet = Data([UInt8(payload.count & 0xFF), UInt8((payload.count >> 8) & 0xFF), 0, 0] + payload) - listener.newConnectionHandler = { connection in - connection.start(queue: queue) - connection.send(content: packet, completion: .contentProcessed { _ in }) - } - let port: Int = try await withCheckedThrowingContinuation { continuation in - let resumed = OSAllocatedUnfairLock(initialState: false) - listener.stateUpdateHandler = { state in - let value: Result? - switch state { - case .ready: - value = listener.port.map { .success(Int($0.rawValue)) } ?? .failure(CancellationError()) - case .failed(let error): - value = .failure(error) - case .cancelled: - value = .failure(CancellationError()) - default: - value = nil - } - guard let value else { return } - let first = resumed.withLock { done -> Bool in - defer { done = true } - return !done - } - if first { continuation.resume(with: value) } - } - listener.start(queue: queue) - } - return FakeGreetingServer(listener: listener, port: port) - } - - func stop() { - listener.cancel() - } -} diff --git a/apple/scripts/probes/check-mysql-dump-plaintext-password.sh b/apple/scripts/probes/check-mysql-dump-plaintext-password.sh index b119d1118c..52284e8fde 100755 --- a/apple/scripts/probes/check-mysql-dump-plaintext-password.sh +++ b/apple/scripts/probes/check-mysql-dump-plaintext-password.sh @@ -3,10 +3,11 @@ # send the password as typed without TLS. # # The tools answer mysql_clear_password, dialog (PAM) and the test plugins Homebrew ships without TLS, and have no -# option that refuses them, so the app points them at a plugin folder that links only the plugins that never do. Their --ssl without verification also falls back to plaintext and -# then answers caching_sha2_password's full authentication with the typed password, so Preferred dials with -# --skip-ssl unless the server's greeting offered TLS. Every row runs a real tool against -# mariadb-tls-modes/fake-mysql-server.py. MySQL's own tools are not checked here: the app leaves them as they are. +# option that refuses them, so the app points them at a plugin folder that links only the plugins that never do. +# Their --ssl without verification also falls back to plaintext and then answers caching_sha2_password's full +# authentication with the typed password, so Preferred dials with --skip-ssl unless the app's session to the server +# runs over TLS. Every row runs a real tool against mariadb-tls-modes/fake-mysql-server.py. MySQL's own tools are not +# checked here: the app leaves them as they are. # # Needs python3, openssl and MariaDB's mariadb-dump and mariadb (brew install mariadb). Skips (exit 3) without them. # Exits non-zero on any unexpected outcome. @@ -123,7 +124,7 @@ check() { for tool in "$DUMP" "$CLIENT"; do folder="$(plugin_folder "$tool")" hidden=(--skip-ssl "--plugin-dir=$folder") - # Disabled, and Preferred after a greeting without TLS, run with the same options. + # Disabled, and Preferred against a server without TLS, run with the same options. check "Disabled, server asks for mysql_clear_password" "$tool" refused-mysql_clear_password "--auth cleartext" \ "${hidden[@]}" check "Disabled, server asks through PAM dialog" "$tool" refused-dialog "--auth dialog" "${hidden[@]}" @@ -136,7 +137,7 @@ for tool in "$DUMP" "$CLIENT"; do check "Preferred, server offers TLS, full authentication" "$tool" tls \ "--auth sha2-full --cert $WORK/self.pem --key $WORK/self.key" --ssl --skip-ssl-verify-server-cert check "Required, server without TLS" "$tool" ssl-required "--auth cleartext" --ssl --ssl-verify-server-cert - # Why Preferred reads the greeting: the options it had before send the password to a server without TLS. + # Why Preferred asks first: the options it had before send the password to a server without TLS. check "Old Preferred options, server without TLS, full authentication" "$tool" leak "--auth sha2-full" \ --ssl --skip-ssl-verify-server-cert done From cb478ca6b993a1e646b1676fa7b03360377a306e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 14:20:01 +0700 Subject: [PATCH 10/12] fix(mysql): read the server's greeting again; a closed probe never blocks the host --- .../Core/Database/MySQLClientArguments.swift | 12 +- .../Core/Database/MySQLServerGreeting.swift | 105 +++++++++++ .../Core/Database/NativeDumpDescriptor.swift | 11 +- .../Core/Database/NativeDumpRegistry.swift | 37 ++-- .../Core/Database/NativeDumpService.swift | 14 +- .../TablePro/Resources/Localizable.xcstrings | 3 + .../Database/MySQLClientArgumentsTests.swift | 4 +- .../MySQLDumpPlaintextPasswordTests.swift | 164 +++++++++++++----- .../check-mysql-dump-plaintext-password.sh | 10 +- 9 files changed, 269 insertions(+), 91 deletions(-) create mode 100644 apple/TablePro/Core/Database/MySQLServerGreeting.swift diff --git a/apple/TablePro/Core/Database/MySQLClientArguments.swift b/apple/TablePro/Core/Database/MySQLClientArguments.swift index 5cd983f30b..185ca3aadd 100644 --- a/apple/TablePro/Core/Database/MySQLClientArguments.swift +++ b/apple/TablePro/Core/Database/MySQLClientArguments.swift @@ -33,8 +33,8 @@ enum MySQLClientArguments { /// /// MariaDB's `--ssl` without verification also sends the typed password when the server offers no /// TLS and caching_sha2_password asks for full authentication, so Preferred dials in plaintext - /// when the server offers no TLS. Measured with mariadb-dump 13.0.2: `--skip-ssl` asks for the - /// server's RSA key instead. Nil means the server was not asked. + /// when the server's greeting offered no TLS. Measured with mariadb-dump 13.0.2: `--skip-ssl` + /// asks for the server's RSA key instead. Nil means the greeting was not read. static func tls( _ ssl: SSLConfiguration, flavor: NativeDumpToolFlavor, @@ -57,15 +57,15 @@ enum MySQLClientArguments { guard flavor == .mariadb, reachesServerOverNetwork(connection) else { return false } switch connection.transportSSLConfiguration.mode { case .disabled: return true - // The answer comes from the app's own session, not the tool's connection. Someone on the path who answers the - // two differently can as well end the unverified TLS Preferred accepts, in the app's driver too; keeping the - // password from them is what Required is for. + // The greeting comes from a connection of its own. Someone on the path who answers the two differently can as + // well end the unverified TLS Preferred accepts, in the app's driver too; keeping the password from them is + // what Required is for. case .preferred: return serverOffersTLS != true case .required, .verifyCa, .verifyIdentity: return false } } - static func needsServerTLSAnswer(_ connection: DatabaseConnection, flavor: NativeDumpToolFlavor) -> Bool { + static func needsServerGreeting(_ connection: DatabaseConnection, flavor: NativeDumpToolFlavor) -> Bool { flavor == .mariadb && connection.transportSSLConfiguration.mode == .preferred && reachesServerOverNetwork(connection) diff --git a/apple/TablePro/Core/Database/MySQLServerGreeting.swift b/apple/TablePro/Core/Database/MySQLServerGreeting.swift new file mode 100644 index 0000000000..2ad9a191fa --- /dev/null +++ b/apple/TablePro/Core/Database/MySQLServerGreeting.swift @@ -0,0 +1,105 @@ +// +// MySQLServerGreeting.swift +// TablePro +// + +import Foundation +import Network +import os + +// MariaDB's client tools on Preferred fall back to plaintext when the server offers no TLS, and still send the typed +// password when caching_sha2_password asks for full authentication. Reading the server's greeting first, which carries +// no credentials, lets the dump pick TLS or plaintext up front instead. The greeting comes from the endpoint the tool +// dials, so a proxy answers for its own front end. Measured with max_connect_errors=1 and name resolution on, MySQL +// 8.4 and MariaDB 11.4 count a connection closed after the greeting as aborted, never as a blocking host error. +enum MySQLServerGreeting { + enum Answer: Equatable, Sendable { + case offersTLS(Bool) + case serverError(String) + case unreadable + } + + private static let clientSSL: UInt16 = 0x0800 + private static let errorPacket: UInt8 = 0xFF + private static let queue = DispatchQueue(label: "com.TablePro.MySQLServerGreeting", qos: .userInitiated) + + static func read(host: String, port: Int, deadline: Duration = .seconds(10)) async -> Answer { + guard port > 0, let nwPort = NWEndpoint.Port(rawValue: UInt16(clamping: port)) else { return .unreadable } + let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp) + return await withCheckedContinuation { continuation in + let resumed = OSAllocatedUnfairLock(initialState: false) + let complete: @Sendable (Answer) -> Void = { value in + let shouldResume = resumed.withLock { done -> Bool in + guard !done else { return false } + done = true + return true + } + guard shouldResume else { return } + connection.cancel() + continuation.resume(returning: value) + } + connection.stateUpdateHandler = { state in + switch state { + case .ready: + readGreeting(from: connection, complete: complete) + case .failed, .cancelled, .waiting: + complete(.unreadable) + default: + break + } + } + connection.start(queue: queue) + let parts = deadline.components + let seconds = Double(parts.seconds) + Double(parts.attoseconds) / 1e18 + queue.asyncAfter(deadline: .now() + seconds) { complete(.unreadable) } + } + } + + static func answer(greeting payload: [UInt8]) -> Answer { + guard let first = payload.first else { return .unreadable } + if first == errorPacket { + return serverError(payload).map { .serverError($0) } ?? .unreadable + } + guard let versionEnd = payload.dropFirst().firstIndex(of: 0) else { return .unreadable } + // Thread id (4), the first part of the scramble (8) and a filler byte sit before the capability flags. + let flagsStart = versionEnd + 1 + 4 + 8 + 1 + guard payload.count >= flagsStart + 2 else { return .unreadable } + let flags = UInt16(payload[flagsStart]) | UInt16(payload[flagsStart + 1]) << 8 + return .offersTLS(flags & clientSSL != 0) + } + + // A server refusing the connection outright, such as "Too many connections", answers with an error packet in place + // of the greeting. Before the handshake it carries no SQL state marker. + private static func serverError(_ payload: [UInt8]) -> String? { + guard payload.count > 3 else { return nil } + var message = payload.dropFirst(3) + if message.first == UInt8(ascii: "#"), message.count >= 6 { + message = message.dropFirst(6) + } + let text = String(bytes: message, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + return text?.isEmpty == false ? text : nil + } + + private static func readGreeting(from connection: NWConnection, complete: @escaping @Sendable (Answer) -> Void) { + connection.receive(minimumIncompleteLength: 4, maximumLength: 4) { header, _, _, error in + guard error == nil, let header, header.count == 4 else { + complete(.unreadable) + return + } + let length = Int(header[header.startIndex]) + | Int(header[header.startIndex + 1]) << 8 + | Int(header[header.startIndex + 2]) << 16 + guard length > 0 else { + complete(.unreadable) + return + } + connection.receive(minimumIncompleteLength: length, maximumLength: length) { payload, _, _, error in + guard error == nil, let payload, payload.count == length else { + complete(.unreadable) + return + } + complete(answer(greeting: Array(payload))) + } + } + } +} diff --git a/apple/TablePro/Core/Database/NativeDumpDescriptor.swift b/apple/TablePro/Core/Database/NativeDumpDescriptor.swift index 7f71882cd6..8a9d301550 100644 --- a/apple/TablePro/Core/Database/NativeDumpDescriptor.swift +++ b/apple/TablePro/Core/Database/NativeDumpDescriptor.swift @@ -77,7 +77,7 @@ struct NativeDumpDescriptor: Sendable { /// flags that server needs. `mysqldump` 8.0 reads a table no MariaDB server has. let serverVersion: String? - // Whether the server offers TLS, for a tool whose SSL options depend on it. Nil when not asked. + // Whether the server's greeting offered TLS, for a tool whose SSL options depend on it. Nil when not read. var serverOffersTLS: Bool? // Set by `NativeDumpService.buildCommand` before the arguments are built. @@ -115,9 +115,6 @@ struct NativeDumpDescriptor: Sendable { let value: String } - // A query on the app's own session to the server being dumped, answered as text. - typealias SessionQuery = @Sendable (String) async throws -> [[String?]] - /// A tool on the user's Mac that the app spawns. struct CommandLineTool: Sendable { /// The candidate names, in the order they are tried. More than one because a tool can ship @@ -159,8 +156,8 @@ struct NativeDumpDescriptor: Sendable { let restoreArguments: @Sendable (Request, NativeDumpResolvedTool) throws -> [String] let environment: @Sendable (Request) -> [String: String] - // Runs once the tool is resolved, before the arguments are built, for checks that need the session or the tool. - let prepare: @Sendable (Request, NativeDumpResolvedTool, SessionQuery) async throws -> Request + // Runs once the tool is resolved, before the arguments are built, for checks that need the network or the tool. + let prepare: @Sendable (Request, NativeDumpResolvedTool) async throws -> Request let clientPluginFolder: @Sendable (Request, NativeDumpResolvedTool) throws -> MariaDBClientPluginFolder? @@ -179,7 +176,7 @@ struct NativeDumpDescriptor: Sendable { restoreArguments: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> [String], environment: @escaping @Sendable (Request) -> [String: String] = { _ in [:] }, configurationFileEntries: @escaping @Sendable (Request) -> [ConfigurationEntry] = { _ in [] }, - prepare: @escaping @Sendable (Request, NativeDumpResolvedTool, SessionQuery) async throws -> Request = { request, _, _ in request }, + prepare: @escaping @Sendable (Request, NativeDumpResolvedTool) async throws -> Request = { request, _ in request }, clientPluginFolder: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> MariaDBClientPluginFolder? = { _, _ in nil } ) { self.backupBinaries = backupBinaries diff --git a/apple/TablePro/Core/Database/NativeDumpRegistry.swift b/apple/TablePro/Core/Database/NativeDumpRegistry.swift index a5e6d2c560..66c6de3a34 100644 --- a/apple/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/apple/TablePro/Core/Database/NativeDumpRegistry.swift @@ -218,8 +218,8 @@ enum NativeDumpRegistry { guard let password = request.password, !password.isEmpty else { return [:] } return ["MYSQL_PWD": password] }, - prepare: { request, resolved, query in - try await mysqlPrepare(request, resolved, query) + prepare: { request, resolved in + try await mysqlPrepare(request, resolved) }, clientPluginFolder: { request, resolved in try mysqlPluginFolder(request, resolved) @@ -253,13 +253,10 @@ enum NativeDumpRegistry { private static func mysqlPrepare( _ request: NativeDumpDescriptor.Request, - _ resolved: NativeDumpResolvedTool, - _ query: NativeDumpDescriptor.SessionQuery + _ resolved: NativeDumpResolvedTool ) async throws -> NativeDumpDescriptor.Request { var request = request - if MySQLClientArguments.needsServerTLSAnswer(request.connection, flavor: resolved.flavor) { - request.serverOffersTLS = try await mysqlServerOffersTLS(query) - } + request.serverOffersTLS = try await mysqlServerOffersTLS(request, resolved) let hidesPlugins = MySQLClientArguments.hidesPlaintextPasswordPlugins( request.connection, flavor: resolved.flavor, @@ -297,20 +294,28 @@ enum NativeDumpRegistry { return request } - // The app's own session on Preferred takes TLS whenever the server offers it, so its cipher answers the question - // without a connection of TablePro's own that a server could count against max_connect_errors. No answer fails - // the run: guessing no TLS would dump in plaintext from a server that has it, and guessing TLS brings back the - // fallback that sends the typed password. - private static func mysqlServerOffersTLS(_ query: NativeDumpDescriptor.SessionQuery) async throws -> Bool { - let rows = try? await query("SHOW SESSION STATUS LIKE 'Ssl_cipher'") - guard let row = rows?.first, row.count >= 2 else { + // Unreadable fails the run: guessing no TLS would dump in plaintext from a server that has it, and guessing TLS + // brings back the fallback that sends the typed password. + private static func mysqlServerOffersTLS( + _ request: NativeDumpDescriptor.Request, + _ resolved: NativeDumpResolvedTool + ) async throws -> Bool? { + guard MySQLClientArguments.needsServerGreeting(request.connection, flavor: resolved.flavor) else { return nil } + switch await MySQLServerGreeting.read(host: request.host, port: request.connection.port) { + case .offersTLS(let offered): + return offered + case .serverError(let message): + throw NativeDumpError.unreachableServer(message: message) + case .unreadable: throw NativeDumpError.unreachableServer( message: String( - localized: "TablePro could not ask the server whether it offers TLS. Reconnect, then try again." + format: String( + localized: "TablePro could not reach %@ to see whether it offers TLS. Check that the server is running and reachable, then try again." + ), + "\(request.host):\(request.connection.port)" ) ) } - return !(row[1] ?? "").isEmpty } private static func mysqlPluginFolder( diff --git a/apple/TablePro/Core/Database/NativeDumpService.swift b/apple/TablePro/Core/Database/NativeDumpService.swift index deee69b2a4..1916dca896 100644 --- a/apple/TablePro/Core/Database/NativeDumpService.swift +++ b/apple/TablePro/Core/Database/NativeDumpService.swift @@ -310,10 +310,7 @@ final class NativeDumpService: ObservableObject { serverVersion: serverVersion ) toolName = resolved.name - let connectionId = connection.id - request = try await tool.prepare(request, resolved) { sql in - try await Self.querySession(sql, connectionId: connectionId, database: database) - } + request = try await tool.prepare(request, resolved) let command = try Self.buildCommand( kind: kind, tool: tool, @@ -495,15 +492,6 @@ final class NativeDumpService: ObservableObject { } } - static func querySession(_ sql: String, connectionId: UUID, database: String) async throws -> [[String?]] { - guard let scope = DatabaseManager.shared.resolvedScope(database: database, schema: nil, for: connectionId) else { - throw DatabaseError.notConnected - } - return try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in - try await driver.execute(query: sql).rows.map { row in row.map(\.asText) } - } - } - /// Pure so the choice is testable without an engine. The picked name wins when the engine says /// it exists, because a connection with two catalogs attached has to dump the one that was /// ticked rather than whichever the session is parked on. diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index d1d10ab57d..c9af0cbc08 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -176890,6 +176890,9 @@ } } } + }, + "TablePro could not reach %@ to see whether it offers TLS. Check that the server is running and reachable, then try again." : { + }, "TablePro could not reach the SAP HANA server within %lld seconds." : { "extractionState" : "manual", diff --git a/apple/TableProTests/Database/MySQLClientArgumentsTests.swift b/apple/TableProTests/Database/MySQLClientArgumentsTests.swift index 09faa76825..75db4b4f4a 100644 --- a/apple/TableProTests/Database/MySQLClientArgumentsTests.swift +++ b/apple/TableProTests/Database/MySQLClientArgumentsTests.swift @@ -73,8 +73,8 @@ struct MySQLClientArgumentsTests { // Measured with mariadb-dump 13.0.2: --ssl falls back to plaintext and answers caching_sha2_password's full // authentication with the typed password, where --skip-ssl asks for the server's RSA key. - @Test("MariaDB's Preferred follows whether the server offers TLS, MySQL's does not need to") - func preferredFollowsTheServer() throws { + @Test("MariaDB's Preferred follows the server's greeting, MySQL's does not need to") + func preferredFollowsTheGreeting() throws { #expect( try MySQLClientArguments.tls(ssl(.preferred), flavor: .mariadb, toolPath: "/t", serverOffersTLS: false) == ["--skip-ssl"] diff --git a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift index 2b6f54634b..a674037d9f 100644 --- a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift +++ b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift @@ -4,6 +4,8 @@ // import Foundation +import Network +import os import TableProPluginKit import TableProSSHConfig import Testing @@ -130,12 +132,12 @@ struct MySQLDumpPlaintextPasswordTests { var socket = connection(.disabled) socket.additionalFields[MySQLLocalSocket.fieldKey] = "/tmp/mysql.sock" #expect(try command(socket).clientPluginFolder == nil) - #expect(!MySQLClientArguments.needsServerTLSAnswer(socket, flavor: .mariadb)) + #expect(!MySQLClientArguments.needsServerGreeting(socket, flavor: .mariadb)) var proxied = connection(.preferred) proxied.additionalFields[DatabaseConnection.tunnelSecuresTransportKey] = "true" #expect(try command(proxied).clientPluginFolder == nil) - #expect(!MySQLClientArguments.needsServerTLSAnswer(proxied, flavor: .mariadb)) + #expect(!MySQLClientArguments.needsServerGreeting(proxied, flavor: .mariadb)) } @Test("MySQL's own tools are left as they are") @@ -144,70 +146,88 @@ struct MySQLDumpPlaintextPasswordTests { let built = try command(connection(mode), flavor: .mysql, serverOffersTLS: false) #expect(built.clientPluginFolder == nil) #expect(pluginDirectoryArgument(built) == nil) - #expect(!MySQLClientArguments.needsServerTLSAnswer(connection(mode), flavor: .mysql)) + #expect(!MySQLClientArguments.needsServerGreeting(connection(mode), flavor: .mysql)) } #expect(try command(connection(.preferred), flavor: .mysql).arguments.contains("--ssl-mode=PREFERRED")) } - private func answering(_ cipher: String?) -> NativeDumpDescriptor.SessionQuery { - { sql in - guard sql == "SHOW SESSION STATUS LIKE 'Ssl_cipher'", let cipher else { throw DatabaseError.notConnected } - return [["Ssl_cipher", cipher]] + @Test("Only a MariaDB tool on Preferred reads the server's greeting") + func greetingIsReadOnlyForPreferred() async throws { + let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) + let client = try fakeClient(sendsPassword: false) + defer { try? FileManager.default.removeItem(at: client.deletingLastPathComponent()) } + for (mode, flavor) in [(SSLMode.disabled, NativeDumpToolFlavor.mariadb), (.required, .mariadb), (.preferred, .mysql)] { + let request = NativeDumpDescriptor.Request( + connection: connection(mode), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/out.sql"), + password: nil + ) + let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: client.path, flavor: flavor) + #expect(try await tool.prepare(request, resolved).serverOffersTLS == nil, "\(mode.rawValue) \(flavor)") } } - private func request(_ mode: SSLMode) -> NativeDumpDescriptor.Request { - NativeDumpDescriptor.Request( - connection: connection(mode), + private func preferredRequest(port: Int) -> NativeDumpDescriptor.Request { + var preferred = connection(.preferred) + preferred.host = "127.0.0.1" + preferred.port = port + return NativeDumpDescriptor.Request( + connection: preferred, database: "sales", fileURL: URL(fileURLWithPath: "/tmp/out.sql"), password: nil ) } - @Test("Only a MariaDB tool on Preferred asks the session whether the server offers TLS") - func sessionIsAskedOnlyForPreferred() async throws { - let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) - let client = try fakeClient(sendsPassword: false) - defer { try? FileManager.default.removeItem(at: client.deletingLastPathComponent()) } - let unanswered: NativeDumpDescriptor.SessionQuery = { _ in - Issue.record("the session should not be asked") - return [] - } - for (mode, flavor) in [(SSLMode.disabled, NativeDumpToolFlavor.mariadb), (.required, .mariadb), (.preferred, .mysql)] { - let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: client.path, flavor: flavor) - let prepared = try await tool.prepare(request(mode), resolved, unanswered) - #expect(prepared.serverOffersTLS == nil, "\(mode.rawValue) \(flavor)") - } - } - - // The app's own session on Preferred takes TLS whenever the server offers it. - @Test("Preferred takes the session's cipher as the server's answer") - func preferredReadsTheSessionCipher() async throws { + @Test("Preferred takes the server's own answer from its greeting", arguments: [true, false]) + func preferredReadsTheGreeting(offersTLS: Bool) async throws { + let server = try await FakeGreetingServer.start(payload: Self.greeting(offersTLS: offersTLS)) + defer { server.stop() } let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) let client = try fakeClient(sendsPassword: false) defer { try? FileManager.default.removeItem(at: client.deletingLastPathComponent()) } let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: client.path, flavor: .mariadb) - - let encrypted = try await tool.prepare(request(.preferred), resolved, answering("TLS_AES_256_GCM_SHA384")) - #expect(encrypted.serverOffersTLS == true) - let plain = try await tool.prepare(request(.preferred), resolved, answering("")) - #expect(plain.serverOffersTLS == false) + #expect(try await tool.prepare(preferredRequest(port: server.port), resolved).serverOffersTLS == offersTLS) + #expect(await MySQLServerGreeting.read(host: "127.0.0.1", port: server.port) == .offersTLS(offersTLS)) } // Guessing no TLS would dump in plaintext from a server that has it; guessing TLS brings back the fallback. - @Test("A session that cannot answer fails the run instead of a guess") - func unansweredSessionFailsTheRun() async throws { + @Test("A server that cannot be read fails the run instead of a guess") + func unreadableServerFailsTheRun() async throws { let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: "/t", flavor: .mariadb) + let closed = try #require(LoopbackPort.allocateFree()) await #expect(throws: NativeDumpError.self) { - try await tool.prepare(request(.preferred), resolved, answering(nil)) + try await tool.prepare(preferredRequest(port: closed), resolved) } - await #expect(throws: NativeDumpError.self) { - try await tool.prepare(request(.preferred), resolved) { _ in [] } + + let busy = [0xFF, 0x10, 0x04] + Array("Too many connections".utf8) + let server = try await FakeGreetingServer.start(payload: busy) + defer { server.stop() } + await #expect(throws: NativeDumpError.unreachableServer(message: "Too many connections")) { + try await tool.prepare(preferredRequest(port: server.port), resolved) } } + @Test("An error packet carries the server's words, and a short greeting tells nothing") + func greetingParsing() { + #expect(MySQLServerGreeting.answer(greeting: Self.greeting(offersTLS: true)) == .offersTLS(true)) + #expect(MySQLServerGreeting.answer(greeting: Self.greeting(offersTLS: false)) == .offersTLS(false)) + #expect( + MySQLServerGreeting.answer(greeting: [0xFF, 0x69, 0x04] + Array("Host 'x' is blocked".utf8)) + == .serverError("Host 'x' is blocked") + ) + #expect( + MySQLServerGreeting.answer(greeting: [0xFF, 0x10, 0x04] + Array("#08004Too many connections".utf8)) + == .serverError("Too many connections") + ) + #expect(MySQLServerGreeting.answer(greeting: [0xFF, 0x10, 0x04]) == .unreadable) + #expect(MySQLServerGreeting.answer(greeting: [10] + Array("8.0.36".utf8)) == .unreadable) + #expect(MySQLServerGreeting.answer(greeting: Array(Self.greeting(offersTLS: true).prefix(20))) == .unreadable) + #expect(MySQLServerGreeting.answer(greeting: []) == .unreadable) + } + @Test("The plugin folder is found beside a Homebrew tool, through its link") func pluginDirectoryBesideTheTool() throws { let root = try temporaryDirectory() @@ -353,11 +373,11 @@ struct MySQLDumpPlaintextPasswordTests { password: nil ) await #expect(throws: NativeDumpError.self) { - try await tool.prepare(request, resolved, answering("")) + try await tool.prepare(request, resolved) } let missing = NativeDumpResolvedTool(name: "mariadb-dump", path: "/nonexistent/bin/mariadb-dump", flavor: .mariadb) await #expect(throws: NativeDumpError.self) { - try await tool.prepare(request, missing, answering("")) + try await tool.prepare(request, missing) } let required = NativeDumpDescriptor.Request( @@ -366,7 +386,7 @@ struct MySQLDumpPlaintextPasswordTests { fileURL: URL(fileURLWithPath: "/tmp/out.sql"), password: nil ) - _ = try await tool.prepare(required, resolved, answering("")) + _ = try await tool.prepare(required, resolved) } @Test("The plugin folder is removed when the tool exits and when it cannot launch") @@ -399,6 +419,16 @@ struct MySQLDumpPlaintextPasswordTests { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url } + + static func greeting(offersTLS: Bool) -> [UInt8] { + let capabilities: UInt32 = 0x1 | 0x8 | 0x200 | 0x2000 | 0x8000 | 0x80000 | (offersTLS ? 0x800 : 0) + var payload: [UInt8] = [10] + Array("8.0.36".utf8) + [0] + payload += [1, 0, 0, 0] + Array("abcdefgh".utf8) + [0] + payload += [UInt8(capabilities & 0xFF), UInt8((capabilities >> 8) & 0xFF), 45, 2, 0] + payload += [UInt8((capabilities >> 16) & 0xFF), UInt8((capabilities >> 24) & 0xFF), 21] + [UInt8](repeating: 0, count: 10) + payload += Array("ijklmnopqrst".utf8) + [0] + Array("caching_sha2_password".utf8) + [0] + return payload + } } private final class FinishingRunner: NativeDumpRunner, @unchecked Sendable { @@ -474,3 +504,53 @@ struct MySQLDumpPlaintextPasswordServiceTests { #expect(!FileManager.default.fileExists(atPath: folder.url.path)) } } + +private final class FakeGreetingServer: @unchecked Sendable { + private let listener: NWListener + let port: Int + + private init(listener: NWListener, port: Int) { + self.listener = listener + self.port = port + } + + static func start(payload: [UInt8]) async throws -> FakeGreetingServer { + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: .any) + let listener = try NWListener(using: parameters) + let queue = DispatchQueue(label: "com.TablePro.tests.FakeGreetingServer") + let packet = Data([UInt8(payload.count & 0xFF), UInt8((payload.count >> 8) & 0xFF), 0, 0] + payload) + listener.newConnectionHandler = { connection in + connection.start(queue: queue) + connection.send(content: packet, completion: .contentProcessed { _ in }) + } + let port: Int = try await withCheckedThrowingContinuation { continuation in + let resumed = OSAllocatedUnfairLock(initialState: false) + listener.stateUpdateHandler = { state in + let value: Result? + switch state { + case .ready: + value = listener.port.map { .success(Int($0.rawValue)) } ?? .failure(CancellationError()) + case .failed(let error): + value = .failure(error) + case .cancelled: + value = .failure(CancellationError()) + default: + value = nil + } + guard let value else { return } + let first = resumed.withLock { done -> Bool in + defer { done = true } + return !done + } + if first { continuation.resume(with: value) } + } + listener.start(queue: queue) + } + return FakeGreetingServer(listener: listener, port: port) + } + + func stop() { + listener.cancel() + } +} diff --git a/apple/scripts/probes/check-mysql-dump-plaintext-password.sh b/apple/scripts/probes/check-mysql-dump-plaintext-password.sh index 52284e8fde..3ca6685271 100755 --- a/apple/scripts/probes/check-mysql-dump-plaintext-password.sh +++ b/apple/scripts/probes/check-mysql-dump-plaintext-password.sh @@ -5,9 +5,9 @@ # The tools answer mysql_clear_password, dialog (PAM) and the test plugins Homebrew ships without TLS, and have no # option that refuses them, so the app points them at a plugin folder that links only the plugins that never do. # Their --ssl without verification also falls back to plaintext and then answers caching_sha2_password's full -# authentication with the typed password, so Preferred dials with --skip-ssl unless the app's session to the server -# runs over TLS. Every row runs a real tool against mariadb-tls-modes/fake-mysql-server.py. MySQL's own tools are not -# checked here: the app leaves them as they are. +# authentication with the typed password, so Preferred dials with --skip-ssl unless the server's greeting offered TLS. +# Every row runs a real tool against mariadb-tls-modes/fake-mysql-server.py. MySQL's own tools are not checked here: +# the app leaves them as they are. # # Needs python3, openssl and MariaDB's mariadb-dump and mariadb (brew install mariadb). Skips (exit 3) without them. # Exits non-zero on any unexpected outcome. @@ -124,7 +124,7 @@ check() { for tool in "$DUMP" "$CLIENT"; do folder="$(plugin_folder "$tool")" hidden=(--skip-ssl "--plugin-dir=$folder") - # Disabled, and Preferred against a server without TLS, run with the same options. + # Disabled, and Preferred after a greeting without TLS, run with the same options. check "Disabled, server asks for mysql_clear_password" "$tool" refused-mysql_clear_password "--auth cleartext" \ "${hidden[@]}" check "Disabled, server asks through PAM dialog" "$tool" refused-dialog "--auth dialog" "${hidden[@]}" @@ -137,7 +137,7 @@ for tool in "$DUMP" "$CLIENT"; do check "Preferred, server offers TLS, full authentication" "$tool" tls \ "--auth sha2-full --cert $WORK/self.pem --key $WORK/self.key" --ssl --skip-ssl-verify-server-cert check "Required, server without TLS" "$tool" ssl-required "--auth cleartext" --ssl --ssl-verify-server-cert - # Why Preferred asks first: the options it had before send the password to a server without TLS. + # Why Preferred reads the greeting: the options it had before send the password to a server without TLS. check "Old Preferred options, server without TLS, full authentication" "$tool" leak "--auth sha2-full" \ --ssl --skip-ssl-verify-server-cert done From 2af14a22185dbb37884fdee00ade6c522aafa66b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 14:39:55 +0700 Subject: [PATCH 11/12] fix(mysql): add the built-in plugin messages to the catalog --- apple/TablePro/Resources/Localizable.xcstrings | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index c9af0cbc08..14e4eff31a 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -5003,6 +5003,9 @@ } } } + }, + "%@ sends the password in plain text when a server asks, even without TLS, and TablePro cannot turn that off. Set SSL Mode to Required, or install another build of the client tools." : { + }, "%@ shapes" : { "comment" : "Number of distinct query shapes, %@ is a count", @@ -176807,6 +176810,9 @@ } } } + }, + "TablePro could not check whether %@ sends the password in plain text without TLS. Try again, or set SSL Mode to Required." : { + }, "TablePro could not do this for you. Run this in Terminal instead:" : { "extractionState" : "stale", From dbd9a5b56eeb1d0d5a446e98698b84f680bfaff6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 15:12:36 +0700 Subject: [PATCH 12/12] fix(mysql): a cancel during the TLS and plugin checks stops them --- .../MariaDBBuiltInPlaintextCheck.swift | 10 ++++-- .../Core/Database/MySQLServerGreeting.swift | 9 ++++++ .../Core/Database/NativeDumpService.swift | 31 ++++++++++++++++++- .../MySQLDumpPlaintextPasswordTests.swift | 26 ++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift b/apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift index f0d7b24b21..ddad15d435 100644 --- a/apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift +++ b/apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift @@ -29,9 +29,10 @@ enum MariaDBBuiltInPlaintextCheck { if let key, let known = verdicts.withLock({ $0[key] }) { return known } - let verdict = await Task.detached(priority: .userInitiated) { + let asking = Task.detached(priority: .userInitiated) { var combined = Verdict.refusesPlaintext for plugin in Self.plugins { + guard !Task.isCancelled else { return Verdict.unknown } switch Self.ask(toolPath: toolPath, plugin: plugin, timeout: timeout) { case .sendsPlaintext: return Verdict.sendsPlaintext case .unknown: combined = .unknown @@ -39,7 +40,12 @@ enum MariaDBBuiltInPlaintextCheck { } } return combined - }.value + } + let verdict = await withTaskCancellationHandler { + await asking.value + } onCancel: { + asking.cancel() + } if let key, verdict != .unknown { verdicts.withLock { $0[key] = verdict } } diff --git a/apple/TablePro/Core/Database/MySQLServerGreeting.swift b/apple/TablePro/Core/Database/MySQLServerGreeting.swift index 2ad9a191fa..053be2c114 100644 --- a/apple/TablePro/Core/Database/MySQLServerGreeting.swift +++ b/apple/TablePro/Core/Database/MySQLServerGreeting.swift @@ -26,6 +26,15 @@ enum MySQLServerGreeting { static func read(host: String, port: Int, deadline: Duration = .seconds(10)) async -> Answer { guard port > 0, let nwPort = NWEndpoint.Port(rawValue: UInt16(clamping: port)) else { return .unreadable } let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp) + return await withTaskCancellationHandler { + await answer(from: connection, deadline: deadline) + } onCancel: { + connection.cancel() + } + } + + private static func answer(from connection: NWConnection, deadline: Duration) async -> Answer { + guard !Task.isCancelled else { return .unreadable } return await withCheckedContinuation { continuation in let resumed = OSAllocatedUnfairLock(initialState: false) let complete: @Sendable (Answer) -> Void = { value in diff --git a/apple/TablePro/Core/Database/NativeDumpService.swift b/apple/TablePro/Core/Database/NativeDumpService.swift index 1916dca896..884698111a 100644 --- a/apple/TablePro/Core/Database/NativeDumpService.swift +++ b/apple/TablePro/Core/Database/NativeDumpService.swift @@ -203,6 +203,7 @@ final class NativeDumpService: ObservableObject { private var stateObservers: [UUID: AsyncStream.Continuation] = [:] private var toolName = "dump" private var cancelRequested = false + private var preparation: Task? func stateUpdates() -> AsyncStream { let (stream, continuation) = AsyncStream.makeStream() @@ -310,7 +311,10 @@ final class NativeDumpService: ObservableObject { serverVersion: serverVersion ) toolName = resolved.name - request = try await tool.prepare(request, resolved) + guard let prepared = try await prepare(request, with: { try await tool.prepare($0, resolved) }) else { + return + } + request = prepared let command = try Self.buildCommand( kind: kind, tool: tool, @@ -344,6 +348,30 @@ final class NativeDumpService: ObservableObject { Self.logger.info("\(self.toolName, privacy: .public) started db=\(database, privacy: .private(mask: .hash))") } + // Nil when a cancel arrived while it ran: the checks it was making stop, and the run reads as cancelled rather than + // as the error the stop caused. + func prepare( + _ request: NativeDumpDescriptor.Request, + with preparation: @escaping @Sendable (NativeDumpDescriptor.Request) async throws -> NativeDumpDescriptor.Request + ) async throws -> NativeDumpDescriptor.Request? { + guard !cancelRequested else { + setState(.cancelled) + return nil + } + let task = Task { try await preparation(request) } + self.preparation = task + defer { self.preparation = nil } + do { + return try await task.value + } catch { + guard !cancelRequested else { + setState(.cancelled) + return nil + } + throw error + } + } + /// Test-friendly entry: hands the job to the runner and wires up termination and progress. /// Skips dependency resolution. func run( @@ -397,6 +425,7 @@ final class NativeDumpService: ObservableObject { /// the user just said to leave alone. func cancel() { cancelRequested = true + preparation?.cancel() guard case .running = state else { return } setState(.cancelling) runner?.cancel() diff --git a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift index a674037d9f..1294eeafcb 100644 --- a/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift +++ b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift @@ -492,6 +492,32 @@ struct MySQLDumpPlaintextPasswordServiceTests { #expect(message == stderr) } + @Test("A cancel while the checks run stops them and reads as cancelled", .timeLimit(.minutes(1))) + func cancelDuringPreparation() async throws { + let service = NativeDumpService(kind: .restore, runnerFactory: { _ in + FinishingRunner(.init(exitCode: 0, stderr: "", wasCancelled: false)) + }) + let request = NativeDumpDescriptor.Request( + connection: DatabaseConnection(name: "Test", host: "db.example.com", port: 3_306, type: .mysql), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/cancelled.sql"), + password: nil + ) + let (started, signal) = AsyncStream.makeStream() + let pending = Task { + try await service.prepare(request) { request in + signal.yield() + try await Task.sleep(for: .seconds(60)) + return request + } + } + for await _ in started { break } + service.cancel() + let prepared = try await pending.value + #expect(prepared == nil) + #expect(service.state == .cancelled) + } + @Test("A cancel before the tool runs removes the plugin folder it never used") func cancelBeforeRunRemovesTheFolder() throws { let folder = try MariaDBClientPluginFolder.make(from: nil)