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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Raw SQL injection via external URL scheme deeplinks — now requires user confirmation
- MySQL prepared statements silently truncating columns larger than 64KB
- MSSQL error messages misattributed when multiple connections open simultaneously
- BigQuery filter injection via unescaped column names and unvalidated operators
- App quitting without warning when tabs have unsaved edits
- Connection list corruption risk from non-atomic UserDefaults writes
- Stale user-installed plugins silently rejected with no UI feedback
- SSL mode picker showing misleading "Required" instead of "Required (skip verify)"
- Plugin load blocking main thread on first connection after launch

### Changed

- OpenSSL updated to 3.4.3 (CVE-2025-9230, CVE-2025-9231)
- SHA-256 checksum verification added to FreeTDS, Cassandra, and DuckDB build scripts
- Memory pressure monitoring now reactive via DispatchSource

## [0.31.5] - 2026-04-14

### Fixed
Expand Down
7 changes: 7 additions & 0 deletions Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ final class BigQueryPlugin: NSObject, TableProPlugin, DriverPlugin {
section: .authentication,
visibleWhen: FieldVisibilityRule(fieldId: "bqAuthMethod", values: ["oauth"])
),
ConnectionField(
id: "bqOAuthRefreshToken",
label: String(localized: "OAuth Refresh Token"),
fieldType: .secure,
section: .authentication,
visibleWhen: FieldVisibilityRule(fieldId: "bqAuthMethod", values: ["oauth"])
),
ConnectionField(
id: "bqMaxBytesBilled",
label: String(localized: "Max Bytes Billed"),
Expand Down
22 changes: 17 additions & 5 deletions Plugins/BigQueryDriverPlugin/BigQueryQueryBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ internal struct BigQueryQueryBuilder {
: columns
let escapedSearch = searchText.replacingOccurrences(of: "'", with: "''")
let searchClauses = searchCols.map { col in
"CAST(`\(col)` AS STRING) LIKE '%\(escapedSearch)%'"
"CAST(\(quoteIdentifier(col)) AS STRING) LIKE '%\(escapedSearch)%'"
}
if !searchClauses.isEmpty {
whereClauses.append("(\(searchClauses.joined(separator: " OR ")))")
Expand All @@ -206,7 +206,7 @@ internal struct BigQueryQueryBuilder {
let orderClauses = sortColumns.compactMap { sort -> String? in
guard sort.columnIndex < columns.count else { return nil }
let col = columns[sort.columnIndex]
return "`\(col)` \(sort.ascending ? "ASC" : "DESC")"
return "\(quoteIdentifier(col)) \(sort.ascending ? "ASC" : "DESC")"
}
if !orderClauses.isEmpty {
sql += " ORDER BY " + orderClauses.joined(separator: ", ")
Expand Down Expand Up @@ -241,7 +241,7 @@ internal struct BigQueryQueryBuilder {
: columns
let escapedSearch = searchText.replacingOccurrences(of: "'", with: "''")
let searchClauses = searchCols.map { col in
"CAST(`\(col)` AS STRING) LIKE '%\(escapedSearch)%'"
"CAST(\(quoteIdentifier(col)) AS STRING) LIKE '%\(escapedSearch)%'"
}
if !searchClauses.isEmpty {
whereClauses.append("(\(searchClauses.joined(separator: " OR ")))")
Expand Down Expand Up @@ -269,11 +269,23 @@ internal struct BigQueryQueryBuilder {
return "'\(escaped)'"
}

private static let allowedFilterOperators: Set<String> = [
"=", "!=", "<>", ">", ">=", "<", "<=",
"LIKE", "NOT LIKE", "IN", "NOT IN",
"IS NULL", "IS NOT NULL", "CONTAINS"
]

private static func quoteIdentifier(_ name: String) -> String {
// BigQuery does not support escaping backticks inside backtick-quoted identifiers
let sanitized = name.replacingOccurrences(of: "`", with: "")
return "`\(sanitized)`"
}

private static func buildFilterClause(
_ filter: BigQueryFilterSpec,
columns: [String]
) -> String? {
let col = "`\(filter.column)`"
let col = quoteIdentifier(filter.column)
let escaped = filter.value.replacingOccurrences(of: "'", with: "''")

switch filter.op.uppercased() {
Expand Down Expand Up @@ -312,7 +324,7 @@ internal struct BigQueryQueryBuilder {
case "CONTAINS":
return "CAST(\(col) AS STRING) LIKE '%\(escaped)%'"
default:
return "\(col) \(filter.op) \(formatFilterValue(filter.value))"
return nil
}
}

Expand Down
8 changes: 6 additions & 2 deletions Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -563,8 +563,12 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
try await connectionActor.open(path: path)

// Enable auto-install and auto-load of extensions (e.g. core_functions)
try? await connectionActor.executeQuery("SET autoinstall_known_extensions=1")
try? await connectionActor.executeQuery("SET autoload_known_extensions=1")
do {
try await connectionActor.executeQuery("SET autoinstall_known_extensions=1")
try await connectionActor.executeQuery("SET autoload_known_extensions=1")
} catch {
Self.logger.warning("Failed to enable DuckDB extension autoloading: \(error.localizedDescription)")
}

if let conn = await connectionActor.connectionHandleForInterrupt {
setInterruptHandle(conn)
Expand Down
3 changes: 2 additions & 1 deletion Plugins/EtcdDriverPlugin/EtcdHttpClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1065,7 +1065,8 @@ internal final class EtcdHttpClient: @unchecked Sendable {
return
}

let identity = unsafeBitCast(identityRef, to: SecIdentity.self)
// swiftlint:disable:next force_cast
let identity = identityRef as! SecIdentity
let credential = URLCredential(
identity: identity,
certificates: nil,
Expand Down
77 changes: 54 additions & 23 deletions Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,41 +100,69 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin {

// MARK: - Global FreeTDS initialization

private let freetdsLastErrorLock = NSLock()
private var _freetdsLastError = ""
/// Per-connection error storage keyed by DBPROCESS pointer.
/// Falls back to a global error string when the DBPROCESS is nil (pre-connection errors).
private let freetdsErrorLock = NSLock()
private var freetdsConnectionErrors: [UnsafeRawPointer: String] = [:]
private var freetdsGlobalError = ""

private func freetdsGetError(for dbproc: UnsafeMutablePointer<DBPROCESS>?) -> String {
freetdsErrorLock.lock()
defer { freetdsErrorLock.unlock() }
if let dbproc {
return freetdsConnectionErrors[UnsafeRawPointer(dbproc)] ?? freetdsGlobalError
}
return freetdsGlobalError
}

private var freetdsLastError: String {
get {
freetdsLastErrorLock.lock()
defer { freetdsLastErrorLock.unlock() }
return _freetdsLastError
private func freetdsClearError(for dbproc: UnsafeMutablePointer<DBPROCESS>?) {
freetdsErrorLock.lock()
defer { freetdsErrorLock.unlock() }
if let dbproc {
freetdsConnectionErrors[UnsafeRawPointer(dbproc)] = nil
} else {
freetdsGlobalError = ""
}
set {
freetdsLastErrorLock.lock()
defer { freetdsLastErrorLock.unlock() }
_freetdsLastError = newValue
}

private func freetdsSetError(_ msg: String, for dbproc: UnsafeMutablePointer<DBPROCESS>?, overwrite: Bool = false) {
freetdsErrorLock.lock()
defer { freetdsErrorLock.unlock() }
if let dbproc {
let key = UnsafeRawPointer(dbproc)
if overwrite || (freetdsConnectionErrors[key]?.isEmpty ?? true) {
freetdsConnectionErrors[key] = msg
}
} else if overwrite || freetdsGlobalError.isEmpty {
freetdsGlobalError = msg
}
}

private func freetdsUnregister(_ dbproc: UnsafeMutablePointer<DBPROCESS>) {
freetdsErrorLock.lock()
defer { freetdsErrorLock.unlock() }
freetdsConnectionErrors.removeValue(forKey: UnsafeRawPointer(dbproc))
}

private let freetdsLogger = Logger(subsystem: "com.TablePro", category: "FreeTDSConnection")

private let freetdsInitOnce: Void = {
_ = dbinit()
_ = dberrhandle { _, _, dberr, _, dberrstr, oserrstr in
_ = dberrhandle { dbproc, _, dberr, _, dberrstr, oserrstr in
var msg = "db-lib error \(dberr)"
if let s = dberrstr { msg += ": \(String(cString: s))" }
if let s = oserrstr, String(cString: s) != "Success" { msg += " (os: \(String(cString: s)))" }
freetdsLogger.error("FreeTDS: \(msg)")
if freetdsLastError.isEmpty {
freetdsLastError = msg
}
freetdsSetError(msg, for: dbproc)
return INT_CANCEL
}
_ = dbmsghandle { _, msgno, _, severity, msgtext, _, _, _ in
_ = dbmsghandle { dbproc, msgno, _, severity, msgtext, _, _, _ in
guard let text = msgtext else { return 0 }
let msg = String(cString: text)
if severity > 10 {
freetdsLastError = msg
// SQL Server sends informational messages first, error messages last —
// overwrite so the most specific error is kept
freetdsSetError(msg, for: dbproc, overwrite: true)
freetdsLogger.error("FreeTDS msg \(msgno) sev \(severity): \(msg)")
} else {
freetdsLogger.debug("FreeTDS msg \(msgno): \(msg)")
Expand Down Expand Up @@ -200,11 +228,12 @@ private final class FreeTDSConnection: @unchecked Sendable {
_ = dbsetlname(login, "UTF-8", Int32(DBSETCHARSET))
_ = dbsetlversion(login, UInt8(DBVERSION_74))

freetdsLastError = ""
freetdsClearError(for: nil)
let serverName = "\(host):\(port)"
guard let proc = dbopen(login, serverName) else {
let detail = freetdsLastError.isEmpty ? "Check host, port, and credentials" : freetdsLastError
throw MSSQLPluginError.connectionFailed("Failed to connect to \(host):\(port) — \(detail)")
let detail = freetdsGetError(for: nil)
let msg = detail.isEmpty ? "Check host, port, and credentials" : detail
throw MSSQLPluginError.connectionFailed("Failed to connect to \(host):\(port) — \(msg)")
}

if !database.isEmpty {
Expand Down Expand Up @@ -240,6 +269,7 @@ private final class FreeTDSConnection: @unchecked Sendable {
lock.unlock()

if let handle = handle {
freetdsUnregister(handle)
queue.async {
_ = dbclose(handle)
}
Expand Down Expand Up @@ -274,13 +304,14 @@ private final class FreeTDSConnection: @unchecked Sendable {
_isCancelled = false
lock.unlock()

freetdsLastError = ""
freetdsClearError(for: proc)
if dbcmd(proc, query) == FAIL {
throw MSSQLPluginError.queryFailed("Failed to prepare query")
}
if dbsqlexec(proc) == FAIL {
let detail = freetdsLastError.isEmpty ? "Query execution failed" : freetdsLastError
throw MSSQLPluginError.queryFailed(detail)
let detail = freetdsGetError(for: proc)
let msg = detail.isEmpty ? "Query execution failed" : detail
throw MSSQLPluginError.queryFailed(msg)
}

var allColumns: [String] = []
Expand Down
26 changes: 25 additions & 1 deletion Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,7 @@ final class MariaDBPluginConnection: @unchecked Sendable {
for bind in resultBinds {
bind.length?.deallocate()
bind.is_null?.deallocate()
bind.error?.deallocate()
}
}

Expand All @@ -635,6 +636,7 @@ final class MariaDBPluginConnection: @unchecked Sendable {
resultBinds[i].buffer_length = UInt(bufferSize)
resultBinds[i].length = UnsafeMutablePointer<UInt>.allocate(capacity: 1)
resultBinds[i].is_null = UnsafeMutablePointer<my_bool>.allocate(capacity: 1)
resultBinds[i].error = UnsafeMutablePointer<my_bool>.allocate(capacity: 1)
}

if mysql_stmt_bind_result(stmt, &resultBinds) != 0 {
Expand All @@ -645,7 +647,10 @@ final class MariaDBPluginConnection: @unchecked Sendable {
let maxRows = PluginRowLimits.defaultMax
var truncated = false

while mysql_stmt_fetch(stmt) == 0 {
while true {
let fetchStatus = mysql_stmt_fetch(stmt)
if fetchStatus != 0 && fetchStatus != MYSQL_DATA_TRUNCATED { break }

stateLock.lock()
let shouldCancel = _isCancelled
if shouldCancel { _isCancelled = false }
Expand All @@ -659,6 +664,25 @@ final class MariaDBPluginConnection: @unchecked Sendable {
break
}

// Re-fetch truncated columns with correctly sized buffers
if fetchStatus == MYSQL_DATA_TRUNCATED {
for i in 0..<numFields {
let actualLength = Int(resultBinds[i].length?.pointee ?? 0)
if actualLength > Int(resultBinds[i].buffer_length) {
let newBuffer = UnsafeMutableRawPointer.allocate(
byteCount: actualLength, alignment: 1
)
resultBuffers[i].deallocate()
resultBuffers[i] = newBuffer
resultBinds[i].buffer = newBuffer
resultBinds[i].buffer_length = UInt(actualLength)
if mysql_stmt_fetch_column(stmt, &resultBinds[i], UInt32(i), 0) != 0 {
logger.warning("mysql_stmt_fetch_column failed for column \(i)")
}
}
}
}

var row: [String?] = []
for i in 0..<numFields {
if resultBinds[i].is_null?.pointee == 1 {
Expand Down
5 changes: 3 additions & 2 deletions Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,9 @@ final class LibPQPluginConnection: @unchecked Sendable {
throw error
}

_ = "SET client_encoding TO 'UTF8'".withCString { cStr in
PQexec(connection, cStr)
"SET client_encoding TO 'UTF8'".withCString { cStr in
let result = PQexec(connection, cStr)
PQclear(result)
}

let version = PQserverVersion(connection)
Expand Down
25 changes: 25 additions & 0 deletions TablePro/AppDelegate+ConnectionHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,31 @@ extension AppDelegate {

if parsed.filterColumn != nil || parsed.filterCondition != nil {
await waitForNotification(.refreshData, timeout: .seconds(3))

// All filters from external URLs require explicit user confirmation
let filterDescription: String
if let condition = parsed.filterCondition, !condition.isEmpty {
let preview = (condition as NSString).length > 300
? String(condition.prefix(300)) + "…" : condition
filterDescription = preview
} else {
filterDescription = [parsed.filterColumn, parsed.filterOperation, parsed.filterValue]
.compactMap { $0 }.joined(separator: " ")
}
if !filterDescription.isEmpty {
let confirmed = await AlertHelper.confirmDestructive(
title: String(localized: "Apply Filter from Link"),
message: String(
format: String(localized: "An external link wants to apply a filter:\n\n%@"),
filterDescription
),
confirmButton: String(localized: "Apply Filter"),
cancelButton: String(localized: "Cancel"),
window: NSApp.keyWindow
)
guard confirmed else { return }
}

NotificationCenter.default.post(
name: .applyURLFilter,
object: nil,
Expand Down
12 changes: 11 additions & 1 deletion TablePro/AppDelegate+FileOpen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,17 @@ extension AppDelegate {
}

case .openQuery(let name, let sql):
let preview = (sql as NSString).length > 300 ? String(sql.prefix(300)) + "…" : sql
let maxDeeplinkSQLLength = 51_200
let sqlLength = (sql as NSString).length
guard sqlLength <= maxDeeplinkSQLLength else { return }
let preview: String
if sqlLength > 300 {
let hiddenCount = sqlLength - 300
preview = String(sql.prefix(300))
+ String(format: String(localized: "\n\n… (%d more characters not shown)"), hiddenCount)
} else {
preview = sql
}
let confirmed = await AlertHelper.confirmDestructive(
title: String(localized: "Open Query from Link"),
message: String(format: String(localized: "An external link wants to open a query on connection \"%@\":\n\n%@"), name, preview),
Expand Down
Loading
Loading