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
76 changes: 60 additions & 16 deletions lib/core/team/team_profile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConnectionRow> connections) {
Expand Down Expand Up @@ -95,7 +101,16 @@ class TeamProfileCodec {
}

/// Parses profile JSON into connection rows (no ids, no secrets).
static List<ConnectionRow> decode(String source, {DateTime? now}) {
static List<ConnectionRow> 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<ConnectionRow> rows, int skipped}) read(
String source, {
DateTime? now,
}) {
final Object? doc;
try {
doc = jsonDecode(source);
Expand All @@ -116,30 +131,59 @@ class TeamProfileCodec {
}
final stamp = (now ?? DateTime.now()).toIso8601String();
final rows = <ConnectionRow>[];
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<T>(String key) {
final v = item[key];
if (v == null) return null;
if (v is T) return v;
unreadable = true;
return null;
}

final host = field<String>('host');
final port = field<int>('port');
final username = field<String>('username');
final databaseName = field<String>('databaseName');
final authSource = field<String>('authSource');
final extensionId = field<String>('extensionId');
final url = field<String>('connectionString');
final options = field<String>('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
Expand Down
17 changes: 11 additions & 6 deletions lib/features/connections/team_profile_actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,21 @@ Future<void> 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;
Expand Down
84 changes: 84 additions & 0 deletions test/core/team/team_profile_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object?> 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']);
});
});
}
Loading