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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/core/database/mongodb_connection.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<String, Db> _openedDbs = {};
final Map<String, Future<Db>> _openingDbs = {};

Expand Down Expand Up @@ -198,6 +203,7 @@ class MongoConnection {
} catch (e) {
_isConnected = false;
_db = null;
await _cleanupTempTlsKey();
rethrow;
}
}
Expand All @@ -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) {
Expand Down Expand Up @@ -248,6 +260,15 @@ class MongoConnection {
debugPrint('MongoConnection.disconnect: $e');
}
}
await _cleanupTempTlsKey();
}

Future<void> _cleanupTempTlsKey() async {
final path = _tempClientPemPath;
_tempClientPemPath = null;
if (path != null) {
await cleanupMongoTlsTempFile(path);
}
}

/// Opens (or reuses) a [Db] for [databaseName] on this live session.
Expand Down
60 changes: 58 additions & 2 deletions lib/core/security/ssl_certificate_support.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?> resolveMongoTlsCertificateKeyFile({
required String? clientCert,
required String? clientKey,
Expand All @@ -143,12 +149,62 @@ Future<String?> 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<void> 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<void> 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,
Expand Down
2 changes: 2 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -31,6 +32,7 @@ void main([List<String> args = const []]) async {
await UiScaleController.instance.load();
await QueryaMotionController.instance.load();
unawaited(UpdateController.instance.initialize());
unawaited(cleanupStaleMongoTlsTempFiles());
runApp(const QueryaApp());
doWhenWindowReady(() {
final win = appWindow;
Expand Down
38 changes: 38 additions & 0 deletions test/core/database/mongodb_connection_test.dart
Original file line number Diff line number Diff line change
@@ -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', () {
Expand Down Expand Up @@ -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<Directory>()
.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);
});
});
}
67 changes: 67 additions & 0 deletions test/core/security/ssl_certificate_support_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/core/security/ssl_certificate_support.dart';

Expand Down Expand Up @@ -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);
});
});
}
Loading