feat: restrict plain-text connections to localhost only
Hides the SSL/TLS toggle in add/edit account screens when the host is not localhost; enforces SSL in connectImap/connectSmtp for non-localhost hosts so plaintext can never be configured accidentally. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
d4d61b2b39
commit
569273d7ff
@@ -6,6 +6,8 @@ Tasks get moved from next.md to done.md
|
||||
|
||||
## Tasks
|
||||
|
||||
## Plain-text connections only via localhost; SSL toggle hidden for non-localhost hosts
|
||||
|
||||
## ManageSieve uses STARTTLS; clearer TLS-mismatch errors; broader connection check
|
||||
|
||||
The "Email filters" screen failed for IMAP accounts with
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
bool isLocalhost(String host) {
|
||||
final h = host.trim().toLowerCase();
|
||||
return h == 'localhost' || h == '127.0.0.1' || h == '::1';
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:enough_mail/enough_mail.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/utils/host_utils.dart';
|
||||
import 'package:sharedinbox/data/imap/tls_error.dart';
|
||||
|
||||
typedef ImapConnectFn = Future<ImapClient> Function(
|
||||
@@ -31,6 +32,11 @@ Future<ImapClient> connectImap(
|
||||
defaultResponseTimeout: const Duration(seconds: 20),
|
||||
isLogEnabled: verboseBuffer != null,
|
||||
);
|
||||
if (!account.imapSsl && !isLocalhost(account.imapHost)) {
|
||||
throw Exception(
|
||||
'Plain-text IMAP is only allowed for localhost connections',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await client.connectToServer(
|
||||
account.imapHost,
|
||||
@@ -61,6 +67,11 @@ Future<SmtpClient> connectSmtp(
|
||||
final clientDomain =
|
||||
atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost;
|
||||
|
||||
if (!account.smtpSsl && !isLocalhost(account.smtpHost)) {
|
||||
throw Exception(
|
||||
'Plain-text SMTP is only allowed for localhost connections',
|
||||
);
|
||||
}
|
||||
final client = SmtpClient(clientDomain);
|
||||
try {
|
||||
await client.connectToServer(
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/discovery_result.dart';
|
||||
import 'package:sharedinbox/core/utils/host_utils.dart';
|
||||
import 'package:sharedinbox/core/utils/logger.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/try_connection_button.dart';
|
||||
@@ -46,8 +47,19 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
final _jmapFormKey = GlobalKey<FormState>();
|
||||
final _imapFormKey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_imapHostCtrl.addListener(_rebuild);
|
||||
_smtpHostCtrl.addListener(_rebuild);
|
||||
}
|
||||
|
||||
void _rebuild() => setState(() {});
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_imapHostCtrl.removeListener(_rebuild);
|
||||
_smtpHostCtrl.removeListener(_rebuild);
|
||||
for (final c in [
|
||||
_emailCtrl,
|
||||
_displayNameCtrl,
|
||||
@@ -112,18 +124,22 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
jmapUrl: _jmapApiUrlCtrl.text.trim(),
|
||||
);
|
||||
|
||||
Account _buildImapAccount() => Account(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
displayName: _displayNameCtrl.text.trim(),
|
||||
email: _emailCtrl.text.trim(),
|
||||
username: _usernameCtrl.text.trim(),
|
||||
imapHost: _imapHostCtrl.text.trim(),
|
||||
imapPort: int.parse(_imapPortCtrl.text),
|
||||
imapSsl: _imapSsl,
|
||||
smtpHost: _smtpHostCtrl.text.trim(),
|
||||
smtpPort: int.parse(_smtpPortCtrl.text),
|
||||
smtpSsl: _smtpSsl,
|
||||
);
|
||||
Account _buildImapAccount() {
|
||||
final imapHost = _imapHostCtrl.text.trim();
|
||||
final smtpHost = _smtpHostCtrl.text.trim();
|
||||
return Account(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
displayName: _displayNameCtrl.text.trim(),
|
||||
email: _emailCtrl.text.trim(),
|
||||
username: _usernameCtrl.text.trim(),
|
||||
imapHost: imapHost,
|
||||
imapPort: int.parse(_imapPortCtrl.text),
|
||||
imapSsl: isLocalhost(imapHost) ? _imapSsl : true,
|
||||
smtpHost: smtpHost,
|
||||
smtpPort: int.parse(_smtpPortCtrl.text),
|
||||
smtpSsl: isLocalhost(smtpHost) ? _smtpSsl : true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _tryConnection(
|
||||
GlobalKey<FormState> formKey,
|
||||
@@ -399,20 +415,22 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
Text('IMAP', style: Theme.of(context).textTheme.titleSmall),
|
||||
_field(_imapHostCtrl, 'Host'),
|
||||
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||
SwitchListTile(
|
||||
title: const Text('SSL/TLS'),
|
||||
value: _imapSsl,
|
||||
onChanged: (v) => setState(() => _imapSsl = v),
|
||||
),
|
||||
if (isLocalhost(_imapHostCtrl.text.trim()))
|
||||
SwitchListTile(
|
||||
title: const Text('SSL/TLS'),
|
||||
value: _imapSsl,
|
||||
onChanged: (v) => setState(() => _imapSsl = v),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
|
||||
_field(_smtpHostCtrl, 'Host'),
|
||||
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||
SwitchListTile(
|
||||
title: const Text('SSL/TLS'),
|
||||
value: _smtpSsl,
|
||||
onChanged: (v) => setState(() => _smtpSsl = v),
|
||||
),
|
||||
if (isLocalhost(_smtpHostCtrl.text.trim()))
|
||||
SwitchListTile(
|
||||
title: const Text('SSL/TLS'),
|
||||
value: _smtpSsl,
|
||||
onChanged: (v) => setState(() => _smtpSsl = v),
|
||||
),
|
||||
TryConnectionButton(
|
||||
buttonKey: const Key('tryConnectionButton'),
|
||||
testing: _tryTesting,
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/utils/host_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/try_connection_button.dart';
|
||||
|
||||
@@ -46,9 +47,14 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_smtpHostCtrl.addListener(_rebuild);
|
||||
_sieveHostCtrl.addListener(_rebuild);
|
||||
_imapHostCtrl.addListener(_rebuild);
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
void _rebuild() => setState(() {});
|
||||
|
||||
Future<void> _load() async {
|
||||
final repo = ref.read(accountRepositoryProvider);
|
||||
final account = await repo.getAccount(widget.accountId);
|
||||
@@ -75,6 +81,9 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_smtpHostCtrl.removeListener(_rebuild);
|
||||
_sieveHostCtrl.removeListener(_rebuild);
|
||||
_imapHostCtrl.removeListener(_rebuild);
|
||||
for (final c in [
|
||||
_displayNameCtrl,
|
||||
_usernameCtrl,
|
||||
@@ -104,6 +113,8 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
sieveHost != account.manageSieveHost ||
|
||||
sievePort != account.manageSievePort ||
|
||||
_sieveSsl != account.manageSieveSsl;
|
||||
final smtpHost = _smtpHostCtrl.text.trim();
|
||||
final effectiveSieveHost = sieveHost.isNotEmpty ? sieveHost : imapHost;
|
||||
return Account(
|
||||
id: account.id,
|
||||
displayName: _displayNameCtrl.text.trim(),
|
||||
@@ -112,12 +123,13 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
type: account.type,
|
||||
imapHost: imapHost,
|
||||
imapPort: int.tryParse(_imapPortCtrl.text) ?? account.imapPort,
|
||||
smtpHost: _smtpHostCtrl.text.trim(),
|
||||
imapSsl: isLocalhost(imapHost) ? account.imapSsl : true,
|
||||
smtpHost: smtpHost,
|
||||
smtpPort: int.tryParse(_smtpPortCtrl.text) ?? account.smtpPort,
|
||||
smtpSsl: _smtpSsl,
|
||||
smtpSsl: isLocalhost(smtpHost) ? _smtpSsl : true,
|
||||
manageSieveHost: sieveHost,
|
||||
manageSievePort: sievePort,
|
||||
manageSieveSsl: _sieveSsl,
|
||||
manageSieveSsl: isLocalhost(effectiveSieveHost) ? _sieveSsl : true,
|
||||
manageSieveAvailable:
|
||||
sieveSettingsChanged ? null : account.manageSieveAvailable,
|
||||
jmapUrl:
|
||||
@@ -284,11 +296,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
|
||||
_field(_smtpHostCtrl, 'Host'),
|
||||
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||
SwitchListTile(
|
||||
title: const Text('SSL/TLS'),
|
||||
value: _smtpSsl,
|
||||
onChanged: (v) => setState(() => _smtpSsl = v),
|
||||
),
|
||||
if (isLocalhost(_smtpHostCtrl.text.trim()))
|
||||
SwitchListTile(
|
||||
title: const Text('SSL/TLS'),
|
||||
value: _smtpSsl,
|
||||
onChanged: (v) => setState(() => _smtpSsl = v),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
@@ -307,11 +320,16 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
'Port',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('SSL/TLS'),
|
||||
value: _sieveSsl,
|
||||
onChanged: (v) => setState(() => _sieveSsl = v),
|
||||
),
|
||||
if (isLocalhost(
|
||||
_sieveHostCtrl.text.trim().isNotEmpty
|
||||
? _sieveHostCtrl.text.trim()
|
||||
: _imapHostCtrl.text.trim(),
|
||||
))
|
||||
SwitchListTile(
|
||||
title: const Text('SSL/TLS'),
|
||||
value: _sieveSsl,
|
||||
onChanged: (v) => setState(() => _sieveSsl = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -20,5 +20,7 @@ Then push
|
||||
|
||||
## Tasks
|
||||
|
||||
Plain-text connections only via localhost.
|
||||
Dont show in ui, except host is localhost.
|
||||
Download of attachments does not work yet. Attachments have size 0. (IMAP account)
|
||||
---
|
||||
|
||||
I deleted some mails, then I use Thunderbird, but the deleted mails are still there. After restart of sharedinbox the delete seems to get synced. Why not immediately?
|
||||
|
||||
@@ -287,7 +287,8 @@ void main() {
|
||||
expect(find.text('No accounts yet.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('IMAP form shows SSL/TLS label and SMTP toggle',
|
||||
testWidgets(
|
||||
'IMAP form hides SSL toggle for non-localhost, shows for localhost',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
@@ -308,8 +309,23 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('IMAP'), findsOneWidget);
|
||||
// IMAP and SMTP each have an SSL/TLS toggle (the ManageSieve toggle is
|
||||
// hidden inside a collapsed ExpansionTile).
|
||||
// No SSL toggles shown when hosts are empty (non-localhost).
|
||||
expect(find.byType(SwitchListTile), findsNothing);
|
||||
|
||||
// Entering localhost as IMAP host reveals the IMAP SSL toggle.
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Host').first,
|
||||
'localhost',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SwitchListTile), findsOneWidget);
|
||||
|
||||
// Entering localhost as SMTP host reveals both SSL toggles.
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Host').last,
|
||||
'localhost',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SwitchListTile), findsNWidgets(2));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user