Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5c63e11
fix(ios): send the Redis ACL username so Valkey and Redis 6 users can…
datlechin Aug 11, 2026
20a58bc
ci: pin third-party actions to an exact commit
datlechin Aug 11, 2026
085e802
build(ssh): patch libssh2 against CVE-2026-55199
datlechin Aug 11, 2026
ba9baf9
fix(ssh): report a changed host key when the server offers a differen…
datlechin Aug 11, 2026
09e8813
fix(ios): verify the SSH host key before sending any credential
datlechin Aug 11, 2026
92bf90a
fix(ios): keep the stored TLS configuration when saving a connection
datlechin Aug 11, 2026
ee767fb
fix(ios): honor the password sync preference before writing to the ke…
datlechin Aug 11, 2026
c255124
fix(connections): drop credential-resolution fields from an imported …
datlechin Aug 11, 2026
52092b0
fix(plugins): verify a plugin signature immediately before loading it
datlechin Aug 11, 2026
657230f
fix(plugin-mysql): refuse a server request to read a local file
datlechin Aug 11, 2026
1d8d931
fix(plugin-clickhouse): fail closed when the CA file cannot be read
datlechin Aug 11, 2026
c4b110d
fix(mcp): reject a request from a disallowed browser origin
datlechin Aug 11, 2026
1d8f803
fix(connections): keep copied credentials out of clipboard history an…
datlechin Aug 11, 2026
c5d9521
docs(changelog): record the security fixes
datlechin Aug 11, 2026
ebeadc1
build: update static library checksums
datlechin Aug 11, 2026
1e4369b
fix(connections): refuse to run a password source when the store was …
datlechin Aug 11, 2026
0c8cb7a
docs(changelog): record the connection store integrity check
datlechin Aug 11, 2026
a412da8
fix(plugins): require https for a plugin download and pin TLS for Tab…
datlechin Aug 11, 2026
f5a8e10
style(connections): order nonisolated before private on the integrity…
datlechin Aug 11, 2026
3b3c98c
fix(plugin-mssql): make Verify CA and Verify Identity check the certi…
datlechin Aug 11, 2026
038e52b
docs(changelog): record real certificate verification for SQL Server
datlechin Aug 11, 2026
233332f
Merge remote-tracking branch 'origin/main' into fix/security-hardening
datlechin Aug 11, 2026
5275b4b
fix(connections): inject the integrity key source so a locked keychai…
datlechin Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

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

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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ios-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/macos-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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'

Expand All @@ -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'

Expand Down
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions Libs/checksums.sha256
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,39 @@ public struct ExportableConnection: Codable {
}

public extension ExportableConnection {
static let importBlockedAdditionalFieldKeys: Set<String> = ["preConnectScript"]
static let importBlockedAdditionalFieldKeys: Set<String> = [
"preconnectscript",
"pretunnelhost",
"pretunnelport",
"promptforpassword",
"sslclientkeypassphrase",
"usepgpass",
]

static let importBlockedAdditionalFieldPrefixes: Set<String> = ["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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
16 changes: 14 additions & 2 deletions Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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)
Expand Down
49 changes: 47 additions & 2 deletions Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) {
Expand All @@ -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<DBPROCESS>?
) -> UnsafeMutablePointer<DBPROCESS>? {
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<DBPROCESS>?
) -> UnsafeMutablePointer<DBPROCESS>? {
Expand Down
4 changes: 3 additions & 1 deletion Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
Loading
Loading