diff --git a/apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift b/apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift new file mode 100644 index 0000000000..ddad15d435 --- /dev/null +++ b/apple/TablePro/Core/Database/MariaDBBuiltInPlaintextCheck.swift @@ -0,0 +1,216 @@ +// +// 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 { + 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: Verdict]>(initialState: [:]) + + // 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 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 + case .refusesPlaintext: break + } + } + return combined + } + let verdict = await withTaskCancellationHandler { + await asking.value + } onCancel: { + asking.cancel() + } + if let key, verdict != .unknown { + verdicts.withLock { $0[key] = verdict } + } + return verdict + } + + 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 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 .unknown } + defer { try? manager.removeItem(at: folder) } + guard let listener = PluginCheckListener() else { return .unknown } + 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 .unknown + } + defer { + if process.isRunning { process.terminate() } + process.waitUntilExit() + } + + 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)) + var one: Int32 = 1 + setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + + guard send(packet: greeting, sequence: 0, to: client), + 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 &+ 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 + } + } + + 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 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 + return bytes.withUnsafeBytes { buffer in + Darwin.send(descriptor, buffer.baseAddress, buffer.count, 0) == buffer.count + } + } + + 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 .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) -> 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) + } + if read > 0 { + filled += read + } else if read == 0 || (errno != EAGAIN && errno != EWOULDBLOCK) { + return .closed + } else { + return .silent + } + } + return .packet(sequence: 0, payload: 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 new file mode 100644 index 0000000000..018cad23e1 --- /dev/null +++ b/apple/TablePro/Core/Database/MariaDBClientPluginFolder.swift @@ -0,0 +1,70 @@ +// +// MariaDBClientPluginFolder.swift +// TablePro +// + +import Foundation + +// 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" + ] + + // 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 + + 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: 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 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 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: #/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 + } + + 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..185ca3aadd 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,62 @@ 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 + // 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 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..053be2c114 --- /dev/null +++ b/apple/TablePro/Core/Database/MySQLServerGreeting.swift @@ -0,0 +1,114 @@ +// +// 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 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 + 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 4274cbfa88..8a9d301550 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: MariaDBClientPluginFolder? + 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 { @@ -148,6 +156,11 @@ 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 + + let clientPluginFolder: @Sendable (Request, NativeDumpResolvedTool) throws -> MariaDBClientPluginFolder? + init( backupBinaries: [String], restoreBinaries: [String], @@ -162,7 +175,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 [] }, + prepare: @escaping @Sendable (Request, NativeDumpResolvedTool) async throws -> Request = { request, _ in request }, + clientPluginFolder: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> MariaDBClientPluginFolder? = { _, _ in nil } ) { self.backupBinaries = backupBinaries self.restoreBinaries = restoreBinaries @@ -178,6 +193,8 @@ struct NativeDumpDescriptor: Sendable { self.restoreArguments = restoreArguments self.environment = environment self.configurationFileEntries = configurationFileEntries + self.prepare = prepare + 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..66c6de3a34 100644 --- a/apple/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/apple/TablePro/Core/Database/NativeDumpRegistry.swift @@ -217,6 +217,12 @@ enum NativeDumpRegistry { environment: { request in guard let password = request.password, !password.isEmpty else { return [:] } return ["MYSQL_PWD": password] + }, + prepare: { request, resolved in + try await mysqlPrepare(request, resolved) + }, + clientPluginFolder: { request, resolved in + try mysqlPluginFolder(request, resolved) } ) ), @@ -245,6 +251,85 @@ 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 + ) + guard hidesPlugins else { return request } + switch await MariaDBBuiltInPlaintextCheck.verdict(toolPath: resolved.path) { + case .refusesPlaintext: + break + case .sendsPlaintext: + 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 + ) + ) + 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 + } + + // 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 + ) throws -> MariaDBClientPluginFolder? { + guard MySQLClientArguments.hidesPlaintextPasswordPlugins( + request.connection, + flavor: resolved.flavor, + serverOffersTLS: request.serverOffersTLS + ) else { return nil } + return try MariaDBClientPluginFolder.make(from: MariaDBClientPluginFolder.pluginDirectory(forTool: resolved.path)) + } + private static func mysqlConnectionFlags( _ request: NativeDumpDescriptor.Request, _ resolved: NativeDumpResolvedTool @@ -261,12 +346,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..884698111a 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 } } @@ -100,6 +101,8 @@ struct NativeDumpCommand: Equatable { /// standard input. Removed once the process exits, however it exits. let temporaryCredentialsFileURL: URL? + 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. let isRestore: Bool @@ -114,6 +117,7 @@ struct NativeDumpCommand: Equatable { delivery: NativeDumpDescriptor.OutputDelivery = .toolWritesFile, redirectedFileURL: URL? = nil, temporaryCredentialsFileURL: URL? = nil, + clientPluginFolder: MariaDBClientPluginFolder? = nil, isRestore: Bool = false, exitPolicy: NativeDumpExitPolicy = .zeroExitOnly ) { @@ -124,6 +128,7 @@ struct NativeDumpCommand: Equatable { self.delivery = delivery self.redirectedFileURL = redirectedFileURL self.temporaryCredentialsFileURL = temporaryCredentialsFileURL + self.clientPluginFolder = clientPluginFolder self.isRestore = isRestore self.exitPolicy = exitPolicy } @@ -150,6 +155,19 @@ internal extension NativeDumpJob { guard case .process(let command) = self else { return .zeroExitOnly } return command.exitPolicy } + + var clientPluginFolder: MariaDBClientPluginFolder? { + 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. @@ -185,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() @@ -273,7 +292,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 +311,10 @@ final class NativeDumpService: ObservableObject { serverVersion: serverVersion ) toolName = resolved.name + 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, @@ -325,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( @@ -333,9 +380,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 +398,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 +407,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 + ) } } @@ -364,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() @@ -490,7 +552,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 +585,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 +658,8 @@ final class NativeDumpService: ObservableObject { result: NativeDumpRunResult, database: String, fileURL: URL, - exitPolicy: NativeDumpExitPolicy + exitPolicy: NativeDumpExitPolicy, + pluginFolder: MariaDBClientPluginFolder? ) { byteSizeTask?.cancel() byteSizeTask = nil @@ -625,9 +698,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/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index d1d10ab57d..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", @@ -176890,6 +176896,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 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..1294eeafcb --- /dev/null +++ b/apple/TableProTests/Database/MySQLDumpPlaintextPasswordTests.swift @@ -0,0 +1,582 @@ +// +// 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: [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) + } + + @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) + 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 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("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) + #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 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(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.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() + 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("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() + defer { try? FileManager.default.removeItem(at: source) } + 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(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.url.appendingPathComponent("parsec.so").path + ) + #expect(destination == source.appendingPathComponent("parsec.so").path) + + 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("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"# + #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) + } + + // 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. Bash, dd and od, because the check passes the tool no PATH. + private func fakeClient(sendsPassword: Bool) throws -> URL { + let script = """ + #!/bin/bash + while [ $# -gt 0 ]; do + [ "$1" = "-P" ] && port=$2 + shift + done + exec 3<>"/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) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url + } + + @Test("A client with a plain-text plugin built in is caught by asking it", arguments: [true, false]) + func builtInPlaintextPluginIsCaught(sendsPassword: Bool) async throws { + let client = try fakeClient(sendsPassword: sendsPassword) + defer { try? FileManager.default.removeItem(at: client.deletingLastPathComponent()) } + #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") + func builtInPlaintextPluginRefusesTheRun() async throws { + let client = try fakeClient(sendsPassword: true) + defer { try? FileManager.default.removeItem(at: client.deletingLastPathComponent()) } + let tool = try #require(NativeDumpRegistry.descriptor(for: .mysql)?.commandLineTool) + let resolved = NativeDumpResolvedTool(name: "mariadb-dump", path: client.path, flavor: .mariadb) + let request = NativeDumpDescriptor.Request( + connection: connection(.disabled), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/out.sql"), + password: nil + ) + 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), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/out.sql"), + password: nil + ) + _ = try await tool.prepare(required, resolved) + } + + @Test("The plugin folder is removed when the tool exits and when it cannot launch") + func runnerRemovesThePluginFolder() async throws { + 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.url.path)) + + 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.url.path)) + } + + private func runnerCommand(_ executable: String, folder: MariaDBClientPluginFolder) -> NativeDumpCommand { + NativeDumpCommand( + executable: URL(fileURLWithPath: executable), + arguments: ["-c", "exit 0"], + environment: [:], + stderrByteCap: 64_000, + clientPluginFolder: folder + ) + } + + 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 + } +} + +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 } + } +} + +@MainActor +struct MySQLDumpPlaintextPasswordServiceTests { + private func job(folder: URL) -> NativeDumpJob { + .process( + NativeDumpCommand( + executable: URL(fileURLWithPath: "/usr/bin/true"), + arguments: [], + environment: [:], + stderrByteCap: 64_000, + clientPluginFolder: MariaDBClientPluginFolder(url: folder) + ) + ) + } + + 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 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) + 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.url), database: "sales", fileURL: URL(fileURLWithPath: "/tmp/cancelled.sql")) + #expect(!runner.started) + #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/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/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. 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..3ca6685271 --- /dev/null +++ b/apple/scripts/probes/check-mysql-dump-plaintext-password.sh @@ -0,0 +1,150 @@ +#!/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, 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. +# +# 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: only the plugins of the tool's own folder that never send the password as typed. +plugin_folder() { + 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 + [ -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 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 + 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, 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 \ + "--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..c0a46bd5f3 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; 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() @@ -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,24 @@ 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 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": + 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: