Skip to content

Commit f0e2be6

Browse files
authored
fix: resolve all findings from security and production readiness audit (#745)
* fix: resolve all findings from security and production readiness audit Address 35 issues identified in the 2026-04-14 security audit spanning SQL injection, supply chain, memory safety, thread safety, data integrity, credential storage, error handling, accessibility, and production readiness. * fix: address deep review findings — URL filter, FreeTDS errors, BigQuery escaping - Extend URL filter confirmation dialog to cover column+value path too, not just raw SQL condition — closes column name injection vector - FreeTDS dbmsghandle: overwrite errors for severity > 10 since SQL Server sends informational messages first, actual errors last - BigQuery quoteIdentifier: strip backticks instead of backslash-escaping (backslash escape is invalid BigQuery syntax) - Check mysql_stmt_fetch_column return value and log on failure - Remove unused names variable in handlePluginsRejected - Add 10-second timeout to waitForInitialLoad to prevent indefinite hang
1 parent def0584 commit f0e2be6

41 files changed

Lines changed: 806 additions & 100 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Raw SQL injection via external URL scheme deeplinks — now requires user confirmation
13+
- MySQL prepared statements silently truncating columns larger than 64KB
14+
- MSSQL error messages misattributed when multiple connections open simultaneously
15+
- BigQuery filter injection via unescaped column names and unvalidated operators
16+
- App quitting without warning when tabs have unsaved edits
17+
- Connection list corruption risk from non-atomic UserDefaults writes
18+
- Stale user-installed plugins silently rejected with no UI feedback
19+
- SSL mode picker showing misleading "Required" instead of "Required (skip verify)"
20+
- Plugin load blocking main thread on first connection after launch
21+
22+
### Changed
23+
24+
- OpenSSL updated to 3.4.3 (CVE-2025-9230, CVE-2025-9231)
25+
- SHA-256 checksum verification added to FreeTDS, Cassandra, and DuckDB build scripts
26+
- Memory pressure monitoring now reactive via DispatchSource
27+
1028
## [0.31.5] - 2026-04-14
1129

1230
### Fixed

Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ final class BigQueryPlugin: NSObject, TableProPlugin, DriverPlugin {
9898
section: .authentication,
9999
visibleWhen: FieldVisibilityRule(fieldId: "bqAuthMethod", values: ["oauth"])
100100
),
101+
ConnectionField(
102+
id: "bqOAuthRefreshToken",
103+
label: String(localized: "OAuth Refresh Token"),
104+
fieldType: .secure,
105+
section: .authentication,
106+
visibleWhen: FieldVisibilityRule(fieldId: "bqAuthMethod", values: ["oauth"])
107+
),
101108
ConnectionField(
102109
id: "bqMaxBytesBilled",
103110
label: String(localized: "Max Bytes Billed"),

Plugins/BigQueryDriverPlugin/BigQueryQueryBuilder.swift

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ internal struct BigQueryQueryBuilder {
190190
: columns
191191
let escapedSearch = searchText.replacingOccurrences(of: "'", with: "''")
192192
let searchClauses = searchCols.map { col in
193-
"CAST(`\(col)` AS STRING) LIKE '%\(escapedSearch)%'"
193+
"CAST(\(quoteIdentifier(col)) AS STRING) LIKE '%\(escapedSearch)%'"
194194
}
195195
if !searchClauses.isEmpty {
196196
whereClauses.append("(\(searchClauses.joined(separator: " OR ")))")
@@ -206,7 +206,7 @@ internal struct BigQueryQueryBuilder {
206206
let orderClauses = sortColumns.compactMap { sort -> String? in
207207
guard sort.columnIndex < columns.count else { return nil }
208208
let col = columns[sort.columnIndex]
209-
return "`\(col)` \(sort.ascending ? "ASC" : "DESC")"
209+
return "\(quoteIdentifier(col)) \(sort.ascending ? "ASC" : "DESC")"
210210
}
211211
if !orderClauses.isEmpty {
212212
sql += " ORDER BY " + orderClauses.joined(separator: ", ")
@@ -241,7 +241,7 @@ internal struct BigQueryQueryBuilder {
241241
: columns
242242
let escapedSearch = searchText.replacingOccurrences(of: "'", with: "''")
243243
let searchClauses = searchCols.map { col in
244-
"CAST(`\(col)` AS STRING) LIKE '%\(escapedSearch)%'"
244+
"CAST(\(quoteIdentifier(col)) AS STRING) LIKE '%\(escapedSearch)%'"
245245
}
246246
if !searchClauses.isEmpty {
247247
whereClauses.append("(\(searchClauses.joined(separator: " OR ")))")
@@ -269,11 +269,23 @@ internal struct BigQueryQueryBuilder {
269269
return "'\(escaped)'"
270270
}
271271

272+
private static let allowedFilterOperators: Set<String> = [
273+
"=", "!=", "<>", ">", ">=", "<", "<=",
274+
"LIKE", "NOT LIKE", "IN", "NOT IN",
275+
"IS NULL", "IS NOT NULL", "CONTAINS"
276+
]
277+
278+
private static func quoteIdentifier(_ name: String) -> String {
279+
// BigQuery does not support escaping backticks inside backtick-quoted identifiers
280+
let sanitized = name.replacingOccurrences(of: "`", with: "")
281+
return "`\(sanitized)`"
282+
}
283+
272284
private static func buildFilterClause(
273285
_ filter: BigQueryFilterSpec,
274286
columns: [String]
275287
) -> String? {
276-
let col = "`\(filter.column)`"
288+
let col = quoteIdentifier(filter.column)
277289
let escaped = filter.value.replacingOccurrences(of: "'", with: "''")
278290

279291
switch filter.op.uppercased() {
@@ -312,7 +324,7 @@ internal struct BigQueryQueryBuilder {
312324
case "CONTAINS":
313325
return "CAST(\(col) AS STRING) LIKE '%\(escaped)%'"
314326
default:
315-
return "\(col) \(filter.op) \(formatFilterValue(filter.value))"
327+
return nil
316328
}
317329
}
318330

Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -563,8 +563,12 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
563563
try await connectionActor.open(path: path)
564564

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

569573
if let conn = await connectionActor.connectionHandleForInterrupt {
570574
setInterruptHandle(conn)

Plugins/EtcdDriverPlugin/EtcdHttpClient.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1065,7 +1065,8 @@ internal final class EtcdHttpClient: @unchecked Sendable {
10651065
return
10661066
}
10671067

1068-
let identity = unsafeBitCast(identityRef, to: SecIdentity.self)
1068+
// swiftlint:disable:next force_cast
1069+
let identity = identityRef as! SecIdentity
10691070
let credential = URLCredential(
10701071
identity: identity,
10711072
certificates: nil,

Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -100,41 +100,69 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
100100

101101
// MARK: - Global FreeTDS initialization
102102

103-
private let freetdsLastErrorLock = NSLock()
104-
private var _freetdsLastError = ""
103+
/// Per-connection error storage keyed by DBPROCESS pointer.
104+
/// Falls back to a global error string when the DBPROCESS is nil (pre-connection errors).
105+
private let freetdsErrorLock = NSLock()
106+
private var freetdsConnectionErrors: [UnsafeRawPointer: String] = [:]
107+
private var freetdsGlobalError = ""
108+
109+
private func freetdsGetError(for dbproc: UnsafeMutablePointer<DBPROCESS>?) -> String {
110+
freetdsErrorLock.lock()
111+
defer { freetdsErrorLock.unlock() }
112+
if let dbproc {
113+
return freetdsConnectionErrors[UnsafeRawPointer(dbproc)] ?? freetdsGlobalError
114+
}
115+
return freetdsGlobalError
116+
}
105117

106-
private var freetdsLastError: String {
107-
get {
108-
freetdsLastErrorLock.lock()
109-
defer { freetdsLastErrorLock.unlock() }
110-
return _freetdsLastError
118+
private func freetdsClearError(for dbproc: UnsafeMutablePointer<DBPROCESS>?) {
119+
freetdsErrorLock.lock()
120+
defer { freetdsErrorLock.unlock() }
121+
if let dbproc {
122+
freetdsConnectionErrors[UnsafeRawPointer(dbproc)] = nil
123+
} else {
124+
freetdsGlobalError = ""
111125
}
112-
set {
113-
freetdsLastErrorLock.lock()
114-
defer { freetdsLastErrorLock.unlock() }
115-
_freetdsLastError = newValue
126+
}
127+
128+
private func freetdsSetError(_ msg: String, for dbproc: UnsafeMutablePointer<DBPROCESS>?, overwrite: Bool = false) {
129+
freetdsErrorLock.lock()
130+
defer { freetdsErrorLock.unlock() }
131+
if let dbproc {
132+
let key = UnsafeRawPointer(dbproc)
133+
if overwrite || (freetdsConnectionErrors[key]?.isEmpty ?? true) {
134+
freetdsConnectionErrors[key] = msg
135+
}
136+
} else if overwrite || freetdsGlobalError.isEmpty {
137+
freetdsGlobalError = msg
116138
}
117139
}
118140

141+
private func freetdsUnregister(_ dbproc: UnsafeMutablePointer<DBPROCESS>) {
142+
freetdsErrorLock.lock()
143+
defer { freetdsErrorLock.unlock() }
144+
freetdsConnectionErrors.removeValue(forKey: UnsafeRawPointer(dbproc))
145+
}
146+
119147
private let freetdsLogger = Logger(subsystem: "com.TablePro", category: "FreeTDSConnection")
120148

121149
private let freetdsInitOnce: Void = {
122150
_ = dbinit()
123-
_ = dberrhandle { _, _, dberr, _, dberrstr, oserrstr in
151+
_ = dberrhandle { dbproc, _, dberr, _, dberrstr, oserrstr in
124152
var msg = "db-lib error \(dberr)"
125153
if let s = dberrstr { msg += ": \(String(cString: s))" }
126154
if let s = oserrstr, String(cString: s) != "Success" { msg += " (os: \(String(cString: s)))" }
127155
freetdsLogger.error("FreeTDS: \(msg)")
128-
if freetdsLastError.isEmpty {
129-
freetdsLastError = msg
130-
}
156+
freetdsSetError(msg, for: dbproc)
131157
return INT_CANCEL
132158
}
133-
_ = dbmsghandle { _, msgno, _, severity, msgtext, _, _, _ in
159+
_ = dbmsghandle { dbproc, msgno, _, severity, msgtext, _, _, _ in
134160
guard let text = msgtext else { return 0 }
135161
let msg = String(cString: text)
136162
if severity > 10 {
137-
freetdsLastError = msg
163+
// SQL Server sends informational messages first, error messages last —
164+
// overwrite so the most specific error is kept
165+
freetdsSetError(msg, for: dbproc, overwrite: true)
138166
freetdsLogger.error("FreeTDS msg \(msgno) sev \(severity): \(msg)")
139167
} else {
140168
freetdsLogger.debug("FreeTDS msg \(msgno): \(msg)")
@@ -200,11 +228,12 @@ private final class FreeTDSConnection: @unchecked Sendable {
200228
_ = dbsetlname(login, "UTF-8", Int32(DBSETCHARSET))
201229
_ = dbsetlversion(login, UInt8(DBVERSION_74))
202230

203-
freetdsLastError = ""
231+
freetdsClearError(for: nil)
204232
let serverName = "\(host):\(port)"
205233
guard let proc = dbopen(login, serverName) else {
206-
let detail = freetdsLastError.isEmpty ? "Check host, port, and credentials" : freetdsLastError
207-
throw MSSQLPluginError.connectionFailed("Failed to connect to \(host):\(port)\(detail)")
234+
let detail = freetdsGetError(for: nil)
235+
let msg = detail.isEmpty ? "Check host, port, and credentials" : detail
236+
throw MSSQLPluginError.connectionFailed("Failed to connect to \(host):\(port)\(msg)")
208237
}
209238

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

242271
if let handle = handle {
272+
freetdsUnregister(handle)
243273
queue.async {
244274
_ = dbclose(handle)
245275
}
@@ -274,13 +304,14 @@ private final class FreeTDSConnection: @unchecked Sendable {
274304
_isCancelled = false
275305
lock.unlock()
276306

277-
freetdsLastError = ""
307+
freetdsClearError(for: proc)
278308
if dbcmd(proc, query) == FAIL {
279309
throw MSSQLPluginError.queryFailed("Failed to prepare query")
280310
}
281311
if dbsqlexec(proc) == FAIL {
282-
let detail = freetdsLastError.isEmpty ? "Query execution failed" : freetdsLastError
283-
throw MSSQLPluginError.queryFailed(detail)
312+
let detail = freetdsGetError(for: proc)
313+
let msg = detail.isEmpty ? "Query execution failed" : detail
314+
throw MSSQLPluginError.queryFailed(msg)
284315
}
285316

286317
var allColumns: [String] = []

Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,7 @@ final class MariaDBPluginConnection: @unchecked Sendable {
622622
for bind in resultBinds {
623623
bind.length?.deallocate()
624624
bind.is_null?.deallocate()
625+
bind.error?.deallocate()
625626
}
626627
}
627628

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

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

648-
while mysql_stmt_fetch(stmt) == 0 {
650+
while true {
651+
let fetchStatus = mysql_stmt_fetch(stmt)
652+
if fetchStatus != 0 && fetchStatus != MYSQL_DATA_TRUNCATED { break }
653+
649654
stateLock.lock()
650655
let shouldCancel = _isCancelled
651656
if shouldCancel { _isCancelled = false }
@@ -659,6 +664,25 @@ final class MariaDBPluginConnection: @unchecked Sendable {
659664
break
660665
}
661666

667+
// Re-fetch truncated columns with correctly sized buffers
668+
if fetchStatus == MYSQL_DATA_TRUNCATED {
669+
for i in 0..<numFields {
670+
let actualLength = Int(resultBinds[i].length?.pointee ?? 0)
671+
if actualLength > Int(resultBinds[i].buffer_length) {
672+
let newBuffer = UnsafeMutableRawPointer.allocate(
673+
byteCount: actualLength, alignment: 1
674+
)
675+
resultBuffers[i].deallocate()
676+
resultBuffers[i] = newBuffer
677+
resultBinds[i].buffer = newBuffer
678+
resultBinds[i].buffer_length = UInt(actualLength)
679+
if mysql_stmt_fetch_column(stmt, &resultBinds[i], UInt32(i), 0) != 0 {
680+
logger.warning("mysql_stmt_fetch_column failed for column \(i)")
681+
}
682+
}
683+
}
684+
}
685+
662686
var row: [String?] = []
663687
for i in 0..<numFields {
664688
if resultBinds[i].is_null?.pointee == 1 {

Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,9 @@ final class LibPQPluginConnection: @unchecked Sendable {
224224
throw error
225225
}
226226

227-
_ = "SET client_encoding TO 'UTF8'".withCString { cStr in
228-
PQexec(connection, cStr)
227+
"SET client_encoding TO 'UTF8'".withCString { cStr in
228+
let result = PQexec(connection, cStr)
229+
PQclear(result)
229230
}
230231

231232
let version = PQserverVersion(connection)

TablePro/AppDelegate+ConnectionHandler.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,31 @@ extension AppDelegate {
354354

355355
if parsed.filterColumn != nil || parsed.filterCondition != nil {
356356
await waitForNotification(.refreshData, timeout: .seconds(3))
357+
358+
// All filters from external URLs require explicit user confirmation
359+
let filterDescription: String
360+
if let condition = parsed.filterCondition, !condition.isEmpty {
361+
let preview = (condition as NSString).length > 300
362+
? String(condition.prefix(300)) + "" : condition
363+
filterDescription = preview
364+
} else {
365+
filterDescription = [parsed.filterColumn, parsed.filterOperation, parsed.filterValue]
366+
.compactMap { $0 }.joined(separator: " ")
367+
}
368+
if !filterDescription.isEmpty {
369+
let confirmed = await AlertHelper.confirmDestructive(
370+
title: String(localized: "Apply Filter from Link"),
371+
message: String(
372+
format: String(localized: "An external link wants to apply a filter:\n\n%@"),
373+
filterDescription
374+
),
375+
confirmButton: String(localized: "Apply Filter"),
376+
cancelButton: String(localized: "Cancel"),
377+
window: NSApp.keyWindow
378+
)
379+
guard confirmed else { return }
380+
}
381+
357382
NotificationCenter.default.post(
358383
name: .applyURLFilter,
359384
object: nil,

TablePro/AppDelegate+FileOpen.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,17 @@ extension AppDelegate {
181181
}
182182

183183
case .openQuery(let name, let sql):
184-
let preview = (sql as NSString).length > 300 ? String(sql.prefix(300)) + "" : sql
184+
let maxDeeplinkSQLLength = 51_200
185+
let sqlLength = (sql as NSString).length
186+
guard sqlLength <= maxDeeplinkSQLLength else { return }
187+
let preview: String
188+
if sqlLength > 300 {
189+
let hiddenCount = sqlLength - 300
190+
preview = String(sql.prefix(300))
191+
+ String(format: String(localized: "\n\n… (%d more characters not shown)"), hiddenCount)
192+
} else {
193+
preview = sql
194+
}
185195
let confirmed = await AlertHelper.confirmDestructive(
186196
title: String(localized: "Open Query from Link"),
187197
message: String(format: String(localized: "An external link wants to open a query on connection \"%@\":\n\n%@"), name, preview),

0 commit comments

Comments
 (0)