diff --git a/lib/core/team/team_profile.dart b/lib/core/team/team_profile.dart index b4e25809..f80e08cf 100644 --- a/lib/core/team/team_profile.dart +++ b/lib/core/team/team_profile.dart @@ -22,8 +22,14 @@ class TeamProfileCodec { static const format = 'querya-team-profile'; static const version = 1; - static final _secretKey = - RegExp(r'pass(word|phrase)?|secret|token|api_?key', caseSensitive: false); + /// Option and query-parameter names whose value is a secret (#1564). The + /// match is a substring of the key, case-insensitive: `privateKey`, + /// `private_key`, `accessKey`, `db_pwd`, `credentials`, `sig` are all caught. + static final _secretKey = RegExp( + r'pass(word|phrase)?|pwd|secret|token|api_?key|private_?key|access_?key|' + r'credential|signature', + caseSensitive: false, + ); /// Serializes [connections] to profile JSON without any secret. static String encode(List connections) { @@ -95,7 +101,16 @@ class TeamProfileCodec { } /// Parses profile JSON into connection rows (no ids, no secrets). - static List decode(String source, {DateTime? now}) { + static List decode(String source, {DateTime? now}) => + read(source, now: now).rows; + + /// Like [decode], and says how many entries were left out because they were + /// not objects, had no type or name, or had a field of the wrong type (a + /// hand-edited or newer file), so the import can report it. + static ({List rows, int skipped}) read( + String source, { + DateTime? now, + }) { final Object? doc; try { doc = jsonDecode(source); @@ -116,30 +131,59 @@ class TeamProfileCodec { } final stamp = (now ?? DateTime.now()).toIso8601String(); final rows = []; + var skipped = 0; for (final item in list) { - if (item is! Map) continue; + if (item is! Map) { + skipped++; + continue; + } final type = item['type']; final name = item['name']; - if (type is! String || name is! String || name.trim().isEmpty) continue; - final options = item['driverOptions']; - final url = item['connectionString']; + if (type is! String || name is! String || name.trim().isEmpty) { + skipped++; + continue; + } + // A field that is present with the wrong type makes the whole entry + // unreadable: dropping just that field would import a connection that + // points somewhere else. + var unreadable = false; + T? field(String key) { + final v = item[key]; + if (v == null) return null; + if (v is T) return v; + unreadable = true; + return null; + } + + final host = field('host'); + final port = field('port'); + final username = field('username'); + final databaseName = field('databaseName'); + final authSource = field('authSource'); + final extensionId = field('extensionId'); + final url = field('connectionString'); + final options = field('driverOptions'); + if (unreadable) { + skipped++; + continue; + } rows.add(ConnectionRow( type: type, name: name, - host: item['host'] as String?, - port: item['port'] as int?, - username: item['username'] as String?, - databaseName: item['databaseName'] as String?, - authSource: item['authSource'] as String?, + host: host, + port: port, + username: username, + databaseName: databaseName, + authSource: authSource, useSSL: item['useSSL'] == true, // Re-check on import: a hand-edited file must not smuggle secrets in. - connectionString: url is String ? _stripUrlCredentials(url) : null, - extensionId: item['extensionId'] as String?, - driverOptions: options is String ? _sanitizeOptions(options) : null, + connectionString: url == null ? null : _stripUrlCredentials(url), + extensionId: extensionId, + driverOptions: options == null ? null : _sanitizeOptions(options), createdAt: stamp, )); } - return rows; + return (rows: rows, skipped: skipped); } /// Imports [rows] into [db], skipping ones that already exist diff --git a/lib/features/connections/team_profile_actions.dart b/lib/features/connections/team_profile_actions.dart index 21d277d2..b6425d40 100644 --- a/lib/features/connections/team_profile_actions.dart +++ b/lib/features/connections/team_profile_actions.dart @@ -38,16 +38,21 @@ Future importTeamProfile(BuildContext context) async { final file = await openFile(acceptedTypeGroups: const [_typeGroup]); if (file == null || !context.mounted) return; try { - final rows = TeamProfileCodec.decode(utf8.decode(await file.readAsBytes())); - final added = await TeamProfileCodec.importInto(LocalDb.instance, rows); + final read = TeamProfileCodec.read(utf8.decode(await file.readAsBytes())); + final added = await TeamProfileCodec.importInto(LocalDb.instance, read.rows); if (!context.mounted) return; QueryaCommandHost.maybeOf(context)?.onReloadConnections?.call(); showAppToast( context: context, - message: added == 0 - ? 'Nothing new to import' - : 'Imported $added connection(s). Enter passwords when connecting.', - variant: AppToastVariant.success, + message: [ + added == 0 + ? 'Nothing new to import' + : 'Imported $added connection(s). Enter passwords when connecting.', + if (read.skipped > 0) + '${read.skipped} entr${read.skipped == 1 ? 'y was' : 'ies were'} ' + 'unreadable and skipped.', + ].join(' '), + variant: read.skipped > 0 ? AppToastVariant.info : AppToastVariant.success, ); } on TeamProfileFormatException catch (e) { if (!context.mounted) return; diff --git a/test/core/team/team_profile_test.dart b/test/core/team/team_profile_test.dart index 6964d138..f8520e89 100644 --- a/test/core/team/team_profile_test.dart +++ b/test/core/team/team_profile_test.dart @@ -88,4 +88,88 @@ void main() { }); expect(TeamProfileCodec.decode(doc).map((r) => r.name), ['ok']); }); + + group('secret keys (#1564)', () { + test('export drops pwd, private keys, access keys, credentials and signatures', + () { + const noisy = ConnectionRow( + type: 'postgres', + name: 'Noisy', + host: 'h', + connectionString: + 'postgres://h:5432/d?db_pwd=s1&privateKey=s2&sslmode=require&authSource=admin', + driverOptions: + '{"pwd":"s3","private_key":"s4","accessKey":"s5","credentials":{"user":"u","x":"s6"},"signature":"s7","keep":"ok"}', + createdAt: '2026-01-01T00:00:00', + ); + final text = TeamProfileCodec.encode([noisy]); + for (final secret in ['s1', 's2', 's3', 's4', 's5', 's6', 's7']) { + expect(text, isNot(contains('"$secret"')), reason: secret); + expect(text, isNot(contains('=$secret')), reason: secret); + } + expect(text, contains('sslmode=require')); + expect(text, contains('authSource=admin')); + expect(text, contains('keep')); + }); + + test('import scrubs the same keys from a hand-edited file', () { + final doc = jsonEncode({ + 'format': TeamProfileCodec.format, + 'version': TeamProfileCodec.version, + 'connections': [ + { + 'type': 'postgres', + 'name': 'Edited', + 'driverOptions': '{"privateKey":"s1","pwd":"s2","ok":1}', + }, + ], + }); + final r = TeamProfileCodec.decode(doc).single; + expect(r.driverOptions, isNot(contains('s1'))); + expect(r.driverOptions, isNot(contains('s2'))); + expect(r.driverOptions, contains('"ok":1')); + }); + }); + + group('unreadable entries (#1564)', () { + String profile(List connections) => jsonEncode({ + 'format': TeamProfileCodec.format, + 'version': TeamProfileCodec.version, + 'connections': connections, + }); + + test('a field of the wrong type skips the entry instead of throwing', () { + final read = TeamProfileCodec.read(profile([ + {'type': 'postgres', 'name': 'good', 'host': 'h', 'port': 5432}, + {'type': 'postgres', 'name': 'port-as-text', 'port': '5432'}, + {'type': 'postgres', 'name': 'host-as-number', 'host': 5}, + {'type': 'postgres', 'name': 'user-as-list', 'username': ['a']}, + {'type': 'postgres', 'name': 'extension-as-map', 'extensionId': {}}, + {'type': 'postgres', 'name': 'url-as-number', 'connectionString': 7}, + ])); + expect(read.rows.map((r) => r.name), ['good']); + expect(read.skipped, 5); + }); + + test('entries without a type or name, or not objects, are counted', () { + final read = TeamProfileCodec.read(profile([ + 'text', + 42, + {'name': 'no-type'}, + {'type': 'mysql', 'name': ' '}, + {'type': 'mysql', 'name': 'ok'}, + ])); + expect(read.rows.map((r) => r.name), ['ok']); + expect(read.skipped, 4); + }); + + test('null fields are fine; decode returns just the rows', () { + final doc = profile([ + {'type': 'sqlite', 'name': 'local', 'host': null, 'port': null}, + {'type': 'sqlite', 'name': 'bad', 'port': 'x'}, + ]); + expect(TeamProfileCodec.read(doc).skipped, 1); + expect(TeamProfileCodec.decode(doc).map((r) => r.name), ['local']); + }); + }); }