Skip to content

Commit 7a10b8e

Browse files
authored
Security hardening across the macOS and iOS apps (#1640)
* fix(connections): strip pre-connect script from imported connections * fix(connections): confirm external database links and keep their passwords out of the keychain * fix(mcp): enforce per-connection access control and require a token by default * fix(plugins): re-verify plugin signature before lazy load * fix(plugin-mongodb): escape filter values in regex operators to prevent NoSQL injection * fix(ios): validate TLS certificates for verify SSL modes on networked drivers * fix(ios): keep copied database values local and auto-expiring on the clipboard * fix(ios): drop host and port from the widget shared store * build(ios): source the development team from Secrets.xcconfig * build: verify downloaded static libs against the git checksum baseline * ci: pass workflow dispatch and matrix tags through env to block script injection * docs(changelog): note the security fixes * test(connections): match sharing deeplink assertions to the shipped serialization values
1 parent 35bcff6 commit 7a10b8e

45 files changed

Lines changed: 1165 additions & 227 deletions

Some content is hidden

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

.github/workflows/build-plugin.yml

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,13 @@ jobs:
4949
id: tags
5050
env:
5151
DEFAULT_PKV: ${{ steps.pkv.outputs.version }}
52+
INPUT_TAGS: ${{ inputs.tags }}
53+
REF_NAME: ${{ github.ref_name }}
5254
run: |
53-
if [ -n "${{ inputs.tags }}" ]; then
54-
IFS=',' read -ra RAW_TAGS <<< "${{ inputs.tags }}"
55+
if [ -n "$INPUT_TAGS" ]; then
56+
IFS=',' read -ra RAW_TAGS <<< "$INPUT_TAGS"
5557
else
56-
RAW_TAGS=("${{ github.ref_name }}")
58+
RAW_TAGS=("$REF_NAME")
5759
fi
5860
5961
JSON='{"include":['
@@ -128,8 +130,10 @@ jobs:
128130
129131
- name: Resolve plugin info
130132
id: plugin
133+
env:
134+
MATRIX_TAG: ${{ matrix.tag }}
131135
run: |
132-
TAG="${{ matrix.tag }}"
136+
TAG="$MATRIX_TAG"
133137
PLUGIN_NAME=$(echo "$TAG" | sed -E 's/^plugin-([a-z0-9-]+)-v([0-9].*)$/\1/')
134138
VERSION=$(echo "$TAG" | sed -E 's/^plugin-([a-z0-9-]+)-v([0-9].*)$/\2/')
135139
@@ -270,9 +274,11 @@ jobs:
270274
./scripts/build-plugin.sh "${{ steps.plugin.outputs.target }}" x86_64 "${{ steps.plugin.outputs.version }}"
271275
272276
- name: Verify built PluginKit version matches the release label
277+
env:
278+
MATRIX_PKV: ${{ matrix.pluginKitVersion }}
273279
run: |
274280
BUNDLE_NAME="${{ steps.plugin.outputs.bundleName }}"
275-
EXPECTED="${{ matrix.pluginKitVersion }}"
281+
EXPECTED="$MATRIX_PKV"
276282
for ARCH in arm64 x86_64; do
277283
WORK=$(mktemp -d)
278284
unzip -oq "build/Plugins/${BUNDLE_NAME}-${ARCH}.zip" -d "$WORK"
@@ -311,14 +317,16 @@ jobs:
311317
- name: Create GitHub Release
312318
env:
313319
GH_TOKEN: ${{ github.token }}
320+
MATRIX_TAG: ${{ matrix.tag }}
321+
MATRIX_PKV: ${{ matrix.pluginKitVersion }}
314322
run: |
315-
TAG="${{ matrix.tag }}"
323+
TAG="$MATRIX_TAG"
316324
DISPLAY_NAME="${{ steps.plugin.outputs.displayName }}"
317325
VERSION="${{ steps.plugin.outputs.version }}"
318326
BUNDLE_NAME="${{ steps.plugin.outputs.bundleName }}"
319327
ARM64_SHA="${{ steps.sha.outputs.arm64 }}"
320328
X86_SHA="${{ steps.sha.outputs.x86_64 }}"
321-
PKV="${{ matrix.pluginKitVersion }}"
329+
PKV="$MATRIX_PKV"
322330
323331
RELEASE_BODY="## $DISPLAY_NAME v$VERSION
324332
@@ -346,10 +354,13 @@ jobs:
346354
build/Plugins/${BUNDLE_NAME}-x86_64.zip
347355
348356
- name: Verify published assets match the PluginKit label
357+
env:
358+
MATRIX_TAG: ${{ matrix.tag }}
359+
MATRIX_PKV: ${{ matrix.pluginKitVersion }}
349360
run: |
350361
BUNDLE_NAME="${{ steps.plugin.outputs.bundleName }}"
351-
TAG="${{ matrix.tag }}"
352-
PKV="${{ matrix.pluginKitVersion }}"
362+
TAG="$MATRIX_TAG"
363+
PKV="$MATRIX_PKV"
353364
REPO="${{ github.repository }}"
354365
for ARCH in arm64 x86_64; do
355366
URL="https://github.com/${REPO}/releases/download/${TAG}/${BUNDLE_NAME}-${ARCH}.zip"
@@ -386,8 +397,10 @@ jobs:
386397
env:
387398
REGISTRY_DEPLOY_KEY: ${{ secrets.REGISTRY_DEPLOY_KEY }}
388399
GH_TOKEN: ${{ github.token }}
400+
MATRIX_TAG: ${{ matrix.tag }}
401+
MATRIX_PKV: ${{ matrix.pluginKitVersion }}
389402
run: |
390-
TAG="${{ matrix.tag }}"
403+
TAG="$MATRIX_TAG"
391404
BUNDLE_NAME="${{ steps.plugin.outputs.bundleName }}"
392405
BUNDLE_ID="${{ steps.plugin.outputs.bundleId }}"
393406
DISPLAY_NAME="${{ steps.plugin.outputs.displayName }}"
@@ -400,7 +413,7 @@ jobs:
400413
CATEGORY="${{ steps.plugin.outputs.category }}"
401414
ARM64_SHA="${{ steps.sha.outputs.arm64 }}"
402415
X86_SHA="${{ steps.sha.outputs.x86_64 }}"
403-
PKV="${{ matrix.pluginKitVersion }}"
416+
PKV="$MATRIX_PKV"
404417
REPO="${{ github.repository }}"
405418
ARM64_URL="https://github.com/${REPO}/releases/download/${TAG}/${BUNDLE_NAME}-arm64.zip"
406419
X86_64_URL="https://github.com/${REPO}/releases/download/${TAG}/${BUNDLE_NAME}-x86_64.zip"

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
- iCloud Sync between the iPhone and Mac apps: the iOS app now uses the Production CloudKit environment, so a development build no longer syncs into a separate database the Mac never reads.
2121
- Exports no longer fail mid-table on servers that enforce a statement time limit; the export session disables the limit and restores it afterwards, the same way mysqldump does. (#1633)
2222

23+
### Security
24+
25+
- Imported connections from a deep link or shared file can no longer carry a pre-connect script that runs a shell command on connect.
26+
- External database links now ask for confirmation before connecting, and a password in the link is never saved to the Keychain.
27+
- MCP tools now enforce each connection's external access level, per-connection AI policy, and token connection scope on every request.
28+
- The MCP server now requires a paired token by default, even over loopback.
29+
- An installed plugin's code signature is re-checked right before it loads, so the binary cannot be swapped after the first check.
30+
- MongoDB filter values in the Contains, Not Contains, Starts With, Ends With, and Regex operators can no longer inject query operators.
31+
- iOS validates TLS certificates for MySQL, PostgreSQL, and Redis connections set to a verify SSL mode.
32+
- Database values copied on iOS stay on the device and clear from the clipboard after a minute.
33+
- The iOS home screen widget no longer stores database host and port on disk.
34+
2335
## [0.50.0] - 2026-06-09
2436

2537
### Added

Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,13 @@ struct MongoDBQueryBuilder {
118118
case "<=":
119119
return "\"\(field)\": {\"$lte\": \(jsonValue(value))}"
120120
case "CONTAINS":
121-
return "\"\(field)\": {\"$regex\": \"\(escapeRegexChars(value))\", \"$options\": \"i\"}"
121+
return "\"\(field)\": \(Self.regexBody(pattern: escapeRegexChars(value)))"
122122
case "NOT CONTAINS":
123-
return "\"\(field)\": {\"$not\": {\"$regex\": \"\(escapeRegexChars(value))\", \"$options\": \"i\"}}"
123+
return "\"\(field)\": {\"$not\": \(Self.regexBody(pattern: escapeRegexChars(value)))}"
124124
case "STARTS WITH":
125-
return "\"\(field)\": {\"$regex\": \"^\(escapeRegexChars(value))\", \"$options\": \"i\"}"
125+
return "\"\(field)\": \(Self.regexBody(pattern: "^\(escapeRegexChars(value))"))"
126126
case "ENDS WITH":
127-
return "\"\(field)\": {\"$regex\": \"\(escapeRegexChars(value))$\", \"$options\": \"i\"}"
127+
return "\"\(field)\": \(Self.regexBody(pattern: "\(escapeRegexChars(value))$"))"
128128
case "IS NULL":
129129
return "\"\(field)\": null"
130130
case "IS NOT NULL":
@@ -134,7 +134,7 @@ struct MongoDBQueryBuilder {
134134
case "IS NOT EMPTY":
135135
return "\"\(field)\": {\"$ne\": \"\"}"
136136
case "REGEX":
137-
return "\"\(field)\": {\"$regex\": \"\(value)\", \"$options\": \"i\"}"
137+
return "\"\(field)\": \(Self.regexBody(pattern: value))"
138138
case "IN":
139139
let items = value.split(separator: ",")
140140
.map { jsonValue(String($0).trimmingCharacters(in: .whitespaces)) }
@@ -178,6 +178,10 @@ struct MongoDBQueryBuilder {
178178
return "\"\(Self.escapeJsonString(value))\""
179179
}
180180

181+
private static func regexBody(pattern: String) -> String {
182+
"{\"$regex\": \"\(escapeJsonString(pattern))\", \"$options\": \"i\"}"
183+
}
184+
181185
static func escapeJsonString(_ value: String) -> String {
182186
var result = ""
183187
result.reserveCapacity((value as NSString).length)

TablePro/Core/Database/DatabaseManager+EnsureConnected.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,18 @@ import Foundation
77
import os
88

99
extension DatabaseManager {
10-
func ensureConnected(_ connection: DatabaseConnection) async throws {
10+
func ensureConnected(
11+
_ connection: DatabaseConnection,
12+
passwordOverride: String? = nil,
13+
sshPasswordOverride: String? = nil
14+
) async throws {
1115
if activeSessions[connection.id]?.driver != nil { return }
1216
try await ensureConnectedDedup.execute(key: connection.id) {
13-
try await self.connectToSession(connection)
17+
try await self.connectToSession(
18+
connection,
19+
passwordOverride: passwordOverride,
20+
sshPasswordOverride: sshPasswordOverride
21+
)
1422
}
1523
}
1624

TablePro/Core/Database/DatabaseManager+Sessions.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import TableProPluginKit
1414
// MARK: - Session Management
1515

1616
extension DatabaseManager {
17-
func connectToSession(_ requestedConnection: DatabaseConnection) async throws {
17+
func connectToSession(
18+
_ requestedConnection: DatabaseConnection,
19+
passwordOverride incomingPasswordOverride: String? = nil,
20+
sshPasswordOverride: String? = nil
21+
) async throws {
1822
let connection = resolvedConnectionDefinition(for: requestedConnection)
1923

2024
if let existing = activeSessions[connection.id], existing.driver != nil {
@@ -40,7 +44,10 @@ extension DatabaseManager {
4044

4145
let effectiveConnection: DatabaseConnection
4246
do {
43-
effectiveConnection = try await buildEffectiveConnection(for: resolvedConnection)
47+
effectiveConnection = try await buildEffectiveConnection(
48+
for: resolvedConnection,
49+
sshPasswordOverride: sshPasswordOverride
50+
)
4451
} catch {
4552
finalizeConnectionFailure(for: connection.id, cancelled: Task.isCancelled)
4653
throw error
@@ -57,8 +64,8 @@ extension DatabaseManager {
5764
}
5865
}
5966

60-
var passwordOverride: String?
61-
if connection.promptForPassword, !pluginManager.hidesPassword(for: connection) {
67+
var passwordOverride: String? = incomingPasswordOverride
68+
if passwordOverride == nil, connection.promptForPassword, !pluginManager.hidesPassword(for: connection) {
6269
if let cached = activeSessions[connection.id]?.cachedPassword {
6370
passwordOverride = cached
6471
} else {

TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,22 @@ public struct MCPValidatedToken: Sendable, Equatable {
66
public let tokenId: UUID
77
public let label: String?
88
public let scopes: Set<MCPScope>
9+
public let connectionAccess: ConnectionAccess
910
public let issuedAt: Date
1011
public let expiresAt: Date?
1112

1213
public init(
1314
tokenId: UUID,
1415
label: String?,
1516
scopes: Set<MCPScope>,
17+
connectionAccess: ConnectionAccess = .all,
1618
issuedAt: Date,
1719
expiresAt: Date?
1820
) {
1921
self.tokenId = tokenId
2022
self.label = label
2123
self.scopes = scopes
24+
self.connectionAccess = connectionAccess
2225
self.issuedAt = issuedAt
2326
self.expiresAt = expiresAt
2427
}
@@ -51,6 +54,7 @@ internal extension MCPTokenStore {
5154
tokenId: authToken.id,
5255
label: authToken.name,
5356
scopes: Self.mcpScopes(for: authToken.permissions),
57+
connectionAccess: authToken.connectionAccess,
5458
issuedAt: authToken.createdAt,
5559
expiresAt: authToken.expiresAt
5660
)
@@ -160,6 +164,7 @@ public actor MCPBearerTokenAuthenticator: MCPAuthenticator {
160164
tokenFingerprint: fingerprint,
161165
tokenId: validated.tokenId,
162166
scopes: validated.scopes,
167+
connectionAccess: validated.connectionAccess,
163168
metadata: MCPPrincipalMetadata(
164169
label: validated.label,
165170
issuedAt: validated.issuedAt,

TablePro/Core/MCP/Auth/MCPCompositeAuthenticator.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public actor MCPCompositeAuthenticator: MCPAuthenticator {
88
tokenFingerprint: "anonymous-loopback",
99
tokenId: nil,
1010
scopes: [.toolsRead, .toolsWrite, .resourcesRead, .admin],
11+
connectionAccess: .all,
1112
metadata: MCPPrincipalMetadata(
1213
label: "Anonymous (loopback)",
1314
issuedAt: .distantPast,

TablePro/Core/MCP/Auth/MCPPrincipal.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,28 @@ public struct MCPPrincipal: Sendable, Equatable, Hashable {
2323
public let tokenFingerprint: String
2424
public let tokenId: UUID?
2525
public let scopes: Set<MCPScope>
26+
public let connectionAccess: ConnectionAccess
2627
public let metadata: MCPPrincipalMetadata
2728

2829
public init(
2930
tokenFingerprint: String,
3031
tokenId: UUID? = nil,
3132
scopes: Set<MCPScope>,
33+
connectionAccess: ConnectionAccess = .all,
3234
metadata: MCPPrincipalMetadata
3335
) {
3436
self.tokenFingerprint = tokenFingerprint
3537
self.tokenId = tokenId
3638
self.scopes = scopes
39+
self.connectionAccess = connectionAccess
3740
self.metadata = metadata
3841
}
3942

4043
public static func == (lhs: MCPPrincipal, rhs: MCPPrincipal) -> Bool {
4144
lhs.tokenFingerprint == rhs.tokenFingerprint
4245
&& lhs.tokenId == rhs.tokenId
4346
&& lhs.scopes == rhs.scopes
47+
&& lhs.connectionAccess == rhs.connectionAccess
4448
&& lhs.metadata == rhs.metadata
4549
}
4650

0 commit comments

Comments
 (0)