From 752eb1c146136f154042fa4235ee5b3818e062a5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 25 Sep 2026 10:05:41 +0300 Subject: [PATCH] fix(security): restrict permissions and cleanup temp tls pem for mongodb (Closes #915) --- lib/core/database/mongodb_connection.dart | 21 ++++++ .../security/ssl_certificate_support.dart | 60 ++++++++++++++++- lib/main.dart | 2 + .../database/mongodb_connection_test.dart | 38 +++++++++++ .../ssl_certificate_support_test.dart | 67 +++++++++++++++++++ 5 files changed, 186 insertions(+), 2 deletions(-) diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 7f96222..5f99de0 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:mongo_dart/mongo_dart.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; @@ -36,6 +38,9 @@ class MongoConnection { /// exposed via [password] / [connectionString] after [scrubCredentials]. String? _sessionUri; + /// Temporary client PEM certificate key file path created for this connection. + String? _tempClientPemPath; + final Map _openedDbs = {}; final Map> _openingDbs = {}; @@ -198,6 +203,7 @@ class MongoConnection { } catch (e) { _isConnected = false; _db = null; + await _cleanupTempTlsKey(); rethrow; } } @@ -219,6 +225,12 @@ class MongoConnection { clientKey: paths.clientKey, ); if (clientPem != null) { + if (clientPem != paths.clientCert) { + if (_tempClientPemPath != null && _tempClientPemPath != clientPem) { + unawaited(cleanupMongoTlsTempFile(_tempClientPemPath)); + } + _tempClientPemPath = clientPem; + } params[kMongoTlsCertificateKeyFileParam] = clientPem; } if (useSSL || paths.hasAny) { @@ -248,6 +260,15 @@ class MongoConnection { debugPrint('MongoConnection.disconnect: $e'); } } + await _cleanupTempTlsKey(); + } + + Future _cleanupTempTlsKey() async { + final path = _tempClientPemPath; + _tempClientPemPath = null; + if (path != null) { + await cleanupMongoTlsTempFile(path); + } } /// Opens (or reuses) a [Db] for [databaseName] on this live session. diff --git a/lib/core/security/ssl_certificate_support.dart b/lib/core/security/ssl_certificate_support.dart index bcb9823..d97d63f 100644 --- a/lib/core/security/ssl_certificate_support.dart +++ b/lib/core/security/ssl_certificate_support.dart @@ -131,7 +131,13 @@ Uri translateQueryaSslParamsForMongo(Uri uri) { return uri.replace(queryParameters: params.isEmpty ? null : params); } +/// Prefix used for temporary MongoDB TLS certificate files. +const kMongoTlsTempPrefix = 'querya_mongo_tls_'; + /// Resolves a client PEM path for mongo_dart when cert and key are separate files. +/// +/// Sets restrictive file permissions (0600 file / 0700 dir on POSIX) to protect +/// the plaintext private key from unauthorized local access. Future resolveMongoTlsCertificateKeyFile({ required String? clientCert, required String? clientKey, @@ -143,12 +149,62 @@ Future resolveMongoTlsCertificateKeyFile({ final certBytes = await File(certPath).readAsString(); final keyBytes = await File(keyPath).readAsString(); - final dir = await Directory.systemTemp.createTemp('querya_mongo_tls_'); + final dir = await Directory.systemTemp.createTemp(kMongoTlsTempPrefix); + if (!Platform.isWindows) { + try { + await Process.run('chmod', ['700', dir.path]); + } catch (_) {} + } final merged = File('${dir.path}/client.pem'); - await merged.writeAsString('$certBytes\n$keyBytes\n'); + await merged.writeAsString('$certBytes\n$keyBytes\n', flush: true); + if (!Platform.isWindows) { + try { + await Process.run('chmod', ['600', merged.path]); + } catch (_) {} + } return merged.path; } +/// Reliably deletes a temporary MongoDB TLS certificate file and its parent temp directory. +Future cleanupMongoTlsTempFile(String? filePath) async { + if (filePath == null || filePath.trim().isEmpty) return; + try { + final file = File(filePath); + final parent = file.parent; + final parentName = + parent.uri.pathSegments.where((s) => s.isNotEmpty).lastOrNull ?? ''; + // Security check: ONLY delete if it is inside our querya_mongo_tls_ directory. + if (!parentName.startsWith(kMongoTlsTempPrefix)) { + return; + } + if (await file.exists()) { + await file.delete(); + } + if (await parent.exists()) { + await parent.delete(recursive: true); + } + } catch (_) {} +} + +/// Cleans up any stale temporary MongoDB TLS directories left from previous sessions. +Future cleanupStaleMongoTlsTempFiles() async { + try { + final tempDir = Directory.systemTemp; + if (!await tempDir.exists()) return; + await for (final entity in tempDir.list()) { + if (entity is Directory) { + final name = + entity.uri.pathSegments.where((s) => s.isNotEmpty).lastOrNull ?? ''; + if (name.startsWith(kMongoTlsTempPrefix)) { + try { + await entity.delete(recursive: true); + } catch (_) {} + } + } + } + } catch (_) {} +} + String buildRedisConnectionUri({ required String host, required int port, diff --git a/lib/main.dart b/lib/main.dart index 54a1f1a..2460a12 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'core/layout/ui_scale_controller.dart'; import 'core/motion/display_refresh_service.dart'; import 'core/motion/querya_motion_controller.dart'; import 'core/platform/file_launch_service.dart'; +import 'core/security/ssl_certificate_support.dart'; import 'core/storage/local_db.dart'; import 'core/theme/theme_controller.dart'; import 'features/updater/update_controller.dart'; @@ -31,6 +32,7 @@ void main([List args = const []]) async { await UiScaleController.instance.load(); await QueryaMotionController.instance.load(); unawaited(UpdateController.instance.initialize()); + unawaited(cleanupStaleMongoTlsTempFiles()); runApp(const QueryaApp()); doWhenWindowReady(() { final win = appWindow; diff --git a/test/core/database/mongodb_connection_test.dart b/test/core/database/mongodb_connection_test.dart index d986a86..b3669b5 100644 --- a/test/core/database/mongodb_connection_test.dart +++ b/test/core/database/mongodb_connection_test.dart @@ -1,5 +1,8 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; void main() { group('MongoConnection.buildConnectionUri', () { @@ -254,4 +257,39 @@ void main() { ); }); }); + + group('MongoConnection TLS temp file cleanup', () { + test('cleans up temporary client PEM file upon failed connect()', () async { + final testDir = await Directory.systemTemp.createTemp('querya_mongo_test_input_'); + final certFile = File('${testDir.path}/client.crt'); + final keyFile = File('${testDir.path}/client.key'); + await certFile.writeAsString('-----BEGIN CERTIFICATE-----\nTEST_CERT\n-----END CERTIFICATE-----\n'); + await keyFile.writeAsString('-----BEGIN PRIVATE KEY-----\nTEST_KEY\n-----END PRIVATE KEY-----\n'); + + final conn = MongoConnection( + id: 999, + name: 'test-tls-cleanup', + host: '127.0.0.1', + port: 65432, + useSSL: true, + connectionString: 'mongodb://127.0.0.1:65432/test?sslcert=${Uri.encodeComponent(certFile.path)}&sslkey=${Uri.encodeComponent(keyFile.path)}', + ); + + try { + await conn.connect(); + } catch (_) {} + + final tempDirs = Directory.systemTemp + .listSync() + .whereType() + .where((d) { + final seg = d.uri.pathSegments.where((s) => s.isNotEmpty).lastOrNull ?? ''; + return seg.startsWith(kMongoTlsTempPrefix); + }) + .toList(); + expect(tempDirs, isEmpty); + + await testDir.delete(recursive: true); + }); + }); } diff --git a/test/core/security/ssl_certificate_support_test.dart b/test/core/security/ssl_certificate_support_test.dart index 24c9c6b..e96886c 100644 --- a/test/core/security/ssl_certificate_support_test.dart +++ b/test/core/security/ssl_certificate_support_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; @@ -42,5 +44,70 @@ void main() { expect(translated.queryParameters.containsKey('sslrootcert'), isFalse); expect(translated.queryParameters.containsKey('sslcert'), isFalse); }); + + test('resolveMongoTlsCertificateKeyFile restricts permissions and merges cert and key', () async { + final testDir = await Directory.systemTemp.createTemp('querya_test_input_'); + final certFile = File('${testDir.path}/test_cert.pem'); + final keyFile = File('${testDir.path}/test_key.pem'); + await certFile.writeAsString('-----BEGIN CERTIFICATE-----\nTEST_CERT\n-----END CERTIFICATE-----\n'); + await keyFile.writeAsString('-----BEGIN PRIVATE KEY-----\nTEST_KEY\n-----END PRIVATE KEY-----\n'); + + final pemPath = await resolveMongoTlsCertificateKeyFile( + clientCert: certFile.path, + clientKey: keyFile.path, + ); + + expect(pemPath, isNotNull); + expect(pemPath, contains(kMongoTlsTempPrefix)); + + final createdFile = File(pemPath!); + expect(await createdFile.exists(), isTrue); + + final content = await createdFile.readAsString(); + expect(content, contains('TEST_CERT')); + expect(content, contains('TEST_KEY')); + + if (!Platform.isWindows) { + final fileStat = createdFile.statSync(); + // Mode mask 0x1ff (0777). 0600 octal == 0x180 (384). + expect(fileStat.mode & 0x1ff, equals(0x180)); + + final dirStat = createdFile.parent.statSync(); + // 0700 octal == 0x1c0 (448). + expect(dirStat.mode & 0x1ff, equals(0x1c0)); + } + + // Cleanup test file + await cleanupMongoTlsTempFile(pemPath); + expect(await createdFile.exists(), isFalse); + expect(await createdFile.parent.exists(), isFalse); + + await testDir.delete(recursive: true); + }); + + test('cleanupMongoTlsTempFile does not delete files outside kMongoTlsTempPrefix', () async { + final testDir = await Directory.systemTemp.createTemp('querya_other_dir_'); + final safeFile = File('${testDir.path}/important.pem'); + await safeFile.writeAsString('CRITICAL DATA'); + + await cleanupMongoTlsTempFile(safeFile.path); + + expect(await safeFile.exists(), isTrue); + expect(await testDir.exists(), isTrue); + + await testDir.delete(recursive: true); + }); + + test('cleanupStaleMongoTlsTempFiles removes orphaned temporary directories', () async { + final orphanDir = await Directory.systemTemp.createTemp(kMongoTlsTempPrefix); + final orphanFile = File('${orphanDir.path}/client.pem'); + await orphanFile.writeAsString('stale key'); + + expect(await orphanDir.exists(), isTrue); + + await cleanupStaleMongoTlsTempFiles(); + + expect(await orphanDir.exists(), isFalse); + }); }); }