diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 95206552d..ddd650abf 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -49,11 +49,13 @@ jobs: id: tags env: DEFAULT_PKV: ${{ steps.pkv.outputs.version }} + INPUT_TAGS: ${{ inputs.tags }} + REF_NAME: ${{ github.ref_name }} run: | - if [ -n "${{ inputs.tags }}" ]; then - IFS=',' read -ra RAW_TAGS <<< "${{ inputs.tags }}" + if [ -n "$INPUT_TAGS" ]; then + IFS=',' read -ra RAW_TAGS <<< "$INPUT_TAGS" else - RAW_TAGS=("${{ github.ref_name }}") + RAW_TAGS=("$REF_NAME") fi JSON='{"include":[' @@ -128,8 +130,10 @@ jobs: - name: Resolve plugin info id: plugin + env: + MATRIX_TAG: ${{ matrix.tag }} run: | - TAG="${{ matrix.tag }}" + TAG="$MATRIX_TAG" PLUGIN_NAME=$(echo "$TAG" | sed -E 's/^plugin-([a-z0-9-]+)-v([0-9].*)$/\1/') VERSION=$(echo "$TAG" | sed -E 's/^plugin-([a-z0-9-]+)-v([0-9].*)$/\2/') @@ -270,9 +274,11 @@ jobs: ./scripts/build-plugin.sh "${{ steps.plugin.outputs.target }}" x86_64 "${{ steps.plugin.outputs.version }}" - name: Verify built PluginKit version matches the release label + env: + MATRIX_PKV: ${{ matrix.pluginKitVersion }} run: | BUNDLE_NAME="${{ steps.plugin.outputs.bundleName }}" - EXPECTED="${{ matrix.pluginKitVersion }}" + EXPECTED="$MATRIX_PKV" for ARCH in arm64 x86_64; do WORK=$(mktemp -d) unzip -oq "build/Plugins/${BUNDLE_NAME}-${ARCH}.zip" -d "$WORK" @@ -311,14 +317,16 @@ jobs: - name: Create GitHub Release env: GH_TOKEN: ${{ github.token }} + MATRIX_TAG: ${{ matrix.tag }} + MATRIX_PKV: ${{ matrix.pluginKitVersion }} run: | - TAG="${{ matrix.tag }}" + TAG="$MATRIX_TAG" DISPLAY_NAME="${{ steps.plugin.outputs.displayName }}" VERSION="${{ steps.plugin.outputs.version }}" BUNDLE_NAME="${{ steps.plugin.outputs.bundleName }}" ARM64_SHA="${{ steps.sha.outputs.arm64 }}" X86_SHA="${{ steps.sha.outputs.x86_64 }}" - PKV="${{ matrix.pluginKitVersion }}" + PKV="$MATRIX_PKV" RELEASE_BODY="## $DISPLAY_NAME v$VERSION @@ -346,10 +354,13 @@ jobs: build/Plugins/${BUNDLE_NAME}-x86_64.zip - name: Verify published assets match the PluginKit label + env: + MATRIX_TAG: ${{ matrix.tag }} + MATRIX_PKV: ${{ matrix.pluginKitVersion }} run: | BUNDLE_NAME="${{ steps.plugin.outputs.bundleName }}" - TAG="${{ matrix.tag }}" - PKV="${{ matrix.pluginKitVersion }}" + TAG="$MATRIX_TAG" + PKV="$MATRIX_PKV" REPO="${{ github.repository }}" for ARCH in arm64 x86_64; do URL="https://github.com/${REPO}/releases/download/${TAG}/${BUNDLE_NAME}-${ARCH}.zip" @@ -386,8 +397,10 @@ jobs: env: REGISTRY_DEPLOY_KEY: ${{ secrets.REGISTRY_DEPLOY_KEY }} GH_TOKEN: ${{ github.token }} + MATRIX_TAG: ${{ matrix.tag }} + MATRIX_PKV: ${{ matrix.pluginKitVersion }} run: | - TAG="${{ matrix.tag }}" + TAG="$MATRIX_TAG" BUNDLE_NAME="${{ steps.plugin.outputs.bundleName }}" BUNDLE_ID="${{ steps.plugin.outputs.bundleId }}" DISPLAY_NAME="${{ steps.plugin.outputs.displayName }}" @@ -400,7 +413,7 @@ jobs: CATEGORY="${{ steps.plugin.outputs.category }}" ARM64_SHA="${{ steps.sha.outputs.arm64 }}" X86_SHA="${{ steps.sha.outputs.x86_64 }}" - PKV="${{ matrix.pluginKitVersion }}" + PKV="$MATRIX_PKV" REPO="${{ github.repository }}" ARM64_URL="https://github.com/${REPO}/releases/download/${TAG}/${BUNDLE_NAME}-arm64.zip" X86_64_URL="https://github.com/${REPO}/releases/download/${TAG}/${BUNDLE_NAME}-x86_64.zip" diff --git a/CHANGELOG.md b/CHANGELOG.md index 87fe1d61e..ab26208b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. - 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) +### Security + +- Imported connections from a deep link or shared file can no longer carry a pre-connect script that runs a shell command on connect. +- External database links now ask for confirmation before connecting, and a password in the link is never saved to the Keychain. +- MCP tools now enforce each connection's external access level, per-connection AI policy, and token connection scope on every request. +- The MCP server now requires a paired token by default, even over loopback. +- An installed plugin's code signature is re-checked right before it loads, so the binary cannot be swapped after the first check. +- MongoDB filter values in the Contains, Not Contains, Starts With, Ends With, and Regex operators can no longer inject query operators. +- iOS validates TLS certificates for MySQL, PostgreSQL, and Redis connections set to a verify SSL mode. +- Database values copied on iOS stay on the device and clear from the clipboard after a minute. +- The iOS home screen widget no longer stores database host and port on disk. + ## [0.50.0] - 2026-06-09 ### Added diff --git a/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift b/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift index a32443b4d..b3ed845ed 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift @@ -118,13 +118,13 @@ struct MongoDBQueryBuilder { case "<=": return "\"\(field)\": {\"$lte\": \(jsonValue(value))}" case "CONTAINS": - return "\"\(field)\": {\"$regex\": \"\(escapeRegexChars(value))\", \"$options\": \"i\"}" + return "\"\(field)\": \(Self.regexBody(pattern: escapeRegexChars(value)))" case "NOT CONTAINS": - return "\"\(field)\": {\"$not\": {\"$regex\": \"\(escapeRegexChars(value))\", \"$options\": \"i\"}}" + return "\"\(field)\": {\"$not\": \(Self.regexBody(pattern: escapeRegexChars(value)))}" case "STARTS WITH": - return "\"\(field)\": {\"$regex\": \"^\(escapeRegexChars(value))\", \"$options\": \"i\"}" + return "\"\(field)\": \(Self.regexBody(pattern: "^\(escapeRegexChars(value))"))" case "ENDS WITH": - return "\"\(field)\": {\"$regex\": \"\(escapeRegexChars(value))$\", \"$options\": \"i\"}" + return "\"\(field)\": \(Self.regexBody(pattern: "\(escapeRegexChars(value))$"))" case "IS NULL": return "\"\(field)\": null" case "IS NOT NULL": @@ -134,7 +134,7 @@ struct MongoDBQueryBuilder { case "IS NOT EMPTY": return "\"\(field)\": {\"$ne\": \"\"}" case "REGEX": - return "\"\(field)\": {\"$regex\": \"\(value)\", \"$options\": \"i\"}" + return "\"\(field)\": \(Self.regexBody(pattern: value))" case "IN": let items = value.split(separator: ",") .map { jsonValue(String($0).trimmingCharacters(in: .whitespaces)) } @@ -178,6 +178,10 @@ struct MongoDBQueryBuilder { return "\"\(Self.escapeJsonString(value))\"" } + private static func regexBody(pattern: String) -> String { + "{\"$regex\": \"\(escapeJsonString(pattern))\", \"$options\": \"i\"}" + } + static func escapeJsonString(_ value: String) -> String { var result = "" result.reserveCapacity((value as NSString).length) diff --git a/TablePro/Core/Database/DatabaseManager+EnsureConnected.swift b/TablePro/Core/Database/DatabaseManager+EnsureConnected.swift index c9437445c..07e996fb9 100644 --- a/TablePro/Core/Database/DatabaseManager+EnsureConnected.swift +++ b/TablePro/Core/Database/DatabaseManager+EnsureConnected.swift @@ -7,10 +7,18 @@ import Foundation import os extension DatabaseManager { - func ensureConnected(_ connection: DatabaseConnection) async throws { + func ensureConnected( + _ connection: DatabaseConnection, + passwordOverride: String? = nil, + sshPasswordOverride: String? = nil + ) async throws { if activeSessions[connection.id]?.driver != nil { return } try await ensureConnectedDedup.execute(key: connection.id) { - try await self.connectToSession(connection) + try await self.connectToSession( + connection, + passwordOverride: passwordOverride, + sshPasswordOverride: sshPasswordOverride + ) } } diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index b41073c0b..c320205f8 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -14,7 +14,11 @@ import TableProPluginKit // MARK: - Session Management extension DatabaseManager { - func connectToSession(_ requestedConnection: DatabaseConnection) async throws { + func connectToSession( + _ requestedConnection: DatabaseConnection, + passwordOverride incomingPasswordOverride: String? = nil, + sshPasswordOverride: String? = nil + ) async throws { let connection = resolvedConnectionDefinition(for: requestedConnection) if let existing = activeSessions[connection.id], existing.driver != nil { @@ -40,7 +44,10 @@ extension DatabaseManager { let effectiveConnection: DatabaseConnection do { - effectiveConnection = try await buildEffectiveConnection(for: resolvedConnection) + effectiveConnection = try await buildEffectiveConnection( + for: resolvedConnection, + sshPasswordOverride: sshPasswordOverride + ) } catch { finalizeConnectionFailure(for: connection.id, cancelled: Task.isCancelled) throw error @@ -57,8 +64,8 @@ extension DatabaseManager { } } - var passwordOverride: String? - if connection.promptForPassword, !pluginManager.hidesPassword(for: connection) { + var passwordOverride: String? = incomingPasswordOverride + if passwordOverride == nil, connection.promptForPassword, !pluginManager.hidesPassword(for: connection) { if let cached = activeSessions[connection.id]?.cachedPassword { passwordOverride = cached } else { diff --git a/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift b/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift index 4204f0cef..93f3a7780 100644 --- a/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift +++ b/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift @@ -6,6 +6,7 @@ public struct MCPValidatedToken: Sendable, Equatable { public let tokenId: UUID public let label: String? public let scopes: Set + public let connectionAccess: ConnectionAccess public let issuedAt: Date public let expiresAt: Date? @@ -13,12 +14,14 @@ public struct MCPValidatedToken: Sendable, Equatable { tokenId: UUID, label: String?, scopes: Set, + connectionAccess: ConnectionAccess = .all, issuedAt: Date, expiresAt: Date? ) { self.tokenId = tokenId self.label = label self.scopes = scopes + self.connectionAccess = connectionAccess self.issuedAt = issuedAt self.expiresAt = expiresAt } @@ -51,6 +54,7 @@ internal extension MCPTokenStore { tokenId: authToken.id, label: authToken.name, scopes: Self.mcpScopes(for: authToken.permissions), + connectionAccess: authToken.connectionAccess, issuedAt: authToken.createdAt, expiresAt: authToken.expiresAt ) @@ -160,6 +164,7 @@ public actor MCPBearerTokenAuthenticator: MCPAuthenticator { tokenFingerprint: fingerprint, tokenId: validated.tokenId, scopes: validated.scopes, + connectionAccess: validated.connectionAccess, metadata: MCPPrincipalMetadata( label: validated.label, issuedAt: validated.issuedAt, diff --git a/TablePro/Core/MCP/Auth/MCPCompositeAuthenticator.swift b/TablePro/Core/MCP/Auth/MCPCompositeAuthenticator.swift index b21c27376..0c70410a2 100644 --- a/TablePro/Core/MCP/Auth/MCPCompositeAuthenticator.swift +++ b/TablePro/Core/MCP/Auth/MCPCompositeAuthenticator.swift @@ -8,6 +8,7 @@ public actor MCPCompositeAuthenticator: MCPAuthenticator { tokenFingerprint: "anonymous-loopback", tokenId: nil, scopes: [.toolsRead, .toolsWrite, .resourcesRead, .admin], + connectionAccess: .all, metadata: MCPPrincipalMetadata( label: "Anonymous (loopback)", issuedAt: .distantPast, diff --git a/TablePro/Core/MCP/Auth/MCPPrincipal.swift b/TablePro/Core/MCP/Auth/MCPPrincipal.swift index d4de96724..40265eab5 100644 --- a/TablePro/Core/MCP/Auth/MCPPrincipal.swift +++ b/TablePro/Core/MCP/Auth/MCPPrincipal.swift @@ -23,17 +23,20 @@ public struct MCPPrincipal: Sendable, Equatable, Hashable { public let tokenFingerprint: String public let tokenId: UUID? public let scopes: Set + public let connectionAccess: ConnectionAccess public let metadata: MCPPrincipalMetadata public init( tokenFingerprint: String, tokenId: UUID? = nil, scopes: Set, + connectionAccess: ConnectionAccess = .all, metadata: MCPPrincipalMetadata ) { self.tokenFingerprint = tokenFingerprint self.tokenId = tokenId self.scopes = scopes + self.connectionAccess = connectionAccess self.metadata = metadata } @@ -41,6 +44,7 @@ public struct MCPPrincipal: Sendable, Equatable, Hashable { lhs.tokenFingerprint == rhs.tokenFingerprint && lhs.tokenId == rhs.tokenId && lhs.scopes == rhs.scopes + && lhs.connectionAccess == rhs.connectionAccess && lhs.metadata == rhs.metadata } diff --git a/TablePro/Core/MCP/MCPAuthPolicy.swift b/TablePro/Core/MCP/MCPAuthPolicy.swift index 6f91f3122..0e4e65bb3 100644 --- a/TablePro/Core/MCP/MCPAuthPolicy.swift +++ b/TablePro/Core/MCP/MCPAuthPolicy.swift @@ -5,12 +5,7 @@ import os typealias MCPToolName = String extension MCPToolName { - static let stateMutating: Set = [ - "execute_query", "confirm_destructive_operation", - "switch_database", "switch_schema", "export_data" - ] static let requiresFullAccess: Set = ["confirm_destructive_operation"] - static let requiresReadWrite: Set = ["switch_database", "switch_schema", "export_data"] static let writeQueryTools: Set = ["execute_query"] } @@ -20,10 +15,27 @@ enum AuthDecision: Sendable { case denied(reason: String) } +struct MCPConnectionAuthSnapshot: Sendable { + let policy: AIConnectionPolicy + let externalAccess: ExternalAccessLevel + let name: String + let databaseType: String +} + +typealias MCPConnectionSnapshotResolver = @Sendable (UUID) async -> MCPConnectionAuthSnapshot? + public actor MCPAuthPolicy { private static let logger = Logger(subsystem: "com.TablePro", category: "MCPAuthPolicy") - public init() {} + private let connectionResolver: MCPConnectionSnapshotResolver + + public init() { + self.init(connectionResolver: MCPAuthPolicy.defaultConnectionResolver) + } + + init(connectionResolver: @escaping MCPConnectionSnapshotResolver) { + self.connectionResolver = connectionResolver + } private var sessionApprovals: [String: Set] = [:] private let approvalDedup = OnceTask() @@ -33,26 +45,18 @@ public actor MCPAuthPolicy { let connectionId: UUID } - private struct ConnectionSnapshot: Sendable { - let policy: AIConnectionPolicy - let externalAccess: ExternalAccessLevel - let name: String - let databaseType: String - let safeModeLevel: SafeModeLevel - } - func authorize( - token: MCPAuthToken, + principal: MCPPrincipal, tool: MCPToolName, connectionId: UUID?, sql: String? = nil, sessionId: String ) async throws -> AuthDecision { guard let connectionId else { - return decideTokenTier(token: token, tool: tool) + return .allowed } - guard let snapshot = await loadConnection(connectionId) else { + guard let snapshot = await connectionResolver(connectionId) else { return .denied(reason: String(localized: "Connection not found")) } @@ -64,14 +68,10 @@ public actor MCPAuthPolicy { return .denied(reason: String(localized: "External access is disabled for this connection")) } - if !token.connectionAccess.allows(connectionId) { + if !principal.connectionAccess.allows(connectionId) { return .denied(reason: String(localized: "Token does not have access to this connection")) } - if case .denied(let reason) = decideTokenTier(token: token, tool: tool) { - return .denied(reason: reason) - } - if let writeReason = denialForWriteIntent( tool: tool, sql: sql, @@ -97,14 +97,14 @@ public actor MCPAuthPolicy { } func resolveAndAuthorize( - token: MCPAuthToken, + principal: MCPPrincipal, tool: MCPToolName, connectionId: UUID?, sql: String? = nil, sessionId: String ) async throws { let decision = try await authorize( - token: token, + principal: principal, tool: tool, connectionId: connectionId, sql: sql, @@ -229,34 +229,13 @@ public actor MCPAuthPolicy { } } - private func decideTokenTier(token: MCPAuthToken, tool: MCPToolName) -> AuthDecision { - let required = requiredPermission(for: tool) - if token.permissions.satisfies(required) { - return .allowed - } - return .denied( - reason: String( - format: String(localized: "Token '%@' with permission '%@' cannot access '%@'"), - token.name, - token.permissions.displayName, - tool - ) - ) - } - - private func requiredPermission(for tool: MCPToolName) -> TokenPermissions { - if MCPToolName.requiresFullAccess.contains(tool) { return .fullAccess } - if MCPToolName.requiresReadWrite.contains(tool) { return .readWrite } - return .readOnly - } - private func denialForWriteIntent( tool: MCPToolName, sql: String?, externalAccess: ExternalAccessLevel, databaseType: String ) -> String? { - if MCPToolName.requiresReadWrite.contains(tool) || MCPToolName.requiresFullAccess.contains(tool) { + if MCPToolName.requiresFullAccess.contains(tool) { if externalAccess != .readWrite { return String(localized: "Connection is read only for external clients") } @@ -277,26 +256,23 @@ public actor MCPAuthPolicy { return nil } - private func loadConnection(_ connectionId: UUID) async -> ConnectionSnapshot? { + private static let defaultConnectionResolver: MCPConnectionSnapshotResolver = { connectionId in await MainActor.run { - let state = DatabaseManager.shared.connectionState(connectionId) - switch state { + switch DatabaseManager.shared.connectionState(connectionId) { case .live(_, let session): let conn = session.connection - return ConnectionSnapshot( + return MCPConnectionAuthSnapshot( policy: conn.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, externalAccess: conn.externalAccess, name: conn.name, - databaseType: conn.type.rawValue, - safeModeLevel: session.safeModeLevel + databaseType: conn.type.rawValue ) case .stored(let conn): - return ConnectionSnapshot( + return MCPConnectionAuthSnapshot( policy: conn.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, externalAccess: conn.externalAccess, name: conn.name, - databaseType: conn.type.rawValue, - safeModeLevel: conn.safeModeLevel + databaseType: conn.type.rawValue ) case .unknown: return nil diff --git a/TablePro/Core/MCP/MCPTokenStore.swift b/TablePro/Core/MCP/MCPTokenStore.swift index bc71debf1..13643d52d 100644 --- a/TablePro/Core/MCP/MCPTokenStore.swift +++ b/TablePro/Core/MCP/MCPTokenStore.swift @@ -3,7 +3,7 @@ import Foundation import os import Security -enum ConnectionAccess: Sendable, Codable, Equatable { +public enum ConnectionAccess: Sendable, Codable, Equatable { case all case limited(Set) @@ -14,7 +14,7 @@ enum ConnectionAccess: Sendable, Codable, Equatable { } } - func allows(_ connectionId: UUID) -> Bool { + public func allows(_ connectionId: UUID) -> Bool { switch self { case .all: return true case .limited(let ids): return ids.contains(connectionId) diff --git a/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift index 6674a4714..1e2b4da8e 100644 --- a/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift +++ b/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift @@ -28,18 +28,26 @@ public struct ToolsCallHandler: MCPMethodHandler { } let toolType = type(of: tool) + let connectionId = Self.connectionId(in: arguments) if !toolType.requiredScopes.isSubset(of: context.principal.scopes) { MCPAuditLogger.logToolCalled( tokenId: nil, tokenName: context.principal.metadata.label, toolName: toolName, - connectionId: Self.connectionId(in: arguments), + connectionId: connectionId, outcome: .denied, errorMessage: "missing_scope" ) throw MCPProtocolError.forbidden(reason: "Tool '\(toolName)' requires additional scopes") } + try await authorizeConnectionAccess( + toolName: toolName, + arguments: arguments, + connectionId: connectionId, + context: context + ) + Self.logger.info("tools/call name=\(toolName, privacy: .public)") do { @@ -48,7 +56,7 @@ public struct ToolsCallHandler: MCPMethodHandler { tokenId: nil, tokenName: context.principal.metadata.label, toolName: toolName, - connectionId: Self.connectionId(in: arguments), + connectionId: connectionId, outcome: result.isError ? .error : .success ) return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result.asJsonValue()) @@ -57,7 +65,7 @@ public struct ToolsCallHandler: MCPMethodHandler { tokenId: nil, tokenName: context.principal.metadata.label, toolName: toolName, - connectionId: Self.connectionId(in: arguments), + connectionId: connectionId, outcome: .error, errorMessage: (error as? MCPProtocolError)?.message ?? error.localizedDescription ) @@ -65,9 +73,45 @@ public struct ToolsCallHandler: MCPMethodHandler { } } + private func authorizeConnectionAccess( + toolName: String, + arguments: JsonValue, + connectionId: UUID?, + context: MCPRequestContext + ) async throws { + do { + try await services.authPolicy.resolveAndAuthorize( + principal: context.principal, + tool: toolName, + connectionId: connectionId, + sql: Self.sqlArgument(in: arguments), + sessionId: context.sessionId.rawValue + ) + } catch let error as MCPDataLayerError { + MCPAuditLogger.logToolCalled( + tokenId: nil, + tokenName: context.principal.metadata.label, + toolName: toolName, + connectionId: connectionId, + outcome: .denied, + errorMessage: error.message + ) + if case .forbidden(let reason, _) = error { + throw MCPProtocolError.forbidden(reason: reason) + } + throw error + } + } + private static func connectionId(in arguments: JsonValue) -> UUID? { guard case .object(let object) = arguments, case .string(let value)? = object["connection_id"] else { return nil } return UUID(uuidString: value) } + + private static func sqlArgument(in arguments: JsonValue) -> String? { + guard case .object(let object) = arguments, + case .string(let value)? = object["query"] else { return nil } + return value + } } diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index ac4ba944c..3afcecd67 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -425,6 +425,18 @@ final class PluginManager { let bundleId = bundle.bundleIdentifier ?? url.lastPathComponent guard !activatedBundleIds.contains(bundleId) else { return } + let entry = plugins.first(where: { $0.id == bundleId }) + + if entry?.source != .builtIn { + do { + try verifyCodeSignature(bundle: bundle) + } catch { + Self.logger.error("Refusing to activate lazy plugin '\(bundleId)': code-signature re-check failed before load: \(error.localizedDescription)") + recordLazyActivationRejection(url: url, bundleId: bundleId, entry: entry, error: error) + return + } + } + guard bundle.load() else { Self.logger.error("Failed to load lazy bundle '\(bundleId)' at \(url.lastPathComponent)") return @@ -437,7 +449,7 @@ final class PluginManager { validateCapabilityDeclarations(principalClass, pluginId: bundleId) - let isEnabled = plugins.first(where: { $0.id == bundleId })?.isEnabled ?? false + let isEnabled = entry?.isEnabled ?? false if isEnabled { let instance = principalClass.init() registerCapabilities(instance, pluginId: bundleId) @@ -448,6 +460,26 @@ final class PluginManager { Self.logger.info("Activated plugin '\(bundleId)' on demand") } + private func recordLazyActivationRejection(url: URL, bundleId: String, entry: PluginEntry?, error: Error) { + guard !rejectedPlugins.contains(where: { $0.url == url }) else { return } + var providedDatabaseTypeIds: [String] = [] + if let entry { + if let primaryTypeId = entry.databaseTypeId { + providedDatabaseTypeIds.append(primaryTypeId) + } + providedDatabaseTypeIds.append(contentsOf: entry.additionalTypeIds) + } + rejectedPlugins.append(RejectedPlugin( + url: url, + bundleId: bundleId, + registryId: Self.readRegistryMetadata(for: url)?.pluginId, + name: entry?.name ?? bundleId, + reason: error.localizedDescription, + isOutdated: false, + providedDatabaseTypeIds: providedDatabaseTypeIds + )) + } + private struct ValidatedBundle: @unchecked Sendable { let url: URL let source: PluginSource diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index 2b630ca33..f859c1aaf 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -425,7 +425,15 @@ enum ConnectionExportService { throw ConnectionExportError.unsupportedVersion(envelope.formatVersion) } - return envelope + return ConnectionExportEnvelope( + formatVersion: envelope.formatVersion, + exportedAt: envelope.exportedAt, + appVersion: envelope.appVersion, + connections: envelope.connections.map { $0.sanitizedForImport() }, + groups: envelope.groups, + tags: envelope.tags, + credentials: envelope.credentials + ) } static func analyzeImport(_ envelope: ConnectionExportEnvelope) -> ConnectionImportPreview { diff --git a/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift b/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift index 0455a3b74..036bbaed4 100644 --- a/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift +++ b/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift @@ -332,7 +332,7 @@ internal enum DeeplinkParser { localOnly: value("localOnly") == "1" ? true : nil ) - return .success(.importConnection(exportable)) + return .success(.importConnection(exportable.sanitizedForImport())) } private static func pathSegments(_ url: URL) -> [String] { diff --git a/TablePro/Core/Services/Infrastructure/TabRouter.swift b/TablePro/Core/Services/Infrastructure/TabRouter.swift index 278811dde..71e21fe94 100644 --- a/TablePro/Core/Services/Infrastructure/TabRouter.swift +++ b/TablePro/Core/Services/Infrastructure/TabRouter.swift @@ -98,7 +98,8 @@ internal final class TabRouter { private func openTable( connectionId: UUID, transientConnection: DatabaseConnection? = nil, - database: String?, schema: String?, table: String, isView: Bool + database: String?, schema: String?, table: String, isView: Bool, + passwordOverride: String? = nil, sshPasswordOverride: String? = nil ) async throws { let connection: DatabaseConnection if let transientConnection { @@ -109,7 +110,11 @@ internal final class TabRouter { throw TabRouterError.connectionNotFound(connectionId) } try await runPreConnectScriptIfNeeded(connection) - try await DatabaseManager.shared.ensureConnected(connection) + try await DatabaseManager.shared.ensureConnected( + connection, + passwordOverride: passwordOverride, + sshPasswordOverride: sshPasswordOverride + ) if let schema { await switchSchemaOrDatabase(connectionId: connectionId, target: schema) @@ -247,46 +252,84 @@ internal final class TabRouter { isTransient = true } - if !parsed.password.isEmpty { - ConnectionStorage.shared.savePassword(parsed.password, for: connection.id) - } - if let sshPass = parsed.sshPassword, !sshPass.isEmpty { - ConnectionStorage.shared.saveSSHPassword(sshPass, for: connection.id) + guard await confirmExternalDatabaseConnection(connection) else { + throw TabRouterError.userCancelled } - do { - if let table = parsed.tableName { - try await openTable( - connectionId: connection.id, - transientConnection: isTransient ? connection : nil, - database: parsed.database.isEmpty ? nil : parsed.database, - schema: parsed.schema, - table: table, - isView: parsed.isView - ) - if parsed.filterColumn != nil || parsed.filterCondition != nil { - try await applyFilterFromParsedURL(parsed: parsed, connectionId: connection.id) - } - return + let passwordOverride = parsed.password.isEmpty ? nil : parsed.password + let sshPasswordOverride = parsed.sshPassword.flatMap { $0.isEmpty ? nil : $0 } + + if let table = parsed.tableName { + try await openTable( + connectionId: connection.id, + transientConnection: isTransient ? connection : nil, + database: parsed.database.isEmpty ? nil : parsed.database, + schema: parsed.schema, + table: table, + isView: parsed.isView, + passwordOverride: passwordOverride, + sshPasswordOverride: sshPasswordOverride + ) + if parsed.filterColumn != nil || parsed.filterCondition != nil { + try await applyFilterFromParsedURL(parsed: parsed, connectionId: connection.id) } + return + } - try await runPreConnectScriptIfNeeded(connection) - let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) - WindowManager.shared.openTab(payload: payload) - NSApp.activate(ignoringOtherApps: true) - try await DatabaseManager.shared.ensureConnected(connection) - closeWelcomeWindows() + try await runPreConnectScriptIfNeeded(connection) + let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) + WindowManager.shared.openTab(payload: payload) + NSApp.activate(ignoringOtherApps: true) + try await DatabaseManager.shared.ensureConnected( + connection, + passwordOverride: passwordOverride, + sshPasswordOverride: sshPasswordOverride + ) + closeWelcomeWindows() - if let schema = parsed.schema { - await switchSchemaOrDatabase(connectionId: connection.id, target: schema) - } - } catch { - if isTransient { - ConnectionStorage.shared.deletePassword(for: connection.id) - ConnectionStorage.shared.deleteSSHPassword(for: connection.id) + if let schema = parsed.schema { + await switchSchemaOrDatabase(connectionId: connection.id, target: schema) + } + } + + private func confirmExternalDatabaseConnection(_ connection: DatabaseConnection) async -> Bool { + var details: [String] = [ + String(format: String(localized: "Host: %@"), "\(connection.host):\(connection.port)") + ] + if !connection.username.isEmpty { + details.append(String(format: String(localized: "User: %@"), connection.username)) + } + if !connection.database.isEmpty { + details.append(String(format: String(localized: "Database: %@"), connection.database)) + } + + let alert = NSAlert() + alert.messageText = String(localized: "Open External Database Connection?") + alert.informativeText = String( + format: String(localized: """ + An external link wants to connect to a %@ database: + + %@ + + Connect only if you trust the source of this link. + """), + connection.type.rawValue, + details.joined(separator: "\n") + ) + alert.alertStyle = .warning + alert.addButton(withTitle: String(localized: "Connect")) + alert.addButton(withTitle: String(localized: "Cancel")) + alert.buttons[1].keyEquivalent = "\r" + alert.buttons[0].keyEquivalent = "" + + if let window = AlertHelper.resolveWindow(NSApp.keyWindow) { + return await withCheckedContinuation { continuation in + alert.beginSheetModal(for: window) { response in + continuation.resume(returning: response == .alertFirstButtonReturn) + } } - throw error } + return alert.runModal() == .alertFirstButtonReturn } // MARK: - Database File diff --git a/TablePro/Models/Connection/ConnectionExport.swift b/TablePro/Models/Connection/ConnectionExport.swift index d1cce6a26..5715841d7 100644 --- a/TablePro/Models/Connection/ConnectionExport.swift +++ b/TablePro/Models/Connection/ConnectionExport.swift @@ -84,6 +84,25 @@ struct ExportableConnection: Codable { } } +extension ExportableConnection { + static let importBlockedAdditionalFieldKeys: Set = ["preConnectScript"] + + func sanitizedForImport() -> ExportableConnection { + guard let additionalFields else { return self } + let allowed = additionalFields.filter { !Self.importBlockedAdditionalFieldKeys.contains($0.key) } + guard allowed.count != additionalFields.count 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, + groupName: groupName, sshProfileId: sshProfileId, + safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, + additionalFields: allowed.isEmpty ? nil : allowed, redisDatabase: redisDatabase, + startupCommands: startupCommands, localOnly: localOnly + ) + } +} + // MARK: - SSH Config struct ExportableSSHConfig: Codable { diff --git a/TablePro/Models/Settings/MCPSettings.swift b/TablePro/Models/Settings/MCPSettings.swift index 09d77cd9f..b90bf1c96 100644 --- a/TablePro/Models/Settings/MCPSettings.swift +++ b/TablePro/Models/Settings/MCPSettings.swift @@ -17,7 +17,7 @@ struct MCPSettings: Codable, Equatable { maxRowLimit: 10_000, queryTimeoutSeconds: 30, logQueriesInHistory: true, - requireAuthentication: false, + requireAuthentication: true, allowRemoteConnections: false ) @@ -28,7 +28,7 @@ struct MCPSettings: Codable, Equatable { maxRowLimit: Int = 10_000, queryTimeoutSeconds: Int = 30, logQueriesInHistory: Bool = true, - requireAuthentication: Bool = false, + requireAuthentication: Bool = true, allowRemoteConnections: Bool = false ) { self.enabled = enabled @@ -50,7 +50,7 @@ struct MCPSettings: Codable, Equatable { maxRowLimit = try container.decodeIfPresent(Int.self, forKey: .maxRowLimit) ?? 10_000 queryTimeoutSeconds = try container.decodeIfPresent(Int.self, forKey: .queryTimeoutSeconds) ?? 30 logQueriesInHistory = try container.decodeIfPresent(Bool.self, forKey: .logQueriesInHistory) ?? true - requireAuthentication = try container.decodeIfPresent(Bool.self, forKey: .requireAuthentication) ?? false + requireAuthentication = try container.decodeIfPresent(Bool.self, forKey: .requireAuthentication) ?? true allowRemoteConnections = try container.decodeIfPresent(Bool.self, forKey: .allowRemoteConnections) ?? false } } diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index 2954f68cd..df8d8290f 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -1976,7 +1976,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2042,7 +2042,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2157,7 +2157,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; MARKETING_VERSION = 1.0; @@ -2180,7 +2180,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = D7HJ5TFYCU; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; MARKETING_VERSION = 1.0; diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 355ae79c3..e27eea643 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -318,8 +318,6 @@ final class AppState { id: conn.id, name: conn.name.isEmpty ? conn.host : conn.name, type: conn.type.rawValue, - host: conn.host, - port: conn.port, sortOrder: conn.sortOrder ) } diff --git a/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift b/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift new file mode 100644 index 000000000..5faab8a4b --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift @@ -0,0 +1,52 @@ +import Foundation +import TableProModels + +struct DriverSSLConfiguration: Equatable, Sendable { + let mode: SSLConfiguration.SSLMode + let caCertificatePath: String? + + static let disabled = DriverSSLConfiguration(mode: .disable) + + init(mode: SSLConfiguration.SSLMode, caCertificatePath: String? = nil) { + self.mode = mode + self.caCertificatePath = caCertificatePath + } + + init(sslEnabled: Bool, configuration: SSLConfiguration?) { + guard let configuration else { + mode = sslEnabled ? .require : .disable + caCertificatePath = nil + return + } + mode = configuration.mode + caCertificatePath = configuration.caCertificatePath + } + + var isEnabled: Bool { mode != .disable } + var verifiesCertificate: Bool { mode == .verifyCa || mode == .verifyFull } + var verifiesHostname: Bool { mode == .verifyFull } + + var postgresSSLMode: String { + switch mode { + case .disable: return "disable" + case .require: return "require" + case .verifyCa: return "verify-ca" + case .verifyFull: return "verify-full" + } + } + + var freetdsEncryptionFlag: String { + switch mode { + case .disable: return "off" + case .require, .verifyCa, .verifyFull: return "require" + } + } + + var existingCACertificatePath: String? { + guard verifiesCertificate, + let path = caCertificatePath, + !path.isEmpty, + FileManager.default.fileExists(atPath: path) else { return nil } + return path + } +} diff --git a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift index b0435ab8b..91448c7b3 100644 --- a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift @@ -36,7 +36,10 @@ final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { password: password ?? "", database: connection.database, schema: MSSQLConnectionOptions.schema(from: connection.additionalFields), - encryptionFlag: Self.freetdsEncryptionFlag(for: connection.sslConfiguration), + encryptionFlag: DriverSSLConfiguration( + sslEnabled: connection.sslEnabled, + configuration: connection.sslConfiguration + ).freetdsEncryptionFlag, loginTimeoutSeconds: Int(connection.additionalFields["mssqlLoginTimeout"] ?? "") ?? MSSQLConnectionOptions.defaultLoginTimeoutSeconds ) self.conn = FreeTDSConnection(options: options) @@ -44,15 +47,6 @@ final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { self.currentSchema = options.schema } - private static func freetdsEncryptionFlag(for ssl: SSLConfiguration?) -> String { - guard let mode = ssl?.mode else { return "off" } - switch mode { - case .disable: return "off" - case .require: return "require" - case .verifyCa, .verifyFull: return "require" - } - } - private var escapedSchema: String { (currentSchema ?? "dbo").replacingOccurrences(of: "'", with: "''") } diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index 5bd36f498..76929a464 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -10,7 +10,7 @@ final class MySQLDriver: DatabaseDriver, @unchecked Sendable { private let user: String private let password: String private let database: String - let sslEnabled: Bool + let ssl: DriverSSLConfiguration var supportsSchemas: Bool { false } var currentSchema: String? { nil } @@ -19,20 +19,20 @@ final class MySQLDriver: DatabaseDriver, @unchecked Sendable { // Set once during connect() before the driver is shared — safe for concurrent reads nonisolated(unsafe) private(set) var serverVersion: String? - init(host: String, port: Int, user: String, password: String, database: String, sslEnabled: Bool = false) { + init(host: String, port: Int, user: String, password: String, database: String, ssl: DriverSSLConfiguration = .disabled) { self.host = host self.port = port self.user = user self.password = password self.database = database - self.sslEnabled = sslEnabled + self.ssl = ssl } // MARK: - Connection func connect() async throws { try await LocalNetworkPermission.shared.ensureAccess(for: host) - try await actor.connect(host: host, port: port, user: user, password: password, database: database, sslEnabled: sslEnabled) + try await actor.connect(host: host, port: port, user: user, password: password, database: database, ssl: ssl) serverVersion = await actor.serverVersion() } @@ -256,7 +256,7 @@ final class MySQLDriver: DatabaseDriver, @unchecked Sendable { private actor MySQLActor { private var mysql: UnsafeMutablePointer? - func connect(host: String, port: Int, user: String, password: String, database: String, sslEnabled: Bool) throws { + func connect(host: String, port: Int, user: String, password: String, database: String, ssl: DriverSSLConfiguration) throws { // Close existing connection if reconnecting if let mysql { mysql_close(mysql); self.mysql = nil } @@ -276,16 +276,12 @@ private actor MySQLActor { var reconnect: my_bool = 0 mysql_options(handle, MYSQL_OPT_RECONNECT, &reconnect) - if sslEnabled { - var sslEnforce: my_bool = 1 - mysql_options(handle, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) - var sslVerify: my_bool = 0 - mysql_options(handle, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &sslVerify) - } else { - var sslEnforce: my_bool = 0 - mysql_options(handle, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) - var sslVerify: my_bool = 0 - mysql_options(handle, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &sslVerify) + var sslEnforce: my_bool = ssl.isEnabled ? 1 : 0 + mysql_options(handle, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) + var sslVerify: my_bool = ssl.verifiesCertificate ? 1 : 0 + mysql_options(handle, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &sslVerify) + if let caPath = ssl.existingCACertificatePath { + _ = caPath.withCString { mysql_options(handle, MYSQL_OPT_SSL_CA, $0) } } guard let portU32 = UInt32(exactly: port), (1...65_535).contains(port) else { diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift index 9c423dc02..d2c43a95c 100644 --- a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -10,7 +10,7 @@ final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { private let user: String private let password: String private let database: String - private let sslEnabled: Bool + private let ssl: DriverSSLConfiguration var supportsSchemas: Bool { true } var supportsTransactions: Bool { true } @@ -19,20 +19,20 @@ final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { nonisolated(unsafe) private(set) var currentSchema: String? = "public" nonisolated(unsafe) private(set) var serverVersion: String? - init(host: String, port: Int, user: String, password: String, database: String, sslEnabled: Bool = false) { + init(host: String, port: Int, user: String, password: String, database: String, ssl: DriverSSLConfiguration = .disabled) { self.host = host self.port = port self.user = user self.password = password self.database = database - self.sslEnabled = sslEnabled + self.ssl = ssl } // MARK: - Connection func connect() async throws { try await LocalNetworkPermission.shared.ensureAccess(for: host) - try await actor.connect(host: host, port: port, user: user, password: password, database: database, sslEnabled: sslEnabled) + try await actor.connect(host: host, port: port, user: user, password: password, database: database, ssl: ssl) serverVersion = await actor.serverVersion() } @@ -326,7 +326,7 @@ final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { private actor PostgreSQLActor { private var conn: OpaquePointer? - func connect(host: String, port: Int, user: String, password: String, database: String, sslEnabled: Bool = false) throws { + func connect(host: String, port: Int, user: String, password: String, database: String, ssl: DriverSSLConfiguration = .disabled) throws { guard (1...65_535).contains(port) else { throw PostgreSQLError.connectionFailed( "Port \(port) is out of range. Use a value between 1 and 65535." @@ -339,10 +339,12 @@ private actor PostgreSQLActor { let escapedUser = escapeConnParam(user) let escapedPass = escapeConnParam(password) let escapedDb = escapeConnParam(database) - let sslmode = sslEnabled ? "require" : "disable" - let connStr = "host='\(escapedHost)' port='\(port)' dbname='\(escapedDb)' " + - "user='\(escapedUser)' password='\(escapedPass)' connect_timeout='10' sslmode='\(sslmode)'" + var connStr = "host='\(escapedHost)' port='\(port)' dbname='\(escapedDb)' " + + "user='\(escapedUser)' password='\(escapedPass)' connect_timeout='10' sslmode='\(ssl.postgresSSLMode)'" + if let caPath = ssl.existingCACertificatePath { + connStr += " sslrootcert='\(escapeConnParam(caPath))'" + } let connection = PQconnectdb(connStr) diff --git a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift index b98e70b1c..478f85e73 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift @@ -10,7 +10,7 @@ final class RedisDriver: DatabaseDriver, @unchecked Sendable { private let port: Int private let password: String? private let database: Int - let sslEnabled: Bool + let ssl: DriverSSLConfiguration var supportsSchemas: Bool { false } var currentSchema: String? { nil } @@ -19,19 +19,19 @@ final class RedisDriver: DatabaseDriver, @unchecked Sendable { // Set once during connect() before the driver is shared — safe for concurrent reads nonisolated(unsafe) private(set) var serverVersion: String? - init(host: String, port: Int, password: String?, database: Int = 0, sslEnabled: Bool = false) { + init(host: String, port: Int, password: String?, database: Int = 0, ssl: DriverSSLConfiguration = .disabled) { self.host = host self.port = port self.password = password self.database = database - self.sslEnabled = sslEnabled + self.ssl = ssl } // MARK: - Connection func connect() async throws { try await LocalNetworkPermission.shared.ensureAccess(for: host) - try await actor.connect(host: host, port: port, password: password, database: database, sslEnabled: sslEnabled) + try await actor.connect(host: host, port: port, password: password, database: database, ssl: ssl) serverVersion = try? await actor.fetchServerVersion() } @@ -337,6 +337,11 @@ private enum RedisReplyValue: Sendable { } } +private func withOptionalCString(_ string: String?, _ body: (UnsafePointer?) throws -> R) rethrows -> R { + guard let string else { return try body(nil) } + return try string.withCString { try body($0) } +} + // MARK: - Redis Actor (thread-safe C API access) private actor RedisActor { @@ -351,7 +356,7 @@ private actor RedisActor { } }() - func connect(host: String, port: Int, password: String?, database: Int, sslEnabled: Bool) throws { + func connect(host: String, port: Int, password: String?, database: Int, ssl: DriverSSLConfiguration) throws { // Close existing connection if reconnecting close() @@ -374,32 +379,35 @@ private actor RedisActor { tv = timeval(tv_sec: 30, tv_usec: 0) redisSetTimeout(context, tv) - if sslEnabled { + if ssl.isEnabled { _ = Self.initSSL - let ssl: OpaquePointer = try host.withCString { hostCStr in - var sslError = redisSSLContextError(0) - var options = redisSSLOptions() - memset(&options, 0, MemoryLayout.size) - options.server_name = hostCStr - options.verify_mode = REDIS_SSL_VERIFY_NONE - - guard let ssl = redisCreateSSLContextWithOptions(&options, &sslError) else { - redisFree(context) - throw RedisError.connectionFailed("Failed to create SSL context (error \(sslError.rawValue))") + let sslCtx: OpaquePointer = try host.withCString { hostCStr in + try withOptionalCString(ssl.existingCACertificatePath) { caCStr in + var sslError = redisSSLContextError(0) + var options = redisSSLOptions() + memset(&options, 0, MemoryLayout.size) + options.server_name = hostCStr + options.cacert_filename = caCStr + options.verify_mode = ssl.verifiesCertificate ? REDIS_SSL_VERIFY_PEER : REDIS_SSL_VERIFY_NONE + + guard let created = redisCreateSSLContextWithOptions(&options, &sslError) else { + redisFree(context) + throw RedisError.connectionFailed("Failed to create SSL context (error \(sslError.rawValue))") + } + return created } - return ssl } - let result = redisInitiateSSLWithContext(context, ssl) + let result = redisInitiateSSLWithContext(context, sslCtx) if result != REDIS_OK { - redisFreeSSLContext(ssl) + redisFreeSSLContext(sslCtx) let msg = withUnsafePointer(to: &context.pointee.errstr.0) { String(cString: $0) } redisFree(context) throw RedisError.connectionFailed("SSL handshake failed: \(msg)") } - self.sslContext = ssl + self.sslContext = sslCtx } self.ctx = context diff --git a/TableProMobile/TableProMobile/Helpers/ClipboardExporter.swift b/TableProMobile/TableProMobile/Helpers/ClipboardExporter.swift index f6e3d230e..b5b6137b9 100644 --- a/TableProMobile/TableProMobile/Helpers/ClipboardExporter.swift +++ b/TableProMobile/TableProMobile/Helpers/ClipboardExporter.swift @@ -1,6 +1,7 @@ import Foundation import TableProModels import UIKit +import UniformTypeIdentifiers enum ExportFormat: String, CaseIterable, Identifiable { case json = "JSON" @@ -40,8 +41,21 @@ enum ClipboardExporter { } } + static let pasteboardExpiry: TimeInterval = 60 + + static func pasteboardPayload(_ text: String, now: Date = Date()) -> (items: [[String: Any]], options: [UIPasteboard.OptionsKey: Any]) { + ( + items: [[UTType.utf8PlainText.identifier: text]], + options: [ + .localOnly: true, + .expirationDate: now.addingTimeInterval(pasteboardExpiry), + ] + ) + } + static func copyToClipboard(_ text: String) { - UIPasteboard.general.string = text + let payload = pasteboardPayload(text) + UIPasteboard.general.setItems(payload.items, options: payload.options) } // MARK: - Private diff --git a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index 78f73ebdf..9b1d10f8c 100644 --- a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -25,7 +25,7 @@ final class IOSDriverFactory: DriverFactory { user: connection.username, password: password ?? "", database: connection.database, - sslEnabled: connection.sslEnabled + ssl: DriverSSLConfiguration(sslEnabled: connection.sslEnabled, configuration: connection.sslConfiguration) ) case .postgresql, .redshift: return PostgreSQLDriver( @@ -34,7 +34,7 @@ final class IOSDriverFactory: DriverFactory { user: connection.username, password: password ?? "", database: connection.database, - sslEnabled: connection.sslEnabled + ssl: DriverSSLConfiguration(sslEnabled: connection.sslEnabled, configuration: connection.sslConfiguration) ) case .redis: let dbIndex = Int(connection.database) ?? 0 @@ -43,7 +43,7 @@ final class IOSDriverFactory: DriverFactory { port: connection.port, password: password, database: dbIndex, - sslEnabled: connection.sslEnabled + ssl: DriverSSLConfiguration(sslEnabled: connection.sslEnabled, configuration: connection.sslConfiguration) ) case .mssql: return MSSQLDriver(connection: connection, password: password) diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index ab3a81326..4139ea6a9 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -305,7 +305,7 @@ struct QueryEditorView: View { private func resultRowContextMenu(columns: [ColumnInfo], row: [String?]) -> some View { if let firstValue = row.first, let value = firstValue { Button { - UIPasteboard.general.string = value + ClipboardExporter.copyToClipboard(value) } label: { Label("Copy Value", systemImage: "doc.on.doc") } diff --git a/TableProMobile/TableProMobile/Views/QueryHistoryView.swift b/TableProMobile/TableProMobile/Views/QueryHistoryView.swift index 1e741c00c..eff6f061f 100644 --- a/TableProMobile/TableProMobile/Views/QueryHistoryView.swift +++ b/TableProMobile/TableProMobile/Views/QueryHistoryView.swift @@ -1,5 +1,4 @@ import SwiftUI -import UIKit struct QueryHistoryView: View { @Environment(ConnectionCoordinator.self) private var coordinator @@ -24,7 +23,7 @@ struct QueryHistoryView: View { } .contextMenu { Button { - UIPasteboard.general.string = item.query + ClipboardExporter.copyToClipboard(item.query) } label: { Label("Copy Query", systemImage: "doc.on.doc") } diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index 250965931..e6f9465ec 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -216,13 +216,13 @@ struct RowDetailView: View { .contextMenu { if let value { Button { - UIPasteboard.general.string = value + ClipboardExporter.copyToClipboard(value) } label: { Label("Copy Value", systemImage: "doc.on.doc") } } Button { - UIPasteboard.general.string = column.name + ClipboardExporter.copyToClipboard(column.name) } label: { Label("Copy Column Name", systemImage: "textformat") } diff --git a/TableProMobile/TableProMobile/Views/TableListView.swift b/TableProMobile/TableProMobile/Views/TableListView.swift index eba03c9a8..86313c406 100644 --- a/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/TableProMobile/TableProMobile/Views/TableListView.swift @@ -61,7 +61,7 @@ struct TableListView: View { } .contextMenu { Button { - UIPasteboard.general.string = table.name + ClipboardExporter.copyToClipboard(table.name) } label: { Label("Copy Name", systemImage: "doc.on.doc") } diff --git a/TableProMobile/TableProMobileTests/ClipboardExporterPasteboardTests.swift b/TableProMobile/TableProMobileTests/ClipboardExporterPasteboardTests.swift new file mode 100644 index 000000000..93396777b --- /dev/null +++ b/TableProMobile/TableProMobileTests/ClipboardExporterPasteboardTests.swift @@ -0,0 +1,29 @@ +import Foundation +@testable import TableProMobile +import Testing +import UIKit +import UniformTypeIdentifiers + +@Suite("ClipboardExporter pasteboard payload") +struct ClipboardExporterPasteboardTests { + @Test("payload carries the text as utf8 plain text") + func carriesText() { + let payload = ClipboardExporter.pasteboardPayload("secret-value") + let first = payload.items.first + #expect(first?[UTType.utf8PlainText.identifier] as? String == "secret-value") + } + + @Test("payload is local only so it never syncs via Universal Clipboard") + func localOnly() { + let payload = ClipboardExporter.pasteboardPayload("x") + #expect(payload.options[.localOnly] as? Bool == true) + } + + @Test("payload expires about a minute after copy") + func expires() { + let now = Date(timeIntervalSince1970: 1_000_000) + let payload = ClipboardExporter.pasteboardPayload("x", now: now) + let expiry = payload.options[.expirationDate] as? Date + #expect(expiry == now.addingTimeInterval(ClipboardExporter.pasteboardExpiry)) + } +} diff --git a/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift b/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift new file mode 100644 index 000000000..80778b8bb --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift @@ -0,0 +1,86 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@Suite("DriverSSLConfiguration") +struct DriverSSLConfigurationTests { + @Test("legacy bool with no configuration maps to require when enabled") + func legacyBoolEnabled() { + let ssl = DriverSSLConfiguration(sslEnabled: true, configuration: nil) + #expect(ssl.mode == .require) + #expect(ssl.isEnabled) + #expect(!ssl.verifiesCertificate) + #expect(ssl.caCertificatePath == nil) + } + + @Test("legacy bool with no configuration maps to disable when off") + func legacyBoolDisabled() { + let ssl = DriverSSLConfiguration(sslEnabled: false, configuration: nil) + #expect(ssl.mode == .disable) + #expect(!ssl.isEnabled) + } + + @Test("configuration mode is authoritative over the legacy bool") + func configurationWins() { + let config = SSLConfiguration(mode: .verifyFull, caCertificatePath: "/tmp/ca.pem") + let ssl = DriverSSLConfiguration(sslEnabled: false, configuration: config) + #expect(ssl.mode == .verifyFull) + #expect(ssl.verifiesCertificate) + #expect(ssl.verifiesHostname) + #expect(ssl.caCertificatePath == "/tmp/ca.pem") + } + + @Test("verifiesCertificate is true only for verify modes") + func verifiesCertificate() { + #expect(!DriverSSLConfiguration(mode: .disable).verifiesCertificate) + #expect(!DriverSSLConfiguration(mode: .require).verifiesCertificate) + #expect(DriverSSLConfiguration(mode: .verifyCa).verifiesCertificate) + #expect(DriverSSLConfiguration(mode: .verifyFull).verifiesCertificate) + } + + @Test("verifiesHostname is true only for verifyFull") + func verifiesHostname() { + #expect(!DriverSSLConfiguration(mode: .verifyCa).verifiesHostname) + #expect(DriverSSLConfiguration(mode: .verifyFull).verifiesHostname) + } + + @Test("postgres sslmode mirrors libpq mapping") + func postgresMapping() { + #expect(DriverSSLConfiguration(mode: .disable).postgresSSLMode == "disable") + #expect(DriverSSLConfiguration(mode: .require).postgresSSLMode == "require") + #expect(DriverSSLConfiguration(mode: .verifyCa).postgresSSLMode == "verify-ca") + #expect(DriverSSLConfiguration(mode: .verifyFull).postgresSSLMode == "verify-full") + } + + @Test("freetds encryption flag never downgrades verify modes to plaintext") + func freetdsMapping() { + #expect(DriverSSLConfiguration(mode: .disable).freetdsEncryptionFlag == "off") + #expect(DriverSSLConfiguration(mode: .require).freetdsEncryptionFlag == "require") + #expect(DriverSSLConfiguration(mode: .verifyCa).freetdsEncryptionFlag == "require") + #expect(DriverSSLConfiguration(mode: .verifyFull).freetdsEncryptionFlag == "require") + } + + @Test("CA path is ignored for non-verify modes") + func caIgnoredWithoutVerification() { + let path = NSTemporaryDirectory() + "tablepro-ca-\(UUID().uuidString).pem" + FileManager.default.createFile(atPath: path, contents: Data("cert".utf8)) + defer { try? FileManager.default.removeItem(atPath: path) } + + let requireMode = DriverSSLConfiguration(mode: .require, caCertificatePath: path) + #expect(requireMode.existingCACertificatePath == nil) + } + + @Test("CA path is used for verify modes only when the file exists on device") + func caUsedWhenPresent() { + let path = NSTemporaryDirectory() + "tablepro-ca-\(UUID().uuidString).pem" + FileManager.default.createFile(atPath: path, contents: Data("cert".utf8)) + defer { try? FileManager.default.removeItem(atPath: path) } + + let present = DriverSSLConfiguration(mode: .verifyFull, caCertificatePath: path) + #expect(present.existingCACertificatePath == path) + + let missing = DriverSSLConfiguration(mode: .verifyFull, caCertificatePath: "/does/not/exist.pem") + #expect(missing.existingCACertificatePath == nil) + } +} diff --git a/TableProMobile/TableProMobileTests/WidgetConnectionItemTests.swift b/TableProMobile/TableProMobileTests/WidgetConnectionItemTests.swift new file mode 100644 index 000000000..235e6dba3 --- /dev/null +++ b/TableProMobile/TableProMobileTests/WidgetConnectionItemTests.swift @@ -0,0 +1,32 @@ +import Foundation +@testable import TableProMobile +import Testing + +@Suite("WidgetConnectionItem") +struct WidgetConnectionItemTests { + @Test("encoded payload never contains database endpoint fields") + func endpointFieldsAreNotPersisted() throws { + let item = WidgetConnectionItem(id: UUID(), name: "Production", type: "PostgreSQL", sortOrder: 0) + let data = try JSONEncoder().encode(item) + let json = try #require(String(data: data, encoding: .utf8)) + + #expect(!json.contains("host")) + #expect(!json.contains("port")) + #expect(json.contains("Production")) + #expect(json.contains("PostgreSQL")) + } + + @Test("decodes legacy payloads that still carry host and port") + func decodesLegacyPayload() throws { + let id = UUID() + let legacy = """ + {"id":"\(id.uuidString)","name":"Old","type":"MySQL","host":"db.example.com","port":3306,"sortOrder":2} + """ + let item = try JSONDecoder().decode(WidgetConnectionItem.self, from: Data(legacy.utf8)) + + #expect(item.id == id) + #expect(item.name == "Old") + #expect(item.type == "MySQL") + #expect(item.sortOrder == 2) + } +} diff --git a/TableProMobile/TableProWidget/QuickConnectEntry.swift b/TableProMobile/TableProWidget/QuickConnectEntry.swift index fcfa9eb86..c6c99f827 100644 --- a/TableProMobile/TableProWidget/QuickConnectEntry.swift +++ b/TableProMobile/TableProWidget/QuickConnectEntry.swift @@ -8,10 +8,10 @@ struct QuickConnectEntry: TimelineEntry { QuickConnectEntry( date: .now, connections: [ - WidgetConnectionItem(id: UUID(), name: "Production", type: "PostgreSQL", host: "db.example.com", port: 5432, sortOrder: 0), - WidgetConnectionItem(id: UUID(), name: "Local MySQL", type: "MySQL", host: "localhost", port: 3306, sortOrder: 1), - WidgetConnectionItem(id: UUID(), name: "Redis Cache", type: "Redis", host: "cache.local", port: 6379, sortOrder: 2), - WidgetConnectionItem(id: UUID(), name: "Analytics", type: "ClickHouse", host: "ch.example.com", port: 8123, sortOrder: 3) + WidgetConnectionItem(id: UUID(), name: "Production", type: "PostgreSQL", sortOrder: 0), + WidgetConnectionItem(id: UUID(), name: "Local MySQL", type: "MySQL", sortOrder: 1), + WidgetConnectionItem(id: UUID(), name: "Redis Cache", type: "Redis", sortOrder: 2), + WidgetConnectionItem(id: UUID(), name: "Analytics", type: "ClickHouse", sortOrder: 3) ] ) } diff --git a/TableProMobile/TableProWidget/Shared/SharedConnectionStore.swift b/TableProMobile/TableProWidget/Shared/SharedConnectionStore.swift index a13f7ef24..f12e38a6c 100644 --- a/TableProMobile/TableProWidget/Shared/SharedConnectionStore.swift +++ b/TableProMobile/TableProWidget/Shared/SharedConnectionStore.swift @@ -13,11 +13,6 @@ enum SharedConnectionStore { static func write(_ items: [WidgetConnectionItem]) { guard let url = fileURL, let data = try? JSONEncoder().encode(items) else { return } - // File protection is intentionally omitted. Widgets must read this file - // while the device is locked (lock screen widgets, background timeline - // reloads). Using .completeFileProtection or .completeFileProtectionUnlessOpen - // would cause reads to fail when the device is locked. The file contains - // only display metadata (name, type, color) — no credentials or secrets. try? data.write(to: url, options: .atomic) } diff --git a/TableProMobile/TableProWidget/Shared/WidgetConnectionItem.swift b/TableProMobile/TableProWidget/Shared/WidgetConnectionItem.swift index ce6015507..cd2eeb8e6 100644 --- a/TableProMobile/TableProWidget/Shared/WidgetConnectionItem.swift +++ b/TableProMobile/TableProWidget/Shared/WidgetConnectionItem.swift @@ -4,7 +4,5 @@ struct WidgetConnectionItem: Codable, Identifiable, Hashable { let id: UUID let name: String let type: String - let host: String - let port: Int let sortOrder: Int } diff --git a/TableProMobile/TableProWidget/Views/SmallWidgetView.swift b/TableProMobile/TableProWidget/Views/SmallWidgetView.swift index 4852f7b61..f75603134 100644 --- a/TableProMobile/TableProWidget/Views/SmallWidgetView.swift +++ b/TableProMobile/TableProWidget/Views/SmallWidgetView.swift @@ -15,16 +15,9 @@ struct SmallWidgetView: View { Spacer() - VStack(alignment: .leading, spacing: 2) { - Text(connection.name) - .font(.headline) - .lineLimit(1) - - Text(verbatim: "\(connection.host):\(connection.port)") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } + Text(connection.name) + .font(.headline) + .lineLimit(2) } .frame(maxWidth: .infinity, alignment: .leading) .widgetURL(URL(string: "tablepro://connect/\(connection.id.uuidString)")) diff --git a/TableProTests/Core/MCP/MCPAuthPolicyTests.swift b/TableProTests/Core/MCP/MCPAuthPolicyTests.swift new file mode 100644 index 000000000..c591cc7f1 --- /dev/null +++ b/TableProTests/Core/MCP/MCPAuthPolicyTests.swift @@ -0,0 +1,180 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("MCP Auth Policy") +struct MCPAuthPolicyTests { + private let connectionA = UUID() + private let connectionB = UUID() + + private func makePolicy(_ snapshot: MCPConnectionAuthSnapshot?) -> MCPAuthPolicy { + MCPAuthPolicy(connectionResolver: { _ in snapshot }) + } + + private func makeSnapshot( + externalAccess: ExternalAccessLevel = .readWrite, + policy: AIConnectionPolicy = .alwaysAllow + ) -> MCPConnectionAuthSnapshot { + MCPConnectionAuthSnapshot( + policy: policy, + externalAccess: externalAccess, + name: "Test Connection", + databaseType: DatabaseType.postgresql.rawValue + ) + } + + private func makePrincipal(connectionAccess: ConnectionAccess = .all) -> MCPPrincipal { + MCPPrincipal( + tokenFingerprint: "fp", + tokenId: UUID(), + scopes: [.toolsRead, .toolsWrite, .resourcesRead, .admin], + connectionAccess: connectionAccess, + metadata: MCPPrincipalMetadata(label: "token", issuedAt: .distantPast, expiresAt: nil) + ) + } + + @Test("Blocked external access denies any connection tool") + func blockedConnectionDenied() async throws { + let policy = makePolicy(makeSnapshot(externalAccess: .blocked)) + let decision = try await policy.authorize( + principal: makePrincipal(), + tool: "list_tables", + connectionId: connectionA, + sql: nil, + sessionId: "session" + ) + guard case .denied = decision else { + Issue.record("Expected denied for blocked connection, got \(decision)") + return + } + } + + @Test("Read-only external access denies a write query") + func readOnlyDeniesWrite() async throws { + let policy = makePolicy(makeSnapshot(externalAccess: .readOnly)) + let decision = try await policy.authorize( + principal: makePrincipal(), + tool: "execute_query", + connectionId: connectionA, + sql: "UPDATE users SET name = 'x' WHERE id = 1", + sessionId: "session" + ) + guard case .denied = decision else { + Issue.record("Expected denied for write on read-only connection, got \(decision)") + return + } + } + + @Test("Read-only external access allows a read query") + func readOnlyAllowsRead() async throws { + let policy = makePolicy(makeSnapshot(externalAccess: .readOnly)) + let decision = try await policy.authorize( + principal: makePrincipal(), + tool: "execute_query", + connectionId: connectionA, + sql: "SELECT * FROM users", + sessionId: "session" + ) + guard case .allowed = decision else { + Issue.record("Expected allowed for read on read-only connection, got \(decision)") + return + } + } + + @Test("Token scoped to one connection is denied on another") + func connectionScopingDeniesOtherConnection() async throws { + let policy = makePolicy(makeSnapshot()) + let decision = try await policy.authorize( + principal: makePrincipal(connectionAccess: .limited([connectionA])), + tool: "list_tables", + connectionId: connectionB, + sql: nil, + sessionId: "session" + ) + guard case .denied = decision else { + Issue.record("Expected denied for connection outside token scope, got \(decision)") + return + } + } + + @Test("Token scoped to a connection is allowed on that connection") + func connectionScopingAllowsScopedConnection() async throws { + let policy = makePolicy(makeSnapshot()) + let decision = try await policy.authorize( + principal: makePrincipal(connectionAccess: .limited([connectionA])), + tool: "list_tables", + connectionId: connectionA, + sql: nil, + sessionId: "session" + ) + guard case .allowed = decision else { + Issue.record("Expected allowed for connection within token scope, got \(decision)") + return + } + } + + @Test("AI policy never denies access") + func aiPolicyNeverDenied() async throws { + let policy = makePolicy(makeSnapshot(policy: .never)) + let decision = try await policy.authorize( + principal: makePrincipal(), + tool: "list_tables", + connectionId: connectionA, + sql: nil, + sessionId: "session" + ) + guard case .denied = decision else { + Issue.record("Expected denied for AI policy never, got \(decision)") + return + } + } + + @Test("AI policy ask-each-time requires user approval") + func aiPolicyAskEachTimeRequiresApproval() async throws { + let policy = makePolicy(makeSnapshot(policy: .askEachTime)) + let decision = try await policy.authorize( + principal: makePrincipal(), + tool: "list_tables", + connectionId: connectionA, + sql: nil, + sessionId: "session" + ) + guard case .requiresUserApproval = decision else { + Issue.record("Expected approval requirement for ask-each-time, got \(decision)") + return + } + } + + @Test("Unknown connection denies") + func unknownConnectionDenied() async throws { + let policy = makePolicy(nil) + let decision = try await policy.authorize( + principal: makePrincipal(), + tool: "list_tables", + connectionId: connectionA, + sql: nil, + sessionId: "session" + ) + guard case .denied = decision else { + Issue.record("Expected denied for unknown connection, got \(decision)") + return + } + } + + @Test("No connection target only requires token scopes") + func noConnectionAllows() async throws { + let policy = makePolicy(nil) + let decision = try await policy.authorize( + principal: makePrincipal(), + tool: "list_connections", + connectionId: nil, + sql: nil, + sessionId: "session" + ) + guard case .allowed = decision else { + Issue.record("Expected allowed for tool without a connection target, got \(decision)") + return + } + } +} diff --git a/TableProTests/Core/MCP/MCPSettingsTests.swift b/TableProTests/Core/MCP/MCPSettingsTests.swift new file mode 100644 index 000000000..51241de6a --- /dev/null +++ b/TableProTests/Core/MCP/MCPSettingsTests.swift @@ -0,0 +1,43 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("MCP Settings secure defaults") +struct MCPSettingsTests { + @Test("Default settings require authentication") + func defaultRequiresAuthentication() { + #expect(MCPSettings.default.requireAuthentication) + #expect(MCPSettings().requireAuthentication) + } + + @Test("Settings JSON without the key decode to authentication required") + func decodesAbsentKeyAsRequired() throws { + let json = Data("{}".utf8) + let decoded = try JSONDecoder().decode(MCPSettings.self, from: json) + #expect(decoded.requireAuthentication) + } + + @Test("Explicit stored false is respected") + func decodesExplicitValue() throws { + let json = Data(#"{"requireAuthentication": false}"#.utf8) + let decoded = try JSONDecoder().decode(MCPSettings.self, from: json) + #expect(!decoded.requireAuthentication) + } + + @Test("Default settings deny anonymous loopback without a token") + func defaultDeniesAnonymousLoopback() async { + let store = FakeMCPTokenStore() + let bearer = MCPBearerTokenAuthenticator(tokenStore: store, rateLimiter: MCPRateLimiter()) + let composite = MCPCompositeAuthenticator( + bearer: bearer, + requireAuthentication: MCPSettings.default.requireAuthentication + ) + let decision = await composite.authenticate(authorizationHeader: nil, clientAddress: .loopback) + guard case .deny(let reason) = decision else { + Issue.record("Expected deny for anonymous loopback under secure default, got \(decision)") + return + } + #expect(reason.httpStatus == 401) + } +} diff --git a/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift b/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift new file mode 100644 index 000000000..2cd9444f7 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift @@ -0,0 +1,74 @@ +// +// PluginLazyActivationVerificationTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +@Suite("Plugin lazy activation re-verification", .serialized) +@MainActor +struct PluginLazyActivationVerificationTests { + @Test("user-installed lazy bundle that fails the signature re-check is rejected, never loaded") + func rejectsTamperedUserInstalledBundleBeforeLoad() throws { + guard ProcessInfo.processInfo.environment["TABLEPRO_ALLOW_UNSIGNED_PLUGINS"] != "1" else { return } + + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("LazyActivation-\(UUID().uuidString)", isDirectory: true) + let userPluginsDir = root.appendingPathComponent("Plugins", isDirectory: true) + let bundleURL = userPluginsDir.appendingPathComponent("Tampered.tableplugin", isDirectory: true) + let contentsURL = bundleURL.appendingPathComponent("Contents", isDirectory: true) + try fm.createDirectory(at: contentsURL, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + + let bundleId = "com.TablePro.test.tampered.\(UUID().uuidString)" + let typeId = "tampered-db-\(UUID().uuidString)" + let info: [String: Any] = [ + "CFBundleIdentifier": bundleId, + "CFBundleName": "Tampered", + "CFBundleShortVersionString": "1.0.0" + ] + let infoData = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + try infoData.write(to: contentsURL.appendingPathComponent("Info.plist")) + + let suiteName = "LazyActivationTest.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let manager = PluginManager(userDefaults: defaults, builtInPluginsURL: nil, userPluginsDir: userPluginsDir) + let bundle = try #require(Bundle(url: bundleURL)) + manager.plugins = [ + PluginEntry( + id: bundleId, + bundle: bundle, + url: bundleURL, + source: .userInstalled, + name: "Tampered", + version: "1.0.0", + pluginDescription: "", + capabilities: [.databaseDriver], + isEnabled: true, + databaseTypeId: typeId, + additionalTypeIds: [], + pluginIconName: "puzzlepiece", + defaultPort: nil, + exportFormatId: nil, + importFormatId: nil, + inspectorId: nil + ) + ] + + manager.activateLazyBundle(at: bundleURL) + + #expect(manager.driverPlugins[typeId] == nil) + #expect(manager.rejectedPlugins.contains { $0.url == bundleURL }) + #expect(manager.rejectedPlugins.filter { $0.url == bundleURL }.count == 1) + + manager.activateLazyBundle(at: bundleURL) + + #expect(manager.driverPlugins[typeId] == nil) + #expect(manager.rejectedPlugins.filter { $0.url == bundleURL }.count == 1) + } +} diff --git a/TableProTests/Core/Services/ConnectionImportServiceTests.swift b/TableProTests/Core/Services/ConnectionImportServiceTests.swift index 317861b45..d7117ca92 100644 --- a/TableProTests/Core/Services/ConnectionImportServiceTests.swift +++ b/TableProTests/Core/Services/ConnectionImportServiceTests.swift @@ -460,6 +460,37 @@ struct ConnectionImportServiceTests { #expect(saved[0] == existing) } + @Test("decoding a shared blob drops preConnectScript but keeps benign fields") + func decodingStripsPreConnectScript() throws { + let imported = ExportableConnection( + name: "Evil", + host: "localhost", + port: 3_306, + database: "", + username: "root", + type: "MySQL", + sshConfig: nil, + sslConfig: nil, + color: nil, + tagName: nil, + groupName: nil, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: ["preConnectScript": "touch /tmp/pwned", "mongoAuthSource": "admin"], + redisDatabase: nil, + startupCommands: nil, + localOnly: nil + ) + + let data = try ConnectionExportService.encode(makeEnvelope(with: [imported])) + let decoded = try ConnectionExportService.decodeData(data) + let fields = decoded.connections.first?.additionalFields + + #expect(fields?["preConnectScript"] == nil) + #expect(fields?["mongoAuthSource"] == "admin") + } + private func makeStorage() -> ConnectionStorage { let unique = UUID().uuidString let fileURL = FileManager.default.temporaryDirectory diff --git a/TableProTests/Core/Services/ConnectionSharingTests.swift b/TableProTests/Core/Services/ConnectionSharingTests.swift index d673a61bf..155cf4c1d 100644 --- a/TableProTests/Core/Services/ConnectionSharingTests.swift +++ b/TableProTests/Core/Services/ConnectionSharingTests.swift @@ -65,7 +65,7 @@ struct ConnectionSharingTests { #expect(link.contains("sshHost=bastion.com")) #expect(link.contains("sshPort=2222")) #expect(link.contains("sshUsername=deploy")) - #expect(link.contains("sshAuthMethod=privateKey")) + #expect(link.contains("sshAuthMethod=Private%20Key")) } @Test("Omits SSH when disabled") @@ -111,7 +111,7 @@ struct ConnectionSharingTests { sslConfig: ssl ) let link = ConnectionExportService.buildImportDeeplink(for: conn)! - #expect(link.contains("sslMode=required")) + #expect(link.contains("sslMode=Required")) #expect(link.contains("sslCaCertPath=")) } @@ -137,7 +137,7 @@ struct ConnectionSharingTests { aiPolicy: .never ) let link = ConnectionExportService.buildImportDeeplink(for: conn)! - #expect(link.contains("color=red")) + #expect(link.contains("color=Red")) #expect(link.contains("safeModeLevel=readOnly")) #expect(link.contains("aiPolicy=never")) } @@ -351,7 +351,7 @@ struct ConnectionSharingTests { #expect(parsed.sshConfig?.host == "bastion.prod.com") #expect(parsed.sshConfig?.port == 2222) #expect(parsed.sshConfig?.username == "deploy") - #expect(parsed.sshConfig?.authMethod == "privateKey") + #expect(parsed.sshConfig?.authMethod == "Private Key") #expect(parsed.sshConfig?.privateKeyPath == "~/.ssh/prod_key") #expect(parsed.sshConfig?.agentSocketPath == "/tmp/agent.sock") } @@ -377,7 +377,7 @@ struct ConnectionSharingTests { return } #expect(parsed.sslConfig != nil) - #expect(parsed.sslConfig?.mode == "verifyCa") + #expect(parsed.sslConfig?.mode == "Verify CA") #expect(parsed.sslConfig?.caCertificatePath == "~/certs/ca.pem") #expect(parsed.sslConfig?.clientCertificatePath == "~/certs/client.pem") #expect(parsed.sslConfig?.clientKeyPath == "~/certs/client.key") @@ -401,7 +401,7 @@ struct ConnectionSharingTests { Issue.record("Failed to parse round-trip link") return } - #expect(parsed.color == "red") + #expect(parsed.color == "Red") #expect(parsed.safeModeLevel == "readOnly") #expect(parsed.aiPolicy == "never") #expect(parsed.startupCommands == "SET statement_timeout = 30000;") @@ -524,14 +524,14 @@ struct ConnectionSharingTests { #expect(parsed.sshConfig?.host == "bastion.prod.com") #expect(parsed.sshConfig?.port == 2222) #expect(parsed.sshConfig?.username == "deploy") - #expect(parsed.sshConfig?.authMethod == "privateKey") + #expect(parsed.sshConfig?.authMethod == "Private Key") #expect(parsed.sshConfig?.jumpHosts?.count == 1) #expect(parsed.sshConfig?.jumpHosts?.first?.host == "jump1.com") - #expect(parsed.sslConfig?.mode == "verifyCa") + #expect(parsed.sslConfig?.mode == "Verify CA") #expect(parsed.sslConfig?.caCertificatePath == "~/certs/ca.pem") - #expect(parsed.color == "red") + #expect(parsed.color == "Red") #expect(parsed.safeModeLevel == "readOnly") #expect(parsed.aiPolicy == "never") #expect(parsed.startupCommands == "SET statement_timeout = 30000;") @@ -539,4 +539,43 @@ struct ConnectionSharingTests { #expect(parsed.additionalFields?["schema"] == "public") } } + + // MARK: - Import Sanitization + + @Suite("Import Sanitization") + struct ImportSanitizationTests { + + @Test("Deeplink import drops preConnectScript but keeps benign fields") + @MainActor + func testDeeplinkImportDropsPreConnectScript() { + var components = URLComponents() + components.scheme = "tablepro" + components.host = "import" + components.queryItems = [ + URLQueryItem(name: "name", value: "Evil"), + URLQueryItem(name: "host", value: "localhost"), + URLQueryItem(name: "port", value: "3306"), + URLQueryItem(name: "type", value: "MySQL"), + URLQueryItem(name: "af_preConnectScript", value: "touch /tmp/pwned"), + URLQueryItem(name: "af_mongoAuthSource", value: "admin") + ] + guard let url = components.url else { + Issue.record("Failed to build import URL") + return + } + guard case .success(.importConnection(let parsed)) = DeeplinkParser.parse(url) else { + Issue.record("Failed to parse import link") + return + } + + #expect(parsed.additionalFields?["preConnectScript"] == nil) + #expect(parsed.additionalFields?["mongoAuthSource"] == "admin") + + let connection = ConnectionExportService.buildDatabaseConnection( + id: UUID(), from: parsed, name: parsed.name, + tagIdsByName: [:], groupIdsByName: [:] + ) + #expect(connection.preConnectScript == nil) + } + } } diff --git a/TableProTests/Core/Services/Infrastructure/ExternalDatabaseURLConnectTests.swift b/TableProTests/Core/Services/Infrastructure/ExternalDatabaseURLConnectTests.swift new file mode 100644 index 000000000..16bbda9e9 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/ExternalDatabaseURLConnectTests.swift @@ -0,0 +1,73 @@ +// +// ExternalDatabaseURLConnectTests.swift +// TableProTests +// +// Pins the fix for the drive-by SSRF / keychain-pollution finding: a password +// carried by an externally delivered database URL must reach the driver as an +// in-memory override and must never be written to the Keychain for a transient +// connection. The open-URL flow is gated behind a confirmation alert and cannot +// run deterministically in a unit test, so this exercises the override mechanism +// the flow relies on. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("External database URL connect", .serialized) +@MainActor +struct ExternalDatabaseURLConnectTests { + @Test("URL-supplied password reaches the driver in memory and is not persisted") + func urlPasswordUsedInMemoryNotPersisted() async throws { + let typeId = CapturingURLConnectPlugin.databaseTypeId + PluginManager.shared.driverPlugins[typeId] = CapturingURLConnectPlugin() + defer { PluginManager.shared.driverPlugins[typeId] = nil } + + let id = UUID() + let connection = DatabaseConnection( + id: id, + name: "Transient URL", + host: "db.example.com", + port: 15_432, + database: "app", + username: "sa", + type: DatabaseType(rawValue: typeId) + ) + defer { ConnectionStorage.shared.deletePassword(for: id) } + + CapturingURLConnectPlugin.capturedPassword = nil + _ = try await DatabaseDriverFactory.createDriver( + for: connection, + passwordOverride: "url-secret", + awaitPlugins: true + ) + + #expect(CapturingURLConnectPlugin.capturedPassword == "url-secret") + #expect(ConnectionStorage.shared.loadPassword(for: id) == nil) + } +} + +private final class CapturingURLConnectPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Capturing URL Connect Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Captures the resolved password for external URL connect tests" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "URLOverrideTestDB" + static let databaseDisplayName = "URLOverrideTestDB" + static let iconName = "database-icon" + static let defaultPort = 15_432 + static let isDownloadable = false + + nonisolated(unsafe) static var capturedPassword: String? + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + Self.capturedPassword = config.password + return FakeMSSQLPluginDriver() + } + + override required init() { + super.init() + } +} diff --git a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift index d8783158c..91a2179d7 100644 --- a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift +++ b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift @@ -429,4 +429,110 @@ struct MongoDBQueryBuilderTests { #expect(query.hasPrefix("db[\"my.data\"]")) #expect(query.contains(".countDocuments({})")) } + + // MARK: - Security (NoSQL injection) + + private func parseFilter(_ json: String) -> [String: Any]? { + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + @Test("REGEX value cannot break out of the regex string to inject operators") + func regexInjectionContained() { + let payload = ".*\"}, \"$where\": \"function(){return true}\", \"_\":{\"a\":\"" + let doc = parseFilter( + builder.buildFilterDocument(from: [(column: "name", op: "REGEX", value: payload)]) + ) + #expect(doc != nil) + #expect(doc.map { Array($0.keys) } == ["name"]) + let inner = doc?["name"] as? [String: Any] + #expect(inner.map { Array($0.keys).sorted() } == ["$options", "$regex"]) + #expect(inner?["$regex"] as? String == payload) + #expect(inner?["$options"] as? String == "i") + } + + @Test("CONTAINS value cannot break out of the regex string to inject operators") + func containsInjectionContained() { + let payload = "\"}, \"$where\": \"return true" + let doc = parseFilter( + builder.buildFilterDocument(from: [(column: "name", op: "CONTAINS", value: payload)]) + ) + #expect(doc != nil) + #expect(doc.map { Array($0.keys) } == ["name"]) + let regex = (doc?["name"] as? [String: Any])?["$regex"] as? String + #expect(regex?.contains("$where") == true) + } + + @Test("NOT CONTAINS value cannot break out of the nested regex string") + func notContainsInjectionContained() { + let payload = "\"}}, \"$where\": \"1==1" + let doc = parseFilter( + builder.buildFilterDocument(from: [(column: "name", op: "NOT CONTAINS", value: payload)]) + ) + #expect(doc != nil) + #expect(doc.map { Array($0.keys) } == ["name"]) + let not = (doc?["name"] as? [String: Any])?["$not"] as? [String: Any] + #expect((not?["$regex"] as? String)?.contains("$where") == true) + } + + @Test("STARTS WITH escapes embedded double quotes as data") + func startsWithEscapesQuote() { + let doc = parseFilter( + builder.buildFilterDocument(from: [(column: "name", op: "STARTS WITH", value: "Al\"ce")]) + ) + #expect(doc != nil) + let inner = doc?["name"] as? [String: Any] + #expect(inner?["$regex"] as? String == "^Al\"ce") + } + + @Test("ENDS WITH escapes embedded double quotes as data") + func endsWithEscapesQuote() { + let doc = parseFilter( + builder.buildFilterDocument(from: [(column: "name", op: "ENDS WITH", value: "ce\"Al")]) + ) + #expect(doc != nil) + let inner = doc?["name"] as? [String: Any] + #expect(inner?["$regex"] as? String == "ce\"Al$") + } + + @Test("CONTAINS escapes a backslash to a literal-backslash regex") + func containsEscapesBackslash() { + let doc = parseFilter( + builder.buildFilterDocument(from: [(column: "path", op: "CONTAINS", value: "\\")]) + ) + #expect(doc != nil) + let inner = doc?["path"] as? [String: Any] + #expect(inner?["$regex"] as? String == "\\\\") + } + + @Test("REGEX preserves regex metacharacters literally") + func regexPreservesMetacharacters() { + let value = "^[A-Z].*\\d$" + let doc = parseFilter( + builder.buildFilterDocument(from: [(column: "name", op: "REGEX", value: value)]) + ) + #expect(doc != nil) + let inner = doc?["name"] as? [String: Any] + #expect(inner?["$regex"] as? String == value) + } + + @Test("REGEX keeps an embedded double quote as data") + func regexEscapesQuote() { + let doc = parseFilter( + builder.buildFilterDocument(from: [(column: "name", op: "REGEX", value: "a\"b")]) + ) + #expect(doc != nil) + let inner = doc?["name"] as? [String: Any] + #expect(inner?["$regex"] as? String == "a\"b") + } + + @Test("CONTAINS treats regex metacharacters as literals") + func containsTreatsMetacharactersLiterally() { + let doc = parseFilter( + builder.buildFilterDocument(from: [(column: "name", op: "CONTAINS", value: "a.b")]) + ) + #expect(doc != nil) + let inner = doc?["name"] as? [String: Any] + #expect(inner?["$regex"] as? String == "a\\.b") + } } diff --git a/scripts/download-libs.sh b/scripts/download-libs.sh index 2ef25ed07..c7af7fbf0 100755 --- a/scripts/download-libs.sh +++ b/scripts/download-libs.sh @@ -42,20 +42,40 @@ else curl -fSL -o "/tmp/$LIBS_ARCHIVE" "$DOWNLOAD_URL" fi +# Capture the trusted checksum baseline from git BEFORE extraction. The archive +# bundles its own checksums.sha256, and extraction overwrites Libs/checksums.sha256 +# with that copy, so verifying against the extracted file is self-referential: a +# tampered release ships matching checksums and passes. The committed baseline +# (Libs/checksums.sha256 at HEAD) is the only trusted reference. +TRUSTED_CHECKSUMS="" +if git rev-parse --is-inside-work-tree &>/dev/null \ + && git cat-file -e "HEAD:$LIBS_DIR/checksums.sha256" 2>/dev/null; then + TRUSTED_CHECKSUMS="$(mktemp)" + git show "HEAD:$LIBS_DIR/checksums.sha256" > "$TRUSTED_CHECKSUMS" +fi + echo "Extracting to $LIBS_DIR/..." mkdir -p "$LIBS_DIR" tar xzf "/tmp/$LIBS_ARCHIVE" -C "$LIBS_DIR" rm -f "/tmp/$LIBS_ARCHIVE" -# Verify checksums if file exists -if [[ -f "$LIBS_DIR/checksums.sha256" ]]; then - echo "Verifying checksums..." - if shasum -a 256 -c "$LIBS_DIR/checksums.sha256" --quiet 2>/dev/null; then +# Verify the extracted libraries against the trusted git baseline, never the +# archive's own bundled checksum file. +if [[ -n "$TRUSTED_CHECKSUMS" ]]; then + echo "Verifying checksums against the baseline committed in git..." + if shasum -a 256 -c "$TRUSTED_CHECKSUMS" --quiet 2>/dev/null; then echo "Checksums OK" + rm -f "$TRUSTED_CHECKSUMS" else - echo "WARNING: Checksum verification failed!" + rm -f "$TRUSTED_CHECKSUMS" + echo "ERROR: extracted libraries do not match Libs/checksums.sha256 committed in git." + echo "The downloaded archive may be corrupt or tampered with. Aborting." exit 1 fi +else + echo "WARNING: no trusted checksum baseline found (not a git checkout, or" + echo " Libs/checksums.sha256 is absent at HEAD). Skipping integrity" + echo " verification. The archive's own checksum file is NOT trusted." fi # Mark as downloaded