diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index f3e9722eb..82ab98e47 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -90,7 +90,7 @@ jobs: run: git lfs pull - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: "26.4.1" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bd70b0bbd..20e4ba8f5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,7 +40,7 @@ jobs: uses: actions/checkout@v4 - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4.1' @@ -155,7 +155,7 @@ jobs: uses: actions/checkout@v4 - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4.1' @@ -305,7 +305,7 @@ jobs: fetch-depth: 0 - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4.1' @@ -393,7 +393,7 @@ jobs: run: scripts/ci/extract-release-notes.sh "${GITHUB_REF#refs/tags/v}" - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 with: files: | artifacts/*.dmg diff --git a/.github/workflows/ios-tests.yml b/.github/workflows/ios-tests.yml index 791149258..2adda6860 100644 --- a/.github/workflows/ios-tests.yml +++ b/.github/workflows/ios-tests.yml @@ -38,7 +38,7 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4.1' diff --git a/.github/workflows/macos-tests.yml b/.github/workflows/macos-tests.yml index 2a8a746a2..748ff15b6 100644 --- a/.github/workflows/macos-tests.yml +++ b/.github/workflows/macos-tests.yml @@ -46,7 +46,7 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4.1' @@ -61,7 +61,7 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4.1' @@ -82,7 +82,7 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4.1' diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3badf6d..b00aa7199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Mobile keeps remote connections open when you switch apps. +- Mobile no longer copies database passwords to iCloud Keychain unless you turn on Sync Passwords. Mac already worked this way. +- An imported connection link now shows the startup SQL and driver options it carries, before you add it. + +### Security + +- SQL Server Verify CA and Verify Identity now check the certificate for real on Mac. Both used to encrypt without checking anything, while the picker said otherwise. +- A connection's password source no longer runs if the connections file was edited outside TablePro. Save the connection again from the app to confirm the change. +- Mobile now checks the SSH server's host key before sending any credential, and asks you the first time it sees a server. It never checked at all, so anyone intercepting the connection received the SSH password. +- libssh2 is patched against CVE-2026-55199, where a malicious SSH server could pin a CPU core before authentication. +- An import link can no longer make TablePro fetch an AWS credential and send it to the link author's server. +- An import link can no longer preset the fields that decide where a connection looks for its password. +- A changed SSH host key is now reported as changed even when the server offers a different key type. Presenting a new key type used to get the milder first-use prompt. +- Trusting an unknown SSH host key now takes a click. Return picks Cancel. +- MySQL and MariaDB connections refuse a server's request to read a local file. +- A plugin's signature is rechecked immediately before it is loaded, and again before a staged update replaces the installed copy. +- Connections pulled from a team library no longer carry startup SQL or credential-resolution options. +- ClickHouse Verify CA now reads a PEM certificate authority file, and refuses to connect when the file cannot be read. It used to fall back to the public root store without saying so. +- Copy Connection String marks the clipboard item so clipboard-history apps leave it out of their history. +- The MCP server refuses a request whose browser Origin is not on its allow list, which closes a DNS-rebinding route to the local port. +- The MCP connection listing no longer names connections you set to AI Never. +- Release and test workflows pin their third-party actions to an exact commit. ### Fixed -- TablePro Mobile no longer gets killed by iOS when you leave the app with a DuckDB file open. - Mobile sends the client certificate and key on MySQL and PostgreSQL connections that use mutual TLS. (#2083) - Editing a connection on mobile no longer wipes its SSL settings and per-database options, which then synced the loss back to the Mac. (#2083) - A connection whose certificate is missing now says so, instead of connecting without it while still demanding server verification. (#2083) diff --git a/Libs/checksums.sha256 b/Libs/checksums.sha256 index af32116db..527721bc0 100644 --- a/Libs/checksums.sha256 +++ b/Libs/checksums.sha256 @@ -42,10 +42,10 @@ efba529b1ad767de988a58ca2c3fdcc26c38ce79df044a988f41fddbf9fde118 Libs/libpgport b86ecf68d2b0dd8aa7712d13607c9584df2297aca4cd651428e8ee974c6bdf80 Libs/libpq_universal.a 1ce2b45af228915fad05e07f54e96621af7143e199e002e5100777261a7f4a13 Libs/libpq_x86_64.a b86ecf68d2b0dd8aa7712d13607c9584df2297aca4cd651428e8ee974c6bdf80 Libs/libpq.a -6d737d744b5a2494bca0eee9091166d7e15912a3eca5aa0f74644393cc2ce087 Libs/libssh2_arm64.a -c6e3dbcb3d79d740a8bcab355ae10389a39d99e0c8594528202d0eb8c78a5e18 Libs/libssh2_universal.a -bd4dab1e2b24fa695bad8c951d5a2c3271c4c4436f0651f276c09899126fe088 Libs/libssh2_x86_64.a -c6e3dbcb3d79d740a8bcab355ae10389a39d99e0c8594528202d0eb8c78a5e18 Libs/libssh2.a +ba616a85e4de9800ba73095a888602c5202d320fd8d6cca80cf489ca7d923372 Libs/libssh2_arm64.a +c1249063c458a7db695d866c379ba21173fab24b41c4f94bf50370ebbc1202a1 Libs/libssh2_universal.a +c7d0d6e1caa7b44f56712f8f9f01b6f232dbb2c9888843c44c11b64c6f3af7fb Libs/libssh2_x86_64.a +c1249063c458a7db695d866c379ba21173fab24b41c4f94bf50370ebbc1202a1 Libs/libssh2.a b3861975896ebf35255d8c3efccdc59ad39874c9b70fdd710ebd15f0a58c4e10 Libs/libssl_arm64.a 3ca208dedf57dbae4f5cb0a22bfbedeba80dc6740d626484d9d815811d64a2aa Libs/libssl_universal.a 34de647ccd0951095f987591562a5236348bac2d4b3e217877559a7b170cf4e4 Libs/libssl_x86_64.a diff --git a/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift b/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift index 468a68148..2eead45c5 100644 --- a/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift +++ b/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift @@ -150,11 +150,39 @@ public struct ExportableConnection: Codable { } public extension ExportableConnection { - static let importBlockedAdditionalFieldKeys: Set = ["preConnectScript"] + static let importBlockedAdditionalFieldKeys: Set = [ + "preconnectscript", + "pretunnelhost", + "pretunnelport", + "promptforpassword", + "sslclientkeypassphrase", + "usepgpass", + ] + + static let importBlockedAdditionalFieldPrefixes: Set = ["aws"] + + static func isImportBlockedAdditionalFieldKey(_ key: String) -> Bool { + let normalized = key.lowercased() + if importBlockedAdditionalFieldKeys.contains(normalized) { return true } + return importBlockedAdditionalFieldPrefixes.contains { normalized.hasPrefix($0) } + } + + func withoutStartupCommands() -> ExportableConnection { + guard startupCommands != nil else { return self } + return ExportableConnection( + name: name, host: host, port: port, database: database, + username: username, type: type, sshConfig: sshConfig, + sslConfig: sslConfig, color: color, tagName: tagName, tagNames: tagNames, + groupName: groupName, sshProfileId: sshProfileId, + safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, + additionalFields: additionalFields, redisDatabase: redisDatabase, + startupCommands: nil, localOnly: localOnly + ) + } func sanitizedForImport() -> ExportableConnection { guard let additionalFields else { return self } - let allowed = additionalFields.filter { !Self.importBlockedAdditionalFieldKeys.contains($0.key) } + let allowed = additionalFields.filter { !Self.isImportBlockedAdditionalFieldKey($0.key) } guard allowed.count != additionalFields.count else { return self } return ExportableConnection( name: name, host: host, port: port, database: database, diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift index 54a8e1ba2..2a3c5ee09 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift @@ -14,6 +14,11 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { public var kerberosCachePath: String? public var kerberosServicePrincipal: String? + /// How far to check the server certificate. Set after init by the plugin from the + /// connection's SSL mode, so the existing initializer keeps its signature. + public var certificateVerification: MSSQLCertificateVerification = .none + public var caCertificatePath: String? + public static let defaultPort = 1433 public static let defaultSchema = "dbo" public static let defaultApplicationName = "TablePro" diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift new file mode 100644 index 000000000..642a1a305 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift @@ -0,0 +1,64 @@ +// +// MSSQLFreeTDSConfig.swift +// TableProMSSQLCore +// +// FreeTDS db-lib exposes no per-connection API for certificate validation: DBSETENCRYPT only +// says whether to encrypt. The `ca file` and `check certificate hostname` settings live in +// freetds.conf, so a connection that must verify writes its own one-entry config and points +// FREETDSCONF at it for the duration of the dbopen call. +// + +import Foundation + +public enum MSSQLCertificateVerification: Equatable, Sendable { + /// Encrypt without checking who is on the other end. What "Required" has always meant. + case none + /// Check that the server certificate chains to the given authority. + case chain + /// Check the chain and that the certificate names the host being dialled. + case chainAndHostname + + public var checksHostname: Bool { self == .chainAndHostname } + public var needsAuthority: Bool { self != .none } +} + +public enum MSSQLFreeTDSConfig { + /// The name the generated entry carries, and the name handed to dbopen in place of host:port. + public static let serverEntryName = "TableProServer" + + /// macOS ships the system roots as a PEM bundle, which is what FreeTDS wants. Without this a + /// verifying mode would need a CA file from the user even for a public certificate authority. + public static let systemTrustStorePath = "/etc/ssl/cert.pem" + + public static func authorityPath(userSupplied: String?) -> String { + guard let userSupplied, !userSupplied.trimmingCharacters(in: .whitespaces).isEmpty else { + return systemTrustStorePath + } + return userSupplied + } + + public static func configuration( + host: String, + port: Int, + encryptionFlag: String, + verification: MSSQLCertificateVerification, + caCertificatePath: String? + ) -> String { + var lines = [ + "[\(serverEntryName)]", + "\thost = \(host)", + "\tport = \(port)", + "\ttds version = 7.4", + "\tencryption = \(encryptionFlag)", + ] + + if verification.needsAuthority { + lines.append("\tca file = \(authorityPath(userSupplied: caCertificatePath))") + } + if verification.checksHostname { + lines.append("\tcheck certificate hostname = yes") + } + + return lines.joined(separator: "\n") + "\n" + } +} diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index ff8975073..203fa8d1f 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -722,6 +722,7 @@ private final class ClickHouseTLSDelegate: NSObject, URLSessionDelegate, @unchec private enum Strategy { case skipVerify case verifyChain(anchor: SecCertificate?) + case anchorUnavailable } private let strategy: Strategy @@ -739,15 +740,24 @@ private final class ClickHouseTLSDelegate: NSObject, URLSessionDelegate, @unchec case .preferred, .required: return ClickHouseTLSDelegate(strategy: .skipVerify) case .verifyCa: - return ClickHouseTLSDelegate(strategy: .verifyChain(anchor: loadAnchor(at: ssl.caCertificatePath))) + guard !ssl.caCertificatePath.isEmpty else { + return ClickHouseTLSDelegate(strategy: .verifyChain(anchor: nil)) + } + guard let anchor = loadAnchor(at: ssl.caCertificatePath) else { + return ClickHouseTLSDelegate(strategy: .anchorUnavailable) + } + return ClickHouseTLSDelegate(strategy: .verifyChain(anchor: anchor)) } } + /// A verification mode whose anchor cannot be read must fail, never quietly widen to the + /// system roots. `SecCertificateCreateWithData` takes DER only, so PEM is decoded first. private static func loadAnchor(at path: String) -> SecCertificate? { guard !path.isEmpty, let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil } - return SecCertificateCreateWithData(nil, data as CFData) + guard let der = PEMCertificateDecoder.certificateDER(from: data) else { return nil } + return SecCertificateCreateWithData(nil, der as CFData) } func urlSession( @@ -764,6 +774,8 @@ private final class ClickHouseTLSDelegate: NSObject, URLSessionDelegate, @unchec switch strategy { case .skipVerify: completionHandler(.useCredential, URLCredential(trust: serverTrust)) + case .anchorUnavailable: + completionHandler(.cancelAuthenticationChallenge, nil) case .verifyChain(let anchor): if let anchor { SecTrustSetAnchorCertificates(serverTrust, [anchor] as CFArray) diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index bae84dca8..0ec3e0df7 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -126,6 +126,7 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { private var _isCancelled = false private static let kerberosEnvLock = NSLock() + private static let freetdsConfEnvLock = NSLock() private static let deadlineQueue = DispatchQueue(label: "com.TablePro.freetds.connect-deadline", qos: .userInitiated) private static let connectDeadlineMarginSeconds = 5 @@ -200,8 +201,11 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { #endif freetdsClearError(for: nil) - let serverName = "\(options.host):\(options.port)" - guard let proc = withKerberosEnvironmentIfNeeded({ dbopen(login, serverName) }) else { + let verifies = options.certificateVerification != .none + let serverName = verifies ? MSSQLFreeTDSConfig.serverEntryName : "\(options.host):\(options.port)" + guard let proc = withFreeTDSConfigIfNeeded({ + self.withKerberosEnvironmentIfNeeded { dbopen(login, serverName) } + }) else { let detail = freetdsGetError(for: nil) let msg = detail.isEmpty ? "Check host, port, credentials, and TLS settings" : detail if let kind = MSSQLTLSClassifier.classifySSLError(detail) { @@ -215,6 +219,47 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { return proc } + /// A verifying mode needs `ca file` and `check certificate hostname`, which dblib cannot set. + /// The generated config is written 0600 and FREETDSCONF points at it only for this dbopen, so + /// a machine's own freetds.conf is untouched on every other connection. + private func withFreeTDSConfigIfNeeded( + _ body: () -> UnsafeMutablePointer? + ) -> UnsafeMutablePointer? { + guard options.certificateVerification != .none else { return body() } + + let contents = MSSQLFreeTDSConfig.configuration( + host: options.host, + port: options.port, + encryptionFlag: options.encryptionFlag, + verification: options.certificateVerification, + caCertificatePath: options.caCertificatePath + ) + + let path = NSTemporaryDirectory() + "tablepro-freetds-\(UUID().uuidString).conf" + guard let data = contents.data(using: .utf8), + FileManager.default.createFile( + atPath: path, + contents: data, + attributes: [.posixPermissions: 0o600] + ) else { + return body() + } + + Self.freetdsConfEnvLock.lock() + let previous = getenv("FREETDSCONF").map { String(cString: $0) } + setenv("FREETDSCONF", path, 1) + defer { + if let previous { + setenv("FREETDSCONF", previous, 1) + } else { + unsetenv("FREETDSCONF") + } + Self.freetdsConfEnvLock.unlock() + try? FileManager.default.removeItem(atPath: path) + } + return body() + } + private func withKerberosEnvironmentIfNeeded( _ body: () -> UnsafeMutablePointer? ) -> UnsafeMutablePointer? { diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 9079ae7ee..ec9f028a8 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -278,7 +278,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { do { let kerberosCachePath = try await acquireKerberosTicketIfNeeded(authMethod: authMethod) let kerberosServicePrincipal = try await resolveKerberosServicePrincipal(authMethod: authMethod) - let options = MSSQLConnectionOptions( + var options = MSSQLConnectionOptions( host: config.host, port: config.port, user: config.username, @@ -290,6 +290,8 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { kerberosCachePath: kerberosCachePath, kerberosServicePrincipal: kerberosServicePrincipal ) + options.certificateVerification = MSSQLSSLMapping.certificateVerification(for: config.ssl.mode) + options.caCertificatePath = config.ssl.caCertificatePath conn = FreeTDSConnection(options: options) try await conn.connect() } catch let error as MSSQLCoreError { diff --git a/Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift b/Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift index 83880de04..0eab283db 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift @@ -1,12 +1,10 @@ import Foundation +import TableProMSSQLCore import TableProPluginKit -/// FreeTDS dblib reads this value via DBSETENCRYPT. Accepted values come from -/// libtds: "off", "request", "require", "strict". Cert verification beyond what -/// the system trust store provides is configured in freetds.conf, not per -/// connection through dblib, so .verifyCa and .verifyIdentity both map to -/// "require"; the actual verification depends on the trust store and any -/// freetds.conf overrides on the machine. +/// FreeTDS dblib reads the encryption level via DBSETENCRYPT. Accepted values come from libtds: +/// "off", "request", "require", "strict". Certificate validation is not reachable through dblib +/// at all, so a verifying mode also produces a generated freetds.conf; see MSSQLFreeTDSConfig. enum MSSQLSSLMapping { static func freetdsEncryptionFlag(for mode: SSLMode) -> String { switch mode { @@ -15,4 +13,12 @@ enum MSSQLSSLMapping { case .required, .verifyCa, .verifyIdentity: return "require" } } + + static func certificateVerification(for mode: SSLMode) -> MSSQLCertificateVerification { + switch mode { + case .disabled, .preferred, .required: return .none + case .verifyCa: return .chain + case .verifyIdentity: return .chainAndHostname + } + } } diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index a632c4452..38a477dfe 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -273,6 +273,9 @@ final class MariaDBPluginConnection: @unchecked Sendable { var protocol_tcp = UInt32(MYSQL_PROTOCOL_TCP.rawValue) mysql_options(mysql, MYSQL_OPT_PROTOCOL, &protocol_tcp) + var allowLocalInfile: UInt32 = 0 + mysql_options(mysql, MYSQL_OPT_LOCAL_INFILE, &allowLocalInfile) + var sslEnforce: my_bool = enforceSSL ? 1 : 0 mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) @@ -396,6 +399,9 @@ final class MariaDBPluginConnection: @unchecked Sendable { mysql_options(killConn, MYSQL_OPT_READ_TIMEOUT, &killTimeout) mysql_options(killConn, MYSQL_OPT_WRITE_TIMEOUT, &killTimeout) + var killAllowLocalInfile: UInt32 = 0 + mysql_options(killConn, MYSQL_OPT_LOCAL_INFILE, &killAllowLocalInfile) + let killResult = host.withCString { hostPtr in user.withCString { userPtr in if let pass = password { diff --git a/Plugins/TableProPluginKit/PEMCertificateDecoder.swift b/Plugins/TableProPluginKit/PEMCertificateDecoder.swift new file mode 100644 index 000000000..33a6183e2 --- /dev/null +++ b/Plugins/TableProPluginKit/PEMCertificateDecoder.swift @@ -0,0 +1,51 @@ +// +// PEMCertificateDecoder.swift +// TableProPluginKit +// +// Security framework certificate APIs take DER only. A user pointing a "Verify CA" +// setting at a PEM file is the common case, so the DER has to be recovered first. +// + +import Foundation + +public enum PEMCertificateDecoder { + private static let beginMarker = "-----BEGIN CERTIFICATE" + private static let endMarker = "-----END CERTIFICATE" + + /// The DER bytes of the first certificate in a PEM document, or nil when the text + /// carries no complete certificate block. + public static func firstCertificateDER(inPEM text: String) -> Data? { + var base64 = "" + var insideCertificate = false + var sawEndMarker = false + + for line in text.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix(beginMarker) { + insideCertificate = true + continue + } + if trimmed.hasPrefix(endMarker) { + sawEndMarker = true + break + } + if insideCertificate { + base64 += trimmed + } + } + + guard insideCertificate, sawEndMarker, !base64.isEmpty else { return nil } + return Data(base64Encoded: base64) + } + + /// The DER bytes for a certificate file that may be either DER or PEM encoded. A file that + /// announces a certificate block it cannot decode yields nil rather than its own raw bytes, + /// so a caller cannot mistake undecodable text for a usable anchor. + public static func certificateDER(from data: Data) -> Data? { + guard let text = String(data: data, encoding: .utf8), text.contains(beginMarker) else { + return data + } + return firstCertificateDER(inPEM: text) + } +} diff --git a/TablePro/Core/Database/AWS/RDSSigningEndpoint.swift b/TablePro/Core/Database/AWS/RDSSigningEndpoint.swift index 9c416db1f..2e37c74ca 100644 --- a/TablePro/Core/Database/AWS/RDSSigningEndpoint.swift +++ b/TablePro/Core/Database/AWS/RDSSigningEndpoint.swift @@ -28,12 +28,18 @@ enum RDSSigningEndpointResolver { override: String?, defaultPort: Int ) throws -> RDSSigningEndpoint { + let dialsLoopback = isLoopback(configuredHost) + if let override, !override.trimmingCharacters(in: .whitespaces).isEmpty { - return try parse(override, defaultPort: defaultPort) + let endpoint = try parse(override, defaultPort: defaultPort) + guard dialsLoopback || endpoint.host.caseInsensitiveCompare(configuredHost) == .orderedSame else { + throw AWSAuthError.rdsEndpointUnresolved(host: configuredHost) + } + return endpoint } - let host = preTunnelHost ?? configuredHost - let port = preTunnelPort ?? configuredPort + let host = dialsLoopback ? (preTunnelHost ?? configuredHost) : configuredHost + let port = dialsLoopback ? (preTunnelPort ?? configuredPort) : configuredPort guard !isLoopback(host) else { throw AWSAuthError.rdsEndpointUnresolved(host: host) } diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 88975cd36..14b3e4431 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -580,6 +580,9 @@ enum DatabaseDriverFactory { } if let override { return override } if let passwordSource = connection.passwordSource { + guard await ConnectionStorage.shared.storeIsTrusted else { + throw PasswordSourceResolver.ResolutionError.storeNotTrusted + } return try await PasswordSourceResolver.resolve(passwordSource) } if connection.usePgpass { diff --git a/TablePro/Core/Database/PostgresDumpService.swift b/TablePro/Core/Database/PostgresDumpService.swift index dacf723f0..e7dfa06d1 100644 --- a/TablePro/Core/Database/PostgresDumpService.swift +++ b/TablePro/Core/Database/PostgresDumpService.swift @@ -330,7 +330,7 @@ final class PostgresDumpService { ? String(format: String(localized: "Process exited with code %d"), Int(result.exitCode)) : result.stderr setState(.failed(message: summary)) - Self.logger.error("\(self.kind == .backup ? "pg_dump" : "pg_restore", privacy: .public) failed code=\(result.exitCode) db=\(database, privacy: .public) stderr=\(result.stderr, privacy: .public)") + Self.logger.error("\(self.kind == .backup ? "pg_dump" : "pg_restore", privacy: .public) failed code=\(result.exitCode) db=\(database, privacy: .public) stderr=\(result.stderr)") } private func startByteSizePolling(url: URL, database: String, totalBytes: Int64?) { diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index b90953d9d..a43cc55b6 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -9,8 +9,10 @@ public actor MCPConnectionBridge { func listConnections() async -> JsonValue { let (connections, activeSessions) = await MainActor.run { + let defaultPolicy = AppSettingsManager.shared.ai.defaultConnectionPolicy let conns = ConnectionStorage.shared.loadConnections() .filter { $0.externalAccess != .blocked } + .filter { ($0.aiPolicy ?? defaultPolicy) != .never } let sessions = DatabaseManager.shared.activeSessions return (conns, sessions) } diff --git a/TablePro/Core/MCP/Transport/MCPHttpRequestRouter.swift b/TablePro/Core/MCP/Transport/MCPHttpRequestRouter.swift index 5b6f09be4..1d76191c4 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpRequestRouter.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpRequestRouter.swift @@ -24,7 +24,16 @@ struct MCPHttpRequestRouter: Sendable { let clientAddress: MCPClientAddress = await context.clientAddress() let now = await clock.now() - await context.setOrigin(head.headers.value(for: "Origin")) + let origin = head.headers.value(for: "Origin") + await context.setOrigin(origin) + + // A browser attacking the loopback server through DNS rebinding always sends an Origin. + // A native client sends none, so an absent header stays allowed. This runs before every + // route, including the ones that authenticate themselves. + if let origin, !origin.isEmpty, !MCPCorsHeaders.isAllowed(origin: origin) { + await respondHttpForbiddenOrigin(context: context) + return + } if head.method == .post, stripQueryString(head.path) == "/v1/integrations/exchange" { await handleIntegrationsExchange(body: body, context: context) @@ -428,6 +437,15 @@ struct MCPHttpRequestRouter: Sendable { await context.cancel() } + private func respondHttpForbiddenOrigin(context: HttpConnectionContext) async { + await context.writePlainJsonError( + status: .forbidden, + error: "forbidden_origin", + errorDescription: "This origin is not allowed to reach TablePro's MCP server." + ) + await context.cancel() + } + private func respondHttpMethodNotAllowed(context: HttpConnectionContext) async { await context.writePlainJsonError( status: .methodNotAllowed, diff --git a/TablePro/Core/Plugins/PluginInstaller.swift b/TablePro/Core/Plugins/PluginInstaller.swift index c06bcf454..82761f65c 100644 --- a/TablePro/Core/Plugins/PluginInstaller.swift +++ b/TablePro/Core/Plugins/PluginInstaller.swift @@ -70,6 +70,10 @@ actor PluginInstaller { guard let stagedURL = stagedUpdates[pluginId] else { throw PluginError.notFound } + guard let stagedBundle = Bundle(url: stagedURL) else { + throw PluginError.invalidBundle("Cannot create bundle from \(stagedURL.lastPathComponent)") + } + try PluginCodeSignatureVerifier.verify(bundle: stagedBundle) let bundleName = stagedURL.deletingPathExtension().lastPathComponent let destURL = userPluginsDir.appendingPathComponent("\(bundleName).tableplugin", isDirectory: true) let finalURL = try Self.atomicReplace(stagedBundleURL: stagedURL, destURL: destURL) @@ -181,7 +185,11 @@ actor PluginInstaller { ) } - guard let downloadURL = URL(string: binary.downloadURL) else { + // The registry manifest is fetched over the network, so the URL it names is untrusted + // input. The code-signature check is the real gate, but nothing should be fetched over + // cleartext on the way to it. + guard let downloadURL = URL(string: binary.downloadURL), + downloadURL.scheme?.lowercased() == "https" else { throw PluginError.downloadFailed("Invalid download URL") } diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index ee93413a1..db5e21d2b 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -549,6 +549,10 @@ final class PluginManager { try validateBundleVersions(bundle) + if source != .builtIn { + try PluginCodeSignatureVerifier.verify(bundle: bundle) + } + try PluginBundleLoader.load(bundle) return bundle diff --git a/TablePro/Core/SSH/HostKeyStore.swift b/TablePro/Core/SSH/HostKeyStore.swift index 1882e5476..dd5eb084b 100644 --- a/TablePro/Core/SSH/HostKeyStore.swift +++ b/TablePro/Core/SSH/HostKeyStore.swift @@ -60,17 +60,20 @@ internal final class HostKeyStore: @unchecked Sendable { let currentFingerprint = Self.fingerprint(of: keyData) let entries = loadEntries() - guard let existing = entries.first(where: { $0.host == hostKey && $0.keyType == keyType }) else { + let hostEntries = entries.filter { $0.host == hostKey } + guard !hostEntries.isEmpty else { Self.logger.info("Unknown host key for \(hostKey)") return .unknown(fingerprint: currentFingerprint, keyType: keyType) } - let storedFingerprint = Self.fingerprint(of: existing.keyData) - if storedFingerprint == currentFingerprint { + if hostEntries.contains(where: { Self.fingerprint(of: $0.keyData) == currentFingerprint }) { Self.logger.debug("Host key trusted for \(hostKey)") return .trusted } + let sameType = hostEntries.first { $0.keyType == keyType } + let storedFingerprint = Self.fingerprint(of: (sameType ?? hostEntries[0]).keyData) + Self.logger.warning("Host key mismatch for \(hostKey)") return .mismatch(expected: storedFingerprint, actual: currentFingerprint) } diff --git a/TablePro/Core/SSH/HostKeyVerifier.swift b/TablePro/Core/SSH/HostKeyVerifier.swift index 7caf12d01..9f8c10633 100644 --- a/TablePro/Core/SSH/HostKeyVerifier.swift +++ b/TablePro/Core/SSH/HostKeyVerifier.swift @@ -107,9 +107,12 @@ internal enum HostKeyVerifier { let alert = NSAlert() alert.messageText = title alert.informativeText = message - alert.alertStyle = .informational - alert.addButton(withTitle: String(localized: "Trust")) - alert.addButton(withTitle: String(localized: "Cancel")) + alert.alertStyle = .warning + AlertHelper.addConfirmAndCancel( + to: alert, + confirmButton: String(localized: "Trust"), + cancelButton: String(localized: "Cancel") + ) if let window = AlertHelper.resolveWindow(nil) { return await withCheckedContinuation { continuation in diff --git a/TablePro/Core/Services/Infrastructure/ClipboardService.swift b/TablePro/Core/Services/Infrastructure/ClipboardService.swift index 1c9e3cf96..f9e3b4384 100644 --- a/TablePro/Core/Services/Infrastructure/ClipboardService.swift +++ b/TablePro/Core/Services/Infrastructure/ClipboardService.swift @@ -22,11 +22,22 @@ protocol ClipboardProvider { var hasGridRows: Bool { get } } +extension ClipboardProvider { + /// Text a clipboard-history app should not retain. Providers that cannot express that + /// fall back to a plain write rather than refusing to copy. + func writeSecretText(_ text: String) { + writeText(text) + } +} + struct NSPasteboardClipboardProvider: ClipboardProvider { private static let tsvType = NSPasteboard.PasteboardType("public.utf8-tab-separated-values-text") private static let csvType = NSPasteboard.PasteboardType("public.comma-separated-values-text") private static let gridRowsType = NSPasteboard.PasteboardType("com.TablePro.gridRows") + /// The convention clipboard managers watch for to keep an item out of their history. + private static let concealedType = NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType") + func readText() -> String? { NSPasteboard.general.string(forType: .string) } @@ -43,6 +54,14 @@ struct NSPasteboardClipboardProvider: ClipboardProvider { pb.setString(text, forType: NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)) } + func writeSecretText(_ text: String) { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(text, forType: .string) + pb.setString(text, forType: NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)) + pb.setString(text, forType: Self.concealedType) + } + func writeCsv(_ csv: String) { let pb = NSPasteboard.general pb.clearContents() diff --git a/TablePro/Core/Services/TeamLibrary/TeamLibraryStore.swift b/TablePro/Core/Services/TeamLibrary/TeamLibraryStore.swift index c4aedcc31..0bd2a6132 100644 --- a/TablePro/Core/Services/TeamLibrary/TeamLibraryStore.swift +++ b/TablePro/Core/Services/TeamLibrary/TeamLibraryStore.swift @@ -36,14 +36,15 @@ actor TeamLibraryStore { guard let data = try? Data(contentsOf: fileURL) else { return nil } - cached = try? JSONDecoder().decode(TeamLibraryPullResponse.self, from: data) + cached = (try? JSONDecoder().decode(TeamLibraryPullResponse.self, from: data))?.sanitized() return cached } func replace(_ response: TeamLibraryPullResponse) { - cached = response + let sanitized = response.sanitized() + cached = sanitized do { - try JSONEncoder().encode(response).write(to: fileURL, options: .atomic) + try JSONEncoder().encode(sanitized).write(to: fileURL, options: .atomic) } catch { Self.logger.error("Failed to cache team library: \(error.localizedDescription)") } diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 998c5e2f0..4414e568a 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -27,6 +27,10 @@ final class ConnectionStorage { /// In-memory cache to avoid re-decoding JSON from file on every access private var cachedConnections: [DatabaseConnection]? + /// Whether the file on disk is the one TablePro last wrote. False once it has been edited by + /// something else, which is the signal to refuse to run a connection's password source. + private(set) var storeIsTrusted = true + private let fileURL: URL private let keychain: any KeychainStoring @@ -78,9 +82,26 @@ final class ConnectionStorage { if let cached = cachedConnections { return cached } guard let data = try? Data(contentsOf: fileURL) else { + storeIsTrusted = true return [] } + switch ConnectionStoreIntegrity.shared.verify(data, fileURL: fileURL) { + case .trusted: + storeIsTrusted = true + case .unstamped: + // An install that predates the tag. Adopt the file as it stands, which is the only + // option without a prior baseline, and stamp it so later edits are detectable. + ConnectionStoreIntegrity.shared.stamp(data, fileURL: fileURL) + storeIsTrusted = true + case .modified: + Self.logger.warning("connections.json changed outside TablePro; password sources will not run") + storeIsTrusted = false + case .unavailable: + Self.logger.warning("No connection store integrity key; password sources will not run") + storeIsTrusted = false + } + do { let storedConnections = try decoder.decode([StoredConnection].self, from: data) @@ -125,6 +146,9 @@ final class ConnectionStorage { do { let data = try encoder.encode(storedConnections) try data.write(to: fileURL, options: .atomic) + // Trust follows the tag. If no tag could be written, later edits are undetectable, + // so the store is not treated as trusted. + storeIsTrusted = ConnectionStoreIntegrity.shared.stamp(data, fileURL: fileURL) cachedConnections = nil return true } catch { diff --git a/TablePro/Core/Storage/ConnectionStoreIntegrity.swift b/TablePro/Core/Storage/ConnectionStoreIntegrity.swift new file mode 100644 index 000000000..e62997d79 --- /dev/null +++ b/TablePro/Core/Storage/ConnectionStoreIntegrity.swift @@ -0,0 +1,150 @@ +// +// ConnectionStoreIntegrity.swift +// TablePro +// +// connections.json is ordinary user-writable storage, and a connection in it can declare a +// password source that TablePro executes at connect time. This binds the file to a key only +// TablePro can read, so a record written by another process is not acted on. +// +// The tag lives beside the file; forging it needs the key, which the keychain holds under the +// app's own access control. +// + +import CryptoKit +import Foundation +import os +import Security + +protocol IntegrityKeySource: Sendable { + /// The key this device signs its connection store with, or nil when no key can be obtained. + func key() -> SymmetricKey? +} + +struct ConnectionStoreIntegrity: Sendable { + enum Verdict: Equatable { + /// The tag matches, so the file is the one TablePro last wrote. + case trusted + /// No tag yet. An install that predates this check, or a fresh store. + case unstamped + /// A tag exists and does not match. The file changed outside TablePro. + case modified + /// No key, so nothing can be said either way. Treated as untrusted rather than assumed + /// good, for the same reason an unreadable certificate authority fails a connection. + case unavailable + } + + static let shared = ConnectionStoreIntegrity() + + private let keySource: any IntegrityKeySource + + init(keySource: any IntegrityKeySource = KeychainIntegrityKeySource()) { + self.keySource = keySource + } + + static func tagURL(for fileURL: URL) -> URL { + fileURL.appendingPathExtension("hmac") + } + + func verify(_ data: Data, fileURL: URL) -> Verdict { + guard let key = keySource.key() else { return .unavailable } + guard let storedTag = try? Data(contentsOf: Self.tagURL(for: fileURL)), !storedTag.isEmpty else { + return .unstamped + } + + let expected = Data(HMAC.authenticationCode(for: data, using: key)) + return Self.constantTimeEquals(expected, storedTag) ? .trusted : .modified + } + + @discardableResult + func stamp(_ data: Data, fileURL: URL) -> Bool { + guard let key = keySource.key() else { return false } + let tag = Data(HMAC.authenticationCode(for: data, using: key)) + do { + try tag.write(to: Self.tagURL(for: fileURL), options: .atomic) + return true + } catch { + Self.logger.error("Could not write the connection store tag: \(error.localizedDescription)") + return false + } + } + + static func constantTimeEquals(_ lhs: Data, _ rhs: Data) -> Bool { + guard lhs.count == rhs.count else { return false } + var difference: UInt8 = 0 + for (left, right) in zip(lhs, rhs) { + difference |= left ^ right + } + return difference == 0 + } + + fileprivate static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionStoreIntegrity") +} + +/// Holds the key in the keychain under the app's own access control, device local so a tag +/// written on one Mac is never compared against a file on another. +struct KeychainIntegrityKeySource: IntegrityKeySource { + private static let service = "com.TablePro" + private static let account = "com.TablePro.connectionStoreIntegrityKey" + private static let byteCount = 32 + + private static let lock = NSLock() + nonisolated(unsafe) private static var cached: SymmetricKey? + + func key() -> SymmetricKey? { + Self.lock.lock() + defer { Self.lock.unlock() } + + if let cached = Self.cached { return cached } + if let existing = Self.read() { + Self.cached = existing + return existing + } + guard let created = Self.create() else { return nil } + Self.cached = created + return created + } + + private static func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecUseDataProtectionKeychain as String: true, + ] + } + + private static func read() -> SymmetricKey? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data, + data.count == byteCount else { + return nil + } + return SymmetricKey(data: data) + } + + private static func create() -> SymmetricKey? { + var bytes = [UInt8](repeating: 0, count: byteCount) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { + ConnectionStoreIntegrity.logger.error("Could not generate a connection store integrity key") + return nil + } + + var addQuery = baseQuery() + addQuery[kSecValueData as String] = Data(bytes) + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + ConnectionStoreIntegrity.logger.error( + "Could not store the connection store integrity key (OSStatus \(status))" + ) + return nil + } + return SymmetricKey(data: Data(bytes)) + } +} diff --git a/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift index 4173cde27..9697337d5 100644 --- a/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift +++ b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift @@ -25,6 +25,7 @@ enum PasswordSourceResolver { case emptyPassword case invalidSecretJson case jsonKeyNotFound(key: String) + case storeNotTrusted var errorDescription: String? { switch self { @@ -57,6 +58,12 @@ enum PasswordSourceResolver { return String(localized: "The secret manager did not return valid JSON.") case let .jsonKeyNotFound(key): return String(format: String(localized: "Key %@ was not found in the secret JSON."), key) + case .storeNotTrusted: + return String(localized: """ + Your connections file was changed outside TablePro, so this connection's \ + password source was not run. Open the connection and save it again to \ + confirm the change. + """) } } } diff --git a/TablePro/Info.plist b/TablePro/Info.plist index 17d7c86e4..26bb8f3a9 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -313,6 +313,39 @@ NSExceptionRequiresForwardSecrecy + github.com + + NSIncludesSubdomains + + NSExceptionAllowsInsecureHTTPLoads + + NSExceptionMinimumTLSVersion + TLSv1.2 + NSExceptionRequiresForwardSecrecy + + + githubusercontent.com + + NSIncludesSubdomains + + NSExceptionAllowsInsecureHTTPLoads + + NSExceptionMinimumTLSVersion + TLSv1.2 + NSExceptionRequiresForwardSecrecy + + + jsdelivr.net + + NSIncludesSubdomains + + NSExceptionAllowsInsecureHTTPLoads + + NSExceptionMinimumTLSVersion + TLSv1.2 + NSExceptionRequiresForwardSecrecy + + NSUserActivityTypes diff --git a/TablePro/Models/TeamLibrary/TeamLibraryModels.swift b/TablePro/Models/TeamLibrary/TeamLibraryModels.swift index c57f0bc9c..b8687784d 100644 --- a/TablePro/Models/TeamLibrary/TeamLibraryModels.swift +++ b/TablePro/Models/TeamLibrary/TeamLibraryModels.swift @@ -106,6 +106,25 @@ struct TeamLibraryPullResponse: Codable { static let empty = TeamLibraryPullResponse(connections: [], queryFolders: [], queries: [], fetchedAt: "") + /// A pulled connection is authored by another account and lands on this device with no + /// confirmation step, so anything the app would act on by itself is dropped before caching. + func sanitized() -> TeamLibraryPullResponse { + TeamLibraryPullResponse( + connections: connections.map { + Connection( + id: $0.id, + sourceConnectionId: $0.sourceConnectionId, + payload: $0.payload.sanitizedForImport().withoutStartupCommands(), + publishedBy: $0.publishedBy, + publishedAt: $0.publishedAt + ) + }, + queryFolders: queryFolders, + queries: queries, + fetchedAt: fetchedAt + ) + } + struct Connection: Codable, Identifiable { let id: String let sourceConnectionId: String? diff --git a/TablePro/Views/Connection/DeeplinkImportSheet.swift b/TablePro/Views/Connection/DeeplinkImportSheet.swift index 7a50acdf0..ad9416fca 100644 --- a/TablePro/Views/Connection/DeeplinkImportSheet.swift +++ b/TablePro/Views/Connection/DeeplinkImportSheet.swift @@ -67,6 +67,10 @@ struct DeeplinkImportSheet: View { metadataSection } + startupCommandsSection + + optionsSection + if isDuplicate { Section { Label( @@ -95,6 +99,7 @@ struct DeeplinkImportSheet: View { .padding() } .frame(width: 420) + .frame(maxHeight: 560) .onAppear { checkDuplicate() } } @@ -139,6 +144,43 @@ struct DeeplinkImportSheet: View { } } + @ViewBuilder + private var startupCommandsSection: some View { + if let startupCommands = connection.startupCommands, + !startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Section { + Label( + String(localized: "This connection runs SQL every time it connects, using your credentials."), + systemImage: "exclamationmark.triangle.fill" + ) + .foregroundStyle(.orange) + .font(.callout) + + Text(startupCommands) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } header: { + Text("Startup SQL") + } + } + } + + @ViewBuilder + private var optionsSection: some View { + if let fields = connection.additionalFields, !fields.isEmpty { + Section(String(localized: "Driver Options")) { + ForEach(fields.keys.sorted(), id: \.self) { key in + LabeledContent(key) { + Text(fields[key] ?? "") + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + } + } + private var hasMetadata: Bool { connection.color != nil || connection.tagName != nil || connection.groupName != nil } diff --git a/TablePro/Views/Connection/WelcomeContextMenus.swift b/TablePro/Views/Connection/WelcomeContextMenus.swift index 99e241588..4dbd29ede 100644 --- a/TablePro/Views/Connection/WelcomeContextMenus.swift +++ b/TablePro/Views/Connection/WelcomeContextMenus.swift @@ -187,7 +187,7 @@ extension WelcomeWindowView { sshPassword: sshPw, sshProfile: sshProfile ) - ClipboardService.shared.writeText(url) + ClipboardService.shared.writeSecretText(url) } label: { Label(String(localized: "Copy Connection String"), systemImage: "link") } diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index f8c900c49..4af8c2c45 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -276,6 +276,9 @@ private actor MySQLActor { var reconnect: my_bool = 0 mysql_options(handle, MYSQL_OPT_RECONNECT, &reconnect) + var allowLocalInfile: UInt32 = 0 + mysql_options(handle, MYSQL_OPT_LOCAL_INFILE, &allowLocalInfile) + var sslEnforce: my_bool = ssl.isEnabled ? 1 : 0 mysql_options(handle, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) var sslVerify: my_bool = ssl.verifiesCertificate ? 1 : 0 diff --git a/TableProMobile/TableProMobile/Platform/AppPreferences.swift b/TableProMobile/TableProMobile/Platform/AppPreferences.swift index d5716afca..9b13b5d3a 100644 --- a/TableProMobile/TableProMobile/Platform/AppPreferences.swift +++ b/TableProMobile/TableProMobile/Platform/AppPreferences.swift @@ -3,6 +3,7 @@ import TableProModels enum AppPreferences { static let cloudSyncEnabledKey = "com.TablePro.settings.cloudSyncEnabled" + static let syncPasswordsKey = "com.TablePro.settings.syncPasswords" static let defaultPageSizeKey = "com.TablePro.settings.defaultPageSize" static let defaultSafeModeKey = "com.TablePro.settings.defaultSafeMode" static let hideQueryPreviewInActivityKey = "com.TablePro.settings.hideQueryPreviewInActivity" @@ -13,6 +14,10 @@ enum AppPreferences { UserDefaults.standard.object(forKey: cloudSyncEnabledKey) as? Bool ?? true } + static var syncsPasswords: Bool { + isCloudSyncEnabled && UserDefaults.standard.bool(forKey: syncPasswordsKey) + } + static var defaultPageSize: Int { guard let stored = UserDefaults.standard.object(forKey: defaultPageSizeKey) as? Int, pageSizeOptions.contains(stored) else { return 100 } diff --git a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift index 7126e37b7..6bf0a0ad2 100644 --- a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift +++ b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift @@ -37,9 +37,17 @@ final class KeychainSecureStore: SecureStore { return query } + static func accessibility(forSync synchronizable: Bool) -> CFString { + synchronizable + ? kSecAttrAccessibleAfterFirstUnlock + : kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + } + func store(_ value: String, forKey key: String) throws { guard let data = value.data(using: .utf8) else { return } + let synchronizable = AppPreferences.syncsPasswords + let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, @@ -49,15 +57,17 @@ final class KeychainSecureStore: SecureStore { ] SecItemDelete(applyingAccessGroup(deleteQuery) as CFDictionary) - let addQuery: [String: Any] = [ + var addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key, kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, - kSecAttrSynchronizable as String: true, + kSecAttrAccessible as String: Self.accessibility(forSync: synchronizable), kSecUseDataProtectionKeychain as String: true, ] + if synchronizable { + addQuery[kSecAttrSynchronizable as String] = true + } let status = SecItemAdd(applyingAccessGroup(addQuery) as CFDictionary, nil) if status != errSecSuccess { throw KeychainError.storeFailed(status) diff --git a/TableProMobile/TableProMobile/SSH/HostKeyStore.swift b/TableProMobile/TableProMobile/SSH/HostKeyStore.swift new file mode 100644 index 000000000..75575eb8d --- /dev/null +++ b/TableProMobile/TableProMobile/SSH/HostKeyStore.swift @@ -0,0 +1,148 @@ +// +// HostKeyStore.swift +// TableProMobile +// +// Trusted SSH host keys, stored line-based in the app's Application Support +// directory. Mirrors the Mac store's file format and verification semantics. +// + +import CryptoKit +import Foundation +import os + +final class HostKeyStore: @unchecked Sendable { + static let shared = HostKeyStore() + + private static let logger = Logger(subsystem: "com.TablePro", category: "HostKeyStore") + + enum VerificationResult: Equatable { + case trusted + case unknown(fingerprint: String, keyType: String) + case mismatch(expected: String, actual: String) + } + + private let filePath: String + private let lock = NSLock() + + private init() { + guard let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first else { + self.filePath = NSTemporaryDirectory() + "TablePro_known_hosts" + return + } + try? FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) + self.filePath = appSupport.appendingPathComponent("known_hosts").path + } + + init(filePath: String) { + self.filePath = filePath + } + + func verify(keyData: Data, keyType: String, hostname: String, port: Int) -> VerificationResult { + lock.lock() + defer { lock.unlock() } + + let hostKey = hostIdentifier(hostname, port) + let currentFingerprint = Self.fingerprint(of: keyData) + let entries = loadEntries() + + let hostEntries = entries.filter { $0.host == hostKey } + guard !hostEntries.isEmpty else { + Self.logger.info("Unknown host key for \(hostKey)") + return .unknown(fingerprint: currentFingerprint, keyType: keyType) + } + + if hostEntries.contains(where: { Self.fingerprint(of: $0.keyData) == currentFingerprint }) { + return .trusted + } + + let sameType = hostEntries.first { $0.keyType == keyType } + let storedFingerprint = Self.fingerprint(of: (sameType ?? hostEntries[0]).keyData) + + Self.logger.warning("Host key mismatch for \(hostKey)") + return .mismatch(expected: storedFingerprint, actual: currentFingerprint) + } + + func trust(hostname: String, port: Int, key: Data, keyType: String) { + lock.lock() + defer { lock.unlock() } + + let hostKey = hostIdentifier(hostname, port) + var entries = loadEntries() + entries.removeAll { $0.host == hostKey && $0.keyType == keyType } + entries.append((host: hostKey, keyType: keyType, keyData: key)) + saveEntries(entries) + } + + func remove(hostname: String, port: Int) { + lock.lock() + defer { lock.unlock() } + + let hostKey = hostIdentifier(hostname, port) + var entries = loadEntries() + entries.removeAll { $0.host == hostKey } + saveEntries(entries) + } + + func trustedHosts() -> [String] { + lock.lock() + defer { lock.unlock() } + return Array(Set(loadEntries().map(\.host))).sorted() + } + + static func keyTypeName(_ type: Int32) -> String { + switch type { + case 1: return "ssh-rsa" + case 2: return "ssh-dss" + case 3: return "ecdsa-sha2-nistp256" + case 4: return "ecdsa-sha2-nistp384" + case 5: return "ecdsa-sha2-nistp521" + case 6: return "ssh-ed25519" + default: return "unknown" + } + } + + static func fingerprint(of key: Data) -> String { + let digest = SHA256.hash(data: key) + return "SHA256:" + Data(digest).base64EncodedString().replacingOccurrences(of: "=", with: "") + } + + private func hostIdentifier(_ hostname: String, _ port: Int) -> String { + "[\(hostname)]:\(port)" + } + + private func loadEntries() -> [(host: String, keyType: String, keyData: Data)] { + guard let content = try? String(contentsOfFile: filePath, encoding: .utf8) else { + return [] + } + + var entries: [(host: String, keyType: String, keyData: Data)] = [] + + for line in content.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } + + let parts = trimmed.components(separatedBy: " ") + guard parts.count == 3, let keyData = Data(base64Encoded: parts[2]) else { + Self.logger.warning("Skipping malformed known_hosts line") + continue + } + + entries.append((host: parts[0], keyType: parts[1], keyData: keyData)) + } + + return entries + } + + private func saveEntries(_ entries: [(host: String, keyType: String, keyData: Data)]) { + let lines = entries.map { "\($0.host) \($0.keyType) \($0.keyData.base64EncodedString())" } + let content = lines.joined(separator: "\n") + (lines.isEmpty ? "" : "\n") + + do { + try content.write(toFile: filePath, atomically: true, encoding: .utf8) + } catch { + Self.logger.error("Failed to write known_hosts file: \(error.localizedDescription)") + } + } +} diff --git a/TableProMobile/TableProMobile/SSH/HostKeyVerifier.swift b/TableProMobile/TableProMobile/SSH/HostKeyVerifier.swift new file mode 100644 index 000000000..09a521f74 --- /dev/null +++ b/TableProMobile/TableProMobile/SSH/HostKeyVerifier.swift @@ -0,0 +1,159 @@ +// +// HostKeyVerifier.swift +// TableProMobile +// +// Checks a presented SSH host key against the known-hosts store before any +// credential is sent, and asks the user when the key is new or has changed. +// + +import Foundation +import Observation +import SwiftUI + +@MainActor +@Observable +final class HostKeyPromptPresenter { + static let shared = HostKeyPromptPresenter() + + struct Request: Identifiable { + let id = UUID() + let title: String + let message: String + let confirmTitle: String + let isDestructive: Bool + let respond: @MainActor (Bool) -> Void + } + + var pending: Request? + + private init() {} + + /// Only one key decision can be on screen at a time. A second connect racing the + /// first is refused rather than queued, so no attempt is left waiting on a prompt + /// the user never sees. + func ask(title: String, message: String, confirmTitle: String, isDestructive: Bool) async -> Bool { + guard pending == nil else { return false } + + return await withCheckedContinuation { continuation in + pending = Request( + title: title, + message: message, + confirmTitle: confirmTitle, + isDestructive: isDestructive + ) { accepted in + continuation.resume(returning: accepted) + } + } + } + + func resolve(_ request: Request, accepted: Bool) { + guard pending?.id == request.id else { return } + pending = nil + request.respond(accepted) + } +} + +enum HostKeyVerifier { + static func verify(keyData: Data, keyType: String, hostname: String, port: Int) async throws { + let result = HostKeyStore.shared.verify( + keyData: keyData, + keyType: keyType, + hostname: hostname, + port: port + ) + + switch result { + case .trusted: + return + + case let .unknown(fingerprint, presentedType): + let accepted = await HostKeyPromptPresenter.shared.ask( + title: String(localized: "Unknown SSH Server"), + message: String( + format: String(localized: """ + TablePro has not connected to %@ before. + + %@ key fingerprint: + %@ + + Trust this server only if the fingerprint matches the one you expect. + """), + hostDisplay(hostname, port), + presentedType, + fingerprint + ), + confirmTitle: String(localized: "Trust"), + isDestructive: false + ) + guard accepted else { + throw SSHTunnelError.hostKeyRejected(String(localized: "The server's host key was not trusted.")) + } + HostKeyStore.shared.trust(hostname: hostname, port: port, key: keyData, keyType: keyType) + + case let .mismatch(expected, actual): + let accepted = await HostKeyPromptPresenter.shared.ask( + title: String(localized: "SSH Host Key Changed"), + message: String( + format: String(localized: """ + The host key for %@ has changed. + + This can mean the server was rebuilt, or that someone is intercepting \ + the connection. + + Previous fingerprint: + %@ + + Current fingerprint: + %@ + """), + hostDisplay(hostname, port), + expected, + actual + ), + confirmTitle: String(localized: "Connect Anyway"), + isDestructive: true + ) + guard accepted else { + throw SSHTunnelError.hostKeyRejected(String(localized: "The server's host key has changed.")) + } + HostKeyStore.shared.trust(hostname: hostname, port: port, key: keyData, keyType: keyType) + } + } + + private static func hostDisplay(_ hostname: String, _ port: Int) -> String { + "[\(hostname)]:\(port)" + } +} + +struct HostKeyPromptModifier: ViewModifier { + @Bindable var presenter = HostKeyPromptPresenter.shared + + func body(content: Content) -> some View { + content.alert( + presenter.pending?.title ?? "", + isPresented: Binding( + get: { presenter.pending != nil }, + set: { presenting in + guard !presenting, let request = presenter.pending else { return } + presenter.resolve(request, accepted: false) + } + ), + presenting: presenter.pending + ) { request in + Button(request.confirmTitle, role: request.isDestructive ? .destructive : nil) { + presenter.resolve(request, accepted: true) + } + Button(String(localized: "Cancel"), role: .cancel) { + presenter.resolve(request, accepted: false) + } + } message: { request in + Text(request.message) + } + } +} + +extension View { + func hostKeyPrompt() -> some View { + modifier(HostKeyPromptModifier()) + } +} diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift index 4f90260fb..ec49d55ff 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift @@ -131,6 +131,20 @@ actor SSHTunnel { session = sess } + func hostKey() throws -> (keyData: Data, keyType: String) { + guard let session else { + throw SSHTunnelError.handshakeFailed("No active session") + } + + var keyLength = 0 + var keyType: Int32 = 0 + guard let keyPtr = libssh2_session_hostkey(session, &keyLength, &keyType) else { + throw SSHTunnelError.hostKeyRejected("The server did not present a host key.") + } + + return (Data(bytes: keyPtr, count: keyLength), HostKeyStore.keyTypeName(keyType)) + } + // MARK: - Authentication func authenticatePassword(username: String, password: String) throws { diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift b/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift index ceed8d591..f9e1d0cea 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift @@ -6,6 +6,7 @@ enum SSHTunnelError: Error, LocalizedError { case authenticationFailed(String) case noAvailablePort case channelOpenFailed(String) + case hostKeyRejected(String) case tunnelClosed var errorDescription: String? { @@ -15,6 +16,7 @@ enum SSHTunnelError: Error, LocalizedError { case .authenticationFailed(let msg): return "SSH authentication failed: \(msg)" case .noAvailablePort: return "No available local port for SSH tunnel" case .channelOpenFailed(let msg): return "SSH channel open failed: \(msg)" + case .hostKeyRejected(let msg): return msg case .tunnelClosed: return "SSH tunnel is closed" } } diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift index 559b08930..314f2e04c 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift @@ -24,6 +24,19 @@ enum SSHTunnelFactory { try await tunnel.connect(host: config.host, port: config.port) try await tunnel.handshake() + let presentedKey = try await tunnel.hostKey() + do { + try await HostKeyVerifier.verify( + keyData: presentedKey.keyData, + keyType: presentedKey.keyType, + hostname: config.host, + port: config.port + ) + } catch { + await tunnel.close() + throw error + } + switch config.authMethod { case .password: guard let password = sshPassword else { diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index eccc5d289..5f2e1e759 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -40,6 +40,7 @@ struct TableProMobileApp: App { } } .animation(.default, value: lockState.isLocked) + .hostKeyPrompt() .onOpenURL { url in if url.isFileURL, url.pathExtension.lowercased() == "tablepro" { appState.pendingImportURL = url diff --git a/TableProMobile/TableProMobile/Views/SettingsView.swift b/TableProMobile/TableProMobile/Views/SettingsView.swift index c85b7eade..27d2d1b84 100644 --- a/TableProMobile/TableProMobile/Views/SettingsView.swift +++ b/TableProMobile/TableProMobile/Views/SettingsView.swift @@ -9,6 +9,7 @@ struct SettingsView: View { @AppStorage(AppLockState.lockEnabledKey) private var lockEnabled = false @AppStorage(AppLockState.lockTimeoutKey) private var lockTimeoutSeconds = AppLockState.AutoLockTimeout.fiveMinutes.rawValue @AppStorage(AppPreferences.cloudSyncEnabledKey) private var cloudSyncEnabled = true + @AppStorage(AppPreferences.syncPasswordsKey) private var syncPasswords = false @AppStorage(AppPreferences.defaultPageSizeKey) private var defaultPageSize = 100 @AppStorage(AppPreferences.defaultSafeModeKey) private var defaultSafeModeRaw = SafeModeLevel.off.rawValue @AppStorage(AppPreferences.hideQueryPreviewInActivityKey) private var hideQueryPreviewInActivity = false @@ -93,11 +94,17 @@ struct SettingsView: View { } } .disabled(isSyncing) + + Toggle(String(localized: "Sync Passwords"), isOn: $syncPasswords) + + Text("Passwords sync through iCloud Keychain, which is end-to-end encrypted. Only affects new saves. Re-save a password to update its sync.") + .font(.caption) + .foregroundStyle(.secondary) } } header: { Text("Sync") } footer: { - Text("When off, connections, groups, and tags stay on this device only. Existing iCloud data is not deleted.") + Text("When off, connections, groups, and tags stay on this device only. Existing iCloud data is not deleted. Passwords stay on this device unless you turn on Sync Passwords.") } } diff --git a/TableProMobile/TableProMobileTests/SSH/HostKeyStoreTests.swift b/TableProMobile/TableProMobileTests/SSH/HostKeyStoreTests.swift new file mode 100644 index 000000000..5093b222e --- /dev/null +++ b/TableProMobile/TableProMobileTests/SSH/HostKeyStoreTests.swift @@ -0,0 +1,126 @@ +// +// HostKeyStoreTests.swift +// TableProMobileTests +// + +import Foundation +import Testing + +@testable import TableProMobile + +@Suite("HostKeyStore") +struct HostKeyStoreTests { + private func makeTempFilePath() -> String { + (NSTemporaryDirectory() as NSString).appendingPathComponent("test_known_hosts_\(UUID().uuidString)") + } + + private func makeTestKey(_ seed: UInt8) -> Data { + Data(repeating: seed, count: 32) + } + + @Test("An unseen host is unknown, not trusted") + func unknownHostIsNotTrusted() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let key = makeTestKey(0xAA) + + #expect( + store.verify(keyData: key, keyType: "ssh-ed25519", hostname: "db.example.com", port: 22) + == .unknown(fingerprint: HostKeyStore.fingerprint(of: key), keyType: "ssh-ed25519") + ) + } + + @Test("A trusted key verifies on the next connect") + func trustedKeyVerifies() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let key = makeTestKey(0xBB) + + store.trust(hostname: "db.example.com", port: 22, key: key, keyType: "ssh-ed25519") + + #expect(store.verify(keyData: key, keyType: "ssh-ed25519", hostname: "db.example.com", port: 22) == .trusted) + } + + @Test("A changed key is a mismatch") + func changedKeyIsMismatch() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let trusted = makeTestKey(0xCC) + let presented = makeTestKey(0xDD) + + store.trust(hostname: "db.example.com", port: 22, key: trusted, keyType: "ssh-ed25519") + + #expect( + store.verify(keyData: presented, keyType: "ssh-ed25519", hostname: "db.example.com", port: 22) + == .mismatch( + expected: HostKeyStore.fingerprint(of: trusted), + actual: HostKeyStore.fingerprint(of: presented) + ) + ) + } + + @Test("A different key type for a known host is a mismatch, not a first-use prompt") + func differentKeyTypeIsMismatch() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let trusted = makeTestKey(0xEE) + let attacker = makeTestKey(0xEF) + + store.trust(hostname: "db.example.com", port: 22, key: trusted, keyType: "ssh-ed25519") + + #expect( + store.verify(keyData: attacker, keyType: "ssh-rsa", hostname: "db.example.com", port: 22) + == .mismatch( + expected: HostKeyStore.fingerprint(of: trusted), + actual: HostKeyStore.fingerprint(of: attacker) + ) + ) + } + + @Test("Hosts on different ports are tracked separately") + func portsAreSeparateEntries() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let key = makeTestKey(0x11) + + store.trust(hostname: "db.example.com", port: 22, key: key, keyType: "ssh-ed25519") + + #expect(store.verify(keyData: key, keyType: "ssh-ed25519", hostname: "db.example.com", port: 22) == .trusted) + if case .trusted = store.verify( + keyData: key, keyType: "ssh-ed25519", hostname: "db.example.com", port: 2222 + ) { + Issue.record("Port 2222 should not inherit trust from port 22") + } + } + + @Test("Removing a host clears every key type stored for it") + func removeClearsAllKeyTypes() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + store.trust(hostname: "db.example.com", port: 22, key: makeTestKey(0x21), keyType: "ssh-rsa") + store.trust(hostname: "db.example.com", port: 22, key: makeTestKey(0x22), keyType: "ssh-ed25519") + + store.remove(hostname: "db.example.com", port: 22) + + #expect(store.trustedHosts().isEmpty) + } + + @Test("Fingerprints use the OpenSSH SHA256 form") + func fingerprintFormat() { + let fingerprint = HostKeyStore.fingerprint(of: Data("tablepro".utf8)) + #expect(fingerprint.hasPrefix("SHA256:")) + #expect(!fingerprint.contains("=")) + } +} diff --git a/TableProTests/AWS/AWSIAMAuthTests.swift b/TableProTests/AWS/AWSIAMAuthTests.swift index fc872bc2d..7d01779ee 100644 --- a/TableProTests/AWS/AWSIAMAuthTests.swift +++ b/TableProTests/AWS/AWSIAMAuthTests.swift @@ -211,6 +211,38 @@ struct RDSSigningEndpointResolverTests { } } + @Test("An override may not redirect the token away from the host being dialled") + func overrideCannotRedirectToAnotherHost() { + #expect(throws: AWSAuthError.rdsEndpointUnresolved(host: "evil.example.com")) { + _ = try resolve( + host: "evil.example.com", + port: 5_432, + override: "prod.abc123.us-east-1.rds.amazonaws.com:5432" + ) + } + } + + @Test("An override that restates the dialled host is accepted") + func overrideMatchingDialledHostIsAccepted() throws { + let endpoint = try resolve( + host: "mydb.abc123.us-east-1.rds.amazonaws.com", + port: 5_432, + override: "MyDB.abc123.us-east-1.RDS.amazonaws.com:5433" + ) + #expect(endpoint.port == 5_433) + } + + @Test("A pre-tunnel host is ignored when the socket is not a local forward") + func preTunnelHostIgnoredForRemoteDial() throws { + let endpoint = try resolve( + host: "evil.example.com", + port: 5_432, + preTunnelHost: "prod.abc123.us-east-1.rds.amazonaws.com", + preTunnelPort: 5_432 + ) + #expect(endpoint == RDSSigningEndpoint(host: "evil.example.com", port: 5_432)) + } + @Test("The region comes from the signing host, not the local forward") func regionFollowsSigningHost() throws { let endpoint = try resolve( diff --git a/TableProTests/Core/SSH/HostKeyStoreTests.swift b/TableProTests/Core/SSH/HostKeyStoreTests.swift index ee09f941e..784bb2bc7 100644 --- a/TableProTests/Core/SSH/HostKeyStoreTests.swift +++ b/TableProTests/Core/SSH/HostKeyStoreTests.swift @@ -69,6 +69,42 @@ struct HostKeyStoreTests { #expect(result == .mismatch(expected: expectedFingerprint, actual: actualFingerprint)) } + @Test("A different key type for a known host is a mismatch, not a first-use prompt") + func testDifferentKeyTypeForKnownHostIsMismatch() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let trustedKey = makeTestKey(0xEE) + let attackerKey = makeTestKey(0xEF) + + store.trust(hostname: "example.com", port: 22, key: trustedKey, keyType: "ssh-ed25519") + + let result = store.verify(keyData: attackerKey, keyType: "ssh-rsa", hostname: "example.com", port: 22) + #expect( + result == .mismatch( + expected: HostKeyStore.fingerprint(of: trustedKey), + actual: HostKeyStore.fingerprint(of: attackerKey) + ) + ) + } + + @Test("A host trusted under two key types verifies either one") + func testTrustedKeyOfEitherTypeVerifies() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let rsaKey = makeTestKey(0x11) + let edKey = makeTestKey(0x22) + + store.trust(hostname: "example.com", port: 22, key: rsaKey, keyType: "ssh-rsa") + store.trust(hostname: "example.com", port: 22, key: edKey, keyType: "ssh-ed25519") + + #expect(store.verify(keyData: rsaKey, keyType: "ssh-rsa", hostname: "example.com", port: 22) == .trusted) + #expect(store.verify(keyData: edKey, keyType: "ssh-ed25519", hostname: "example.com", port: 22) == .trusted) + } + @Test("Remove a host key then verify returns .unknown") func testRemove() { let path = makeTempFilePath() diff --git a/TableProTests/Core/Services/ConnectionSharingTests.swift b/TableProTests/Core/Services/ConnectionSharingTests.swift index c277cf431..16a75142b 100644 --- a/TableProTests/Core/Services/ConnectionSharingTests.swift +++ b/TableProTests/Core/Services/ConnectionSharingTests.swift @@ -578,5 +578,92 @@ struct ConnectionSharingTests { ) #expect(connection.preConnectScript == nil) } + + private static func parseImportLink(_ items: [URLQueryItem]) -> ExportableConnection? { + var components = URLComponents() + components.scheme = "tablepro" + components.host = "import" + components.queryItems = items + guard let url = components.url, + case .success(.importConnection(let parsed)) = DeeplinkParser.parse(url) else { + return nil + } + return parsed + } + + @Test("Deeplink import drops every AWS credential-resolution field") + @MainActor + func testDeeplinkImportDropsAWSFields() throws { + let parsed = try #require(Self.parseImportLink([ + URLQueryItem(name: "name", value: "Analytics Replica"), + URLQueryItem(name: "host", value: "evil.example.com"), + URLQueryItem(name: "port", value: "5432"), + URLQueryItem(name: "type", value: "PostgreSQL"), + URLQueryItem(name: "af_awsAuth", value: "profile"), + URLQueryItem(name: "af_awsRDSEndpoint", value: "prod.abc.us-east-1.rds.amazonaws.com:5432"), + URLQueryItem(name: "af_awsRegion", value: "us-east-1"), + URLQueryItem(name: "af_awsProfileName", value: "default"), + URLQueryItem(name: "af_mongoAuthSource", value: "admin") + ])) + + #expect(parsed.additionalFields?["awsAuth"] == nil) + #expect(parsed.additionalFields?["awsRDSEndpoint"] == nil) + #expect(parsed.additionalFields?["awsRegion"] == nil) + #expect(parsed.additionalFields?["awsProfileName"] == nil) + #expect(parsed.additionalFields?["mongoAuthSource"] == "admin") + + let connection = ConnectionExportService.buildDatabaseConnection( + id: UUID(), from: parsed, name: parsed.name, + tagIdsByName: [:], groupIdsByName: [:] + ) + #expect(!connection.usesAWSIAM) + } + + @Test("Deeplink import drops pgpass and pre-tunnel redirection fields") + @MainActor + func testDeeplinkImportDropsCredentialRedirectionFields() throws { + let parsed = try #require(Self.parseImportLink([ + URLQueryItem(name: "name", value: "Replica"), + URLQueryItem(name: "host", value: "evil.example.com"), + URLQueryItem(name: "port", value: "5432"), + URLQueryItem(name: "type", value: "PostgreSQL"), + URLQueryItem(name: "af_usePgpass", value: "true"), + URLQueryItem(name: "af_preTunnelHost", value: "prod.internal"), + URLQueryItem(name: "af_preTunnelPort", value: "5432"), + URLQueryItem(name: "af_promptForPassword", value: "false"), + URLQueryItem(name: "af_sslClientKeyPassphrase", value: "secret"), + URLQueryItem(name: "af_mssqlSchema", value: "dbo") + ])) + + #expect(parsed.additionalFields?["usePgpass"] == nil) + #expect(parsed.additionalFields?["preTunnelHost"] == nil) + #expect(parsed.additionalFields?["preTunnelPort"] == nil) + #expect(parsed.additionalFields?["promptForPassword"] == nil) + #expect(parsed.additionalFields?["sslClientKeyPassphrase"] == nil) + #expect(parsed.additionalFields?["mssqlSchema"] == "dbo") + } + + @Test("Blocked field keys are matched without regard to case") + func testBlockedKeysAreCaseInsensitive() { + #expect(ExportableConnection.isImportBlockedAdditionalFieldKey("AWSAuth")) + #expect(ExportableConnection.isImportBlockedAdditionalFieldKey("PreConnectScript")) + #expect(ExportableConnection.isImportBlockedAdditionalFieldKey("UsePgpass")) + #expect(!ExportableConnection.isImportBlockedAdditionalFieldKey("mongoAuthSource")) + #expect(!ExportableConnection.isImportBlockedAdditionalFieldKey("awareness")) + } + + @Test("Imported startup commands survive so the sheet can disclose them") + @MainActor + func testDeeplinkImportKeepsStartupCommandsForDisclosure() throws { + let parsed = try #require(Self.parseImportLink([ + URLQueryItem(name: "name", value: "Staging"), + URLQueryItem(name: "host", value: "db.example.com"), + URLQueryItem(name: "port", value: "5432"), + URLQueryItem(name: "type", value: "PostgreSQL"), + URLQueryItem(name: "startupCommands", value: "GRANT ALL ON *.* TO 'attacker'@'%'") + ])) + + #expect(parsed.startupCommands == "GRANT ALL ON *.* TO 'attacker'@'%'") + } } } diff --git a/TableProTests/Core/Services/PEMCertificateDecoderTests.swift b/TableProTests/Core/Services/PEMCertificateDecoderTests.swift new file mode 100644 index 000000000..5589e4436 --- /dev/null +++ b/TableProTests/Core/Services/PEMCertificateDecoderTests.swift @@ -0,0 +1,74 @@ +// +// PEMCertificateDecoderTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("PEM certificate decoder") +struct PEMCertificateDecoderTests { + private static let derBytes = Data([0x30, 0x82, 0x01, 0x0A, 0x02, 0x01, 0x00]) + + private static func pemDocument(body: String) -> String { + """ + -----BEGIN CERTIFICATE----- + \(body) + -----END CERTIFICATE----- + """ + } + + @Test("Decodes a PEM block back to its DER bytes") + func decodesPEM() { + let pem = Self.pemDocument(body: Self.derBytes.base64EncodedString()) + #expect(PEMCertificateDecoder.firstCertificateDER(inPEM: pem) == Self.derBytes) + } + + @Test("Joins a base64 body wrapped across several lines") + func decodesWrappedBody() { + let base64 = Data(repeating: 0xAB, count: 120).base64EncodedString() + let wrapped = stride(from: 0, to: base64.count, by: 64).map { offset -> String in + let start = base64.index(base64.startIndex, offsetBy: offset) + let end = base64.index(start, offsetBy: min(64, base64.count - offset)) + return String(base64[start.. SymmetricKey? { SymmetricKey(data: material) } +} + +private struct MissingIntegrityKeySource: IntegrityKeySource { + func key() -> SymmetricKey? { nil } +} + +@Suite("Connection store integrity") +struct ConnectionStoreIntegrityTests { + private let integrity = ConnectionStoreIntegrity( + keySource: FixedIntegrityKeySource(material: Data(repeating: 0x5A, count: 32)) + ) + + private func makeTempFileURL() -> URL { + URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("connections_\(UUID().uuidString).json") + } + + private func cleanUp(_ fileURL: URL) { + try? FileManager.default.removeItem(at: fileURL) + try? FileManager.default.removeItem(at: ConnectionStoreIntegrity.tagURL(for: fileURL)) + } + + @Test("A file with no tag reads as unstamped") + func unstampedWithoutTag() { + let fileURL = makeTempFileURL() + defer { cleanUp(fileURL) } + + #expect(integrity.verify(Data("[]".utf8), fileURL: fileURL) == .unstamped) + } + + @Test("A stamped file verifies against its own bytes") + func stampedFileIsTrusted() throws { + let fileURL = makeTempFileURL() + defer { cleanUp(fileURL) } + + let data = Data(#"[{"name":"Prod"}]"#.utf8) + try #require(integrity.stamp(data, fileURL: fileURL)) + + #expect(integrity.verify(data, fileURL: fileURL) == .trusted) + } + + @Test("Changing a single byte is detected") + func modifiedFileIsDetected() throws { + let fileURL = makeTempFileURL() + defer { cleanUp(fileURL) } + + let original = Data(#"[{"name":"Prod"}]"#.utf8) + try #require(integrity.stamp(original, fileURL: fileURL)) + + let tampered = Data(#"[{"name":"Prod!"}]"#.utf8) + #expect(integrity.verify(tampered, fileURL: fileURL) == .modified) + } + + @Test("A planted password source cannot be stamped without the key") + func forgedTagIsRejected() throws { + let fileURL = makeTempFileURL() + defer { cleanUp(fileURL) } + + let original = Data(#"[{"name":"Prod"}]"#.utf8) + try #require(integrity.stamp(original, fileURL: fileURL)) + + let planted = Data(#"[{"name":"Prod","passwordSource":{"kind":"command","shell":"id"}}]"#.utf8) + try Data(repeating: 0xAB, count: 32).write(to: ConnectionStoreIntegrity.tagURL(for: fileURL)) + + #expect(integrity.verify(planted, fileURL: fileURL) == .modified) + } + + @Test("A tag written under a different key does not verify") + func otherKeyDoesNotVerify() throws { + let fileURL = makeTempFileURL() + defer { cleanUp(fileURL) } + + let data = Data(#"[{"name":"Prod"}]"#.utf8) + let other = ConnectionStoreIntegrity( + keySource: FixedIntegrityKeySource(material: Data(repeating: 0x11, count: 32)) + ) + try #require(other.stamp(data, fileURL: fileURL)) + + #expect(integrity.verify(data, fileURL: fileURL) == .modified) + } + + @Test("An empty tag file is treated as no tag, not as a match") + func emptyTagIsUnstamped() throws { + let fileURL = makeTempFileURL() + defer { cleanUp(fileURL) } + + try Data().write(to: ConnectionStoreIntegrity.tagURL(for: fileURL)) + + #expect(integrity.verify(Data("[]".utf8), fileURL: fileURL) == .unstamped) + } + + @Test("No key means unavailable, never a silent pass") + func missingKeyIsUnavailable() { + let fileURL = makeTempFileURL() + defer { cleanUp(fileURL) } + + let withoutKey = ConnectionStoreIntegrity(keySource: MissingIntegrityKeySource()) + + #expect(withoutKey.verify(Data("[]".utf8), fileURL: fileURL) == .unavailable) + #expect(!withoutKey.stamp(Data("[]".utf8), fileURL: fileURL)) + } + + @Test("Constant-time comparison agrees with equality") + func constantTimeComparison() { + let a = Data([1, 2, 3, 4]) + #expect(ConnectionStoreIntegrity.constantTimeEquals(a, Data([1, 2, 3, 4]))) + #expect(!ConnectionStoreIntegrity.constantTimeEquals(a, Data([1, 2, 3, 5]))) + #expect(!ConnectionStoreIntegrity.constantTimeEquals(a, Data([1, 2, 3]))) + } +} diff --git a/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift b/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift new file mode 100644 index 000000000..c90a1b680 --- /dev/null +++ b/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift @@ -0,0 +1,82 @@ +// +// MSSQLFreeTDSConfigTests.swift +// TableProTests +// + +import Foundation +import TableProMSSQLCore +import TableProPluginKit +import Testing + +@Suite("MSSQL FreeTDS config") +struct MSSQLFreeTDSConfigTests { + private func configuration( + verification: MSSQLCertificateVerification, + caCertificatePath: String? = nil + ) -> String { + MSSQLFreeTDSConfig.configuration( + host: "db.example.com", + port: 1_433, + encryptionFlag: "require", + verification: verification, + caCertificatePath: caCertificatePath + ) + } + + @Test("Every SSL mode maps to the verification it advertises") + func modeMapping() { + #expect(MSSQLSSLMapping.certificateVerification(for: .disabled) == .none) + #expect(MSSQLSSLMapping.certificateVerification(for: .preferred) == .none) + #expect(MSSQLSSLMapping.certificateVerification(for: .required) == .none) + #expect(MSSQLSSLMapping.certificateVerification(for: .verifyCa) == .chain) + #expect(MSSQLSSLMapping.certificateVerification(for: .verifyIdentity) == .chainAndHostname) + } + + @Test("Verify CA pins an authority but does not check the hostname") + func verifyCaConfiguration() { + let text = configuration(verification: .chain) + + #expect(text.contains("[\(MSSQLFreeTDSConfig.serverEntryName)]")) + #expect(text.contains("host = db.example.com")) + #expect(text.contains("port = 1433")) + #expect(text.contains("encryption = require")) + #expect(text.contains("ca file = \(MSSQLFreeTDSConfig.systemTrustStorePath)")) + #expect(!text.contains("check certificate hostname")) + } + + @Test("Verify Identity also checks the hostname") + func verifyIdentityConfiguration() { + let text = configuration(verification: .chainAndHostname) + + #expect(text.contains("ca file = ")) + #expect(text.contains("check certificate hostname = yes")) + } + + @Test("A user supplied authority wins over the system trust store") + func userSuppliedAuthority() { + let text = configuration(verification: .chain, caCertificatePath: "/Users/me/corp-ca.pem") + + #expect(text.contains("ca file = /Users/me/corp-ca.pem")) + #expect(!text.contains(MSSQLFreeTDSConfig.systemTrustStorePath)) + } + + @Test("A blank authority path falls back to the system trust store") + func blankAuthorityFallsBack() { + #expect(MSSQLFreeTDSConfig.authorityPath(userSupplied: nil) == MSSQLFreeTDSConfig.systemTrustStorePath) + #expect(MSSQLFreeTDSConfig.authorityPath(userSupplied: " ") == MSSQLFreeTDSConfig.systemTrustStorePath) + #expect(MSSQLFreeTDSConfig.authorityPath(userSupplied: "/tmp/ca.pem") == "/tmp/ca.pem") + } + + @Test("A non-verifying mode writes no authority line") + func nonVerifyingConfiguration() { + let text = configuration(verification: .none) + + #expect(!text.contains("ca file")) + #expect(!text.contains("check certificate hostname")) + } + + @Test("The system trust store is present on this machine") + func systemTrustStoreExists() { + #expect(FileManager.default.fileExists(atPath: MSSQLFreeTDSConfig.systemTrustStorePath)) + } +} diff --git a/docs/databases/clickhouse.mdx b/docs/databases/clickhouse.mdx index eb6bcd217..8684cfb14 100644 --- a/docs/databases/clickhouse.mdx +++ b/docs/databases/clickhouse.mdx @@ -20,6 +20,8 @@ Click **New Connection**, select **ClickHouse**, enter host, port, credentials, **SSL/TLS**: Setting any non-Disabled SSL Mode switches the URL scheme to `https`. **Preferred** and **Required** skip certificate verification, **Verify CA** validates against the supplied CA file, **Verify Identity** uses system default HTTPS trust (certificate chain plus hostname). Use **Verify Identity** for ClickHouse Cloud (port 8443); its certificates chain to a public CA. Use **Required** only for a self-signed certificate you have no CA file for. See [SSL/TLS](/features/ssl). +The CA file may be PEM or DER. If **Verify CA** cannot read the file you point it at, the connection fails rather than falling back to the public root store. + For unencrypted HTTP to a remote server, use [SSH tunneling](/databases/ssh-tunneling). ## Connection URL diff --git a/docs/databases/mssql.mdx b/docs/databases/mssql.mdx index 082da319c..8155fbdd6 100644 --- a/docs/databases/mssql.mdx +++ b/docs/databases/mssql.mdx @@ -103,10 +103,12 @@ TablePro maps the SSL mode to FreeTDS `DBSETENCRYPT`. New connections default to | **Disabled** | `off` | Plain TCP | | **Preferred** | `request` | Try TLS, fall back to plain if the server cannot | | **Required** | `require` | TLS required | -| **Verify CA** | `require` | Same as Required; dblib does not accept a CA path per connection | -| **Verify Identity** | `require` | Same as Required | +| **Verify CA** | `require` | TLS required, and the certificate must chain to a trusted authority | +| **Verify Identity** | `require` | TLS required, chain checked, and the certificate must name the host you dialled | -Certificate verification beyond the system trust store is configured machine-wide in `freetds.conf`, not per connection. See [SSL/TLS](/features/ssl) for concepts. +FreeTDS reads certificate settings from a config file rather than from the connection, so the two verifying modes make TablePro write a one-entry config for that connection and point FreeTDS at it. Nothing on your machine changes: your own `freetds.conf` is used for every other connection. + +Verification uses the macOS system roots at `/etc/ssl/cert.pem`, which covers Azure SQL and any server with a publicly trusted certificate. For a private authority, set the CA certificate on the SSL tab and that file is used instead. See [SSL/TLS](/features/ssl) for concepts. ## Google Cloud SQL diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx index 3754ce2c0..9de4ef87a 100644 --- a/docs/features/connection-sharing.mdx +++ b/docs/features/connection-sharing.mdx @@ -77,6 +77,19 @@ tablepro://import?name=Staging&host=db.example.com&port=5432&type=PostgreSQL&use TablePro also opens `tablepro://connect/` URLs that launch a saved connection. There is no menu item for this form; build the URL from the connection's UUID. See [URL Scheme](/external-api/url-scheme#open-a-connection). +### What a link cannot set + +A link comes from outside, so TablePro drops the settings that decide where a connection finds or sends a credential. These are ignored on import, whether they arrive from a link or a `.tablepro` file: + +- Any `aws*` option, including IAM authentication, the region, the profile name, and the RDS endpoint used to sign a token +- `usePgpass` +- `preTunnelHost` and `preTunnelPort` +- `preConnectScript` +- `promptForPassword` +- `sslClientKeyPassphrase` + +Startup SQL and the remaining driver options do carry across, and the confirmation sheet lists them in full before you add the connection. Read the startup SQL: it runs on every connect, with your credentials. + ## Encrypted Export Starter Include passwords in the export, protected by a passphrase. @@ -123,6 +136,8 @@ Use `$VAR` and `${VAR}` in `.tablepro` files. Resolved from TablePro's process e Connections in `~/Library/Application Support/TablePro/connections.json` can declare where their password comes from instead of storing it in the Keychain. Useful when a script provisions connections, for example one Docker database per git worktree. The password resolves at connect time and is not synced to iCloud, since the path, variable, or command is specific to one Mac. +TablePro keeps a signature for `connections.json`, so it can tell whether the file it is reading is the one it last wrote. If the file changed outside the app, password sources do not run and the connection reports why. Open the connection and save it from TablePro to accept the change. This is what stops a program that can write to your home folder from adding a `command` source and getting it executed the next time TablePro reopens your session. + Add a `passwordSource` object to the connection: ```json diff --git a/docs/features/icloud-sync.mdx b/docs/features/icloud-sync.mdx index 32ab0a0be..110cca65a 100644 --- a/docs/features/icloud-sync.mdx +++ b/docs/features/icloud-sync.mdx @@ -21,6 +21,8 @@ TablePro syncs your connections, groups, table favorites, saved queries, setting Passwords are not synced by default. Enable the **Passwords** toggle under Connections to sync them via Apple's iCloud Keychain. Enabling it only affects new saves: re-save a password to update its sync. With password sync off, enter the password once on each new Mac. + +On iPhone and iPad the same opt-in lives in **Settings** > **Sync** > **Sync Passwords**, under the iCloud Sync toggle. It is off by default and, like the Mac, only affects new saves. ## Enabling iCloud Sync diff --git a/project.yml b/project.yml index 98d212f9a..447baef64 100644 --- a/project.yml +++ b/project.yml @@ -388,6 +388,8 @@ targets: - Plugins/SurrealDBDriverPlugin/SurrealValue.swift dependencies: - target: TablePro + - package: TableProCore + products: [TableProMSSQLCore] settings: base: CURRENT_PROJECT_VERSION: 1 diff --git a/scripts/patches/libssh2-cve-2026-55199.patch b/scripts/patches/libssh2-cve-2026-55199.patch new file mode 100644 index 000000000..a8d830a14 --- /dev/null +++ b/scripts/patches/libssh2-cve-2026-55199.patch @@ -0,0 +1,36 @@ +CVE-2026-55199: pre-authentication CPU exhaustion in the SSH_MSG_EXT_INFO handler + +libssh2 through 1.11.1 reads nr-extensions from SSH_MSG_EXT_INFO as an untrusted uint32 and +loops that many times, ignoring the return value of _libssh2_get_string() on each iteration. +A malicious server declares nr-extensions = 0xFFFFFFFF with a short body; the reads fail +immediately but the loop still runs to completion, spinning the CPU for billions of +iterations. EXT_INFO arrives during key exchange, so this is reachable before the client has +authenticated and before any credential is sent. + +The session read timeout does not apply: the loop never returns to the transport layer. + +The fix stops the loop the first time a string cannot be read, which bounds it by the bytes +actually present rather than by the server's declared count. + +1.11.1 is the newest release and no release carries the fix yet. Delete this patch and bump +LIBSSH2_VERSION once one does. + +https://nvd.nist.gov/vuln/detail/CVE-2026-55199 + +--- a/src/packet.c ++++ b/src/packet.c +@@ -868,8 +868,12 @@ + + nr_extensions -= 1; + +- _libssh2_get_string(&buf, &name, &name_len); +- _libssh2_get_string(&buf, &value, &value_len); ++ if(_libssh2_get_string(&buf, &name, &name_len) || ++ _libssh2_get_string(&buf, &value, &value_len)) { ++ rc = _libssh2_error(session, LIBSSH2_ERROR_PROTO, ++ "Invalid extension info received"); ++ break; ++ } + + if(name && value) { + _libssh2_debug((session,