From 170cc0587b35f099a0183e7fd53a448de3aa080c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 20:17:51 +0300 Subject: [PATCH 1/3] fix(security): enforce root CA verification and expose TLS sslmode for PostgreSQL connections Closes #1367 --- lib/core/database/postgres_connection.dart | 12 ++- .../postgresql_connection_form.dart | 88 ++++++++++++++++--- .../database/postgres_connection_test.dart | 19 ++++ 3 files changed, 103 insertions(+), 16 deletions(-) diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index f7aed16b..875d6ea7 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -191,9 +191,13 @@ class PostgresConnection { ); } + @visibleForTesting + ConnectionSettings buildSettingsForTest() => _buildSettings(); + ConnectionSettings _buildSettings() { SecurityContext? securityContext; - if ((sslRootCert != null && sslRootCert!.trim().isNotEmpty) || + if (useSSL || + (sslRootCert != null && sslRootCert!.trim().isNotEmpty) || (sslCert != null && sslCert!.trim().isNotEmpty) || (sslKey != null && sslKey!.trim().isNotEmpty)) { securityContext = SecurityContext(withTrustedRoots: true); @@ -314,7 +318,11 @@ class PostgresConnection { encoding: parsed.encoding, replicationMode: parsed.replicationMode, queryTimeout: parsed.queryTimeout ?? const Duration(seconds: 30), - securityContext: parsed.securityContext ?? _buildSettings().securityContext, + securityContext: parsed.securityContext ?? + _buildSettings().securityContext ?? + (sslMode != SslMode.disable + ? SecurityContext(withTrustedRoots: true) + : null), sslMode: sslMode, ), ); diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 91619704..1b2ec67e 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -65,6 +65,7 @@ class _PostgresConnectionFormContentState final SshTunnelSecrets _sshSecrets = SshTunnelSecrets(); bool _useSSL = false; + String _sslMode = 'require'; bool _removeSavedPassword = false; bool _showPassword = false; bool _isTesting = false; @@ -102,8 +103,18 @@ class _PostgresConnectionFormContentState _usernameController.text = initial.username ?? ''; _databaseController.text = initial.databaseName ?? ''; _useSSL = initial.useSSL; + final connStr = initial.connectionString; _connectionStringController.text = - redactUriPassword(initial.connectionString) ?? ''; + redactUriPassword(connStr) ?? ''; + if (connStr != null && connStr.isNotEmpty) { + final parsed = Uri.tryParse(connStr); + final mode = parsed?.queryParameters['sslmode']; + if (mode != null && mode.isNotEmpty) { + _sslMode = mode; + } else if (_useSSL) { + _sslMode = 'require'; + } + } if (initial.sshTunnelConfig != null) { _sshConfig = initial.sshTunnelConfig!; } @@ -136,6 +147,11 @@ class _PostgresConnectionFormContentState _sslRootCertController.text = parsed.queryParameters['sslrootcert'] ?? ''; _sslCertController.text = parsed.queryParameters['sslcert'] ?? ''; _sslKeyController.text = parsed.queryParameters['sslkey'] ?? ''; + final mode = parsed.queryParameters['sslmode']; + if (mode != null && mode.isNotEmpty) { + _sslMode = mode; + _useSSL = mode != 'disable'; + } } void _setOrRemoveSslParam( @@ -160,9 +176,13 @@ class _PostgresConnectionFormContentState _setOrRemoveSslParam(params, 'sslrootcert', _sslRootCertController); _setOrRemoveSslParam(params, 'sslcert', _sslCertController); _setOrRemoveSslParam(params, 'sslkey', _sslKeyController); - if (!params.containsKey('sslmode') && - _sslRootCertController.text.trim().isNotEmpty) { - params['sslmode'] = 'verify-full'; + if (_useSSL) { + params['sslmode'] = _sslRootCertController.text.trim().isNotEmpty && + _sslMode == 'require' + ? 'verify-full' + : _sslMode; + } else { + params.remove('sslmode'); } final newUri = Uri( scheme: parsed.scheme, @@ -199,6 +219,7 @@ class _PostgresConnectionFormContentState String? sslRootCert, String? sslCert, String? sslKey, + String? sslMode, }) { final userInfoParts = [ if (username != null && username.isNotEmpty) @@ -206,11 +227,15 @@ class _PostgresConnectionFormContentState if (password != null && password.isNotEmpty) Uri.encodeComponent(password), ]; + final effectiveMode = sslMode ?? + (sslRootCert != null && sslRootCert.isNotEmpty + ? 'verify-full' + : (_useSSL ? _sslMode : null)); final queryParams = { - if (sslRootCert != null && sslRootCert.isNotEmpty) ...{ + if (sslRootCert != null && sslRootCert.isNotEmpty) 'sslrootcert': sslRootCert, - 'sslmode': 'verify-full', - }, + if (effectiveMode != null && effectiveMode.isNotEmpty) + 'sslmode': effectiveMode, if (sslCert != null && sslCert.isNotEmpty) 'sslcert': sslCert, if (sslKey != null && sslKey.isNotEmpty) 'sslkey': sslKey, }; @@ -230,16 +255,19 @@ class _PostgresConnectionFormContentState final sslRootCert = _sslRootCertController.text.trim(); final sslCert = _sslCertController.text.trim(); final sslKey = _sslKeyController.text.trim(); - if (sslRootCert.isEmpty && sslCert.isEmpty && sslKey.isEmpty) return ''; + if (!_useSSL && sslRootCert.isEmpty && sslCert.isEmpty && sslKey.isEmpty) { + return ''; + } return _buildConnectionUri( host: _hostController.text.trim(), port: int.tryParse(_portController.text.trim()) ?? 5432, username: _usernameController.text.trim(), password: _passwordController.text, database: _databaseController.text.trim(), - sslRootCert: sslRootCert, - sslCert: sslCert, - sslKey: sslKey, + sslRootCert: sslRootCert.isEmpty ? null : sslRootCert, + sslCert: sslCert.isEmpty ? null : sslCert, + sslKey: sslKey.isEmpty ? null : sslKey, + sslMode: _useSSL ? _sslMode : null, ); } @@ -666,11 +694,43 @@ class _PostgresConnectionFormContentState ], ), if (_useSSL) ...[ + const Gap(8), + material.Row( + children: [ + const Text('SSL Mode').small(), + const Gap(12), + material.SizedBox( + width: 160, + child: QueryaDropdown( + value: _sslMode, + onSelected: (mode) { + if (mode != null) { + setState(() => _sslMode = mode); + _syncUriSslParams(); + } + }, + items: const [ + QueryaDropdownItem( + value: 'require', + label: 'require (encrypted)', + ), + QueryaDropdownItem( + value: 'verify-ca', + label: 'verify-ca (check CA)', + ), + QueryaDropdownItem( + value: 'verify-full', + label: 'verify-full (strict)', + ), + ], + ), + ), + ], + ), const Gap(8), const Text( - 'Without a Root CA this is sslmode=require: traffic is ' - 'encrypted but the server certificate is not checked ' - '(MITM is possible). A Root CA enables verify-full. ' + 'require encrypts traffic without checking server certificates. ' + 'verify-ca and verify-full enforce CA validation against trusted roots. ' 'A URI sslmode= value always wins.', ).muted().small(), const Gap(16), diff --git a/test/core/database/postgres_connection_test.dart b/test/core/database/postgres_connection_test.dart index 92683ad1..5e5cfdea 100644 --- a/test/core/database/postgres_connection_test.dart +++ b/test/core/database/postgres_connection_test.dart @@ -574,6 +574,25 @@ void main() { ), ); }); + + test('buildSettingsForTest initializes SecurityContext with trusted roots when useSSL is true (#1367)', () { + final connWithoutSsl = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + useSSL: false, + ); + expect(connWithoutSsl.buildSettingsForTest().securityContext, isNull); + + final connWithSsl = PostgresConnection( + id: 2, + name: 'test-ssl', + host: 'localhost', + useSSL: true, + ); + final settings = connWithSsl.buildSettingsForTest(); + expect(settings.securityContext, isNotNull); + }); }); group('PostgresConnection socket state reflection (#1373)', () { From 931d5ea3d829234b1fecb4d0e819c25a30313cf7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 20:21:41 +0300 Subject: [PATCH 2/3] fix(postgresql): upgrade sslmode to verify-full when root CA is set _buildConnectionUri let the default 'require' mode override the verify-full upgrade for a root certificate, so URIs built from form fields ended up with sslrootcert but sslmode=require. --- .../postgresql/postgresql_connection_form.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 1b2ec67e..4bf858d4 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -227,10 +227,14 @@ class _PostgresConnectionFormContentState if (password != null && password.isNotEmpty) Uri.encodeComponent(password), ]; - final effectiveMode = sslMode ?? - (sslRootCert != null && sslRootCert.isNotEmpty + // A root CA with the default `require` mode means the user wants the + // server verified, same as _syncUriSslParams does for a typed URI. + final hasRootCert = sslRootCert != null && sslRootCert.isNotEmpty; + final requestedMode = sslMode ?? (_useSSL ? _sslMode : null); + final effectiveMode = + hasRootCert && (requestedMode == null || requestedMode == 'require') ? 'verify-full' - : (_useSSL ? _sslMode : null)); + : requestedMode; final queryParams = { if (sslRootCert != null && sslRootCert.isNotEmpty) 'sslrootcert': sslRootCert, From 770eda67a9cd56e36d68faf757038ba04cbe3d11 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 20:25:27 +0300 Subject: [PATCH 3/3] fix(postgresql): stop SSL mode dropdown from overflowing in the form The dropdown label was not expanded inside its fixed-width box, so long mode labels overflowed the trigger row. Expand to parent so the label ellipsizes, and widen the box. --- lib/features/postgresql/postgresql_connection_form.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 4bf858d4..bf572165 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -704,8 +704,9 @@ class _PostgresConnectionFormContentState const Text('SSL Mode').small(), const Gap(12), material.SizedBox( - width: 160, + width: 220, child: QueryaDropdown( + expandToParent: true, value: _sslMode, onSelected: (mode) { if (mode != null) {