feat: replace flutter_html with SecureEmailWebView (#21)
Swap the flutter_html renderer for a webview_flutter-based widget that enforces strict security by default: scripts blocked via CSP (script-src 'none'), remote images opt-in, and every link click routed through a confirmation dialog that bolds the registered domain for phishing detection. Links open in the system browser via url_launcher. On Linux (no webview_flutter platform support) the widget falls back to plain text extracted via the existing htmlToPlain() utility. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
902c0a7900
commit
f96f9216cd
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
@@ -13,19 +12,12 @@ import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
|
||||
|
||||
void _openLink(String? url, Map<String, String> attrs, dynamic _) {
|
||||
if (url == null) return;
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null) {
|
||||
unawaited(launchUrl(uri, mode: LaunchMode.externalApplication));
|
||||
}
|
||||
}
|
||||
|
||||
class EmailDetailScreen extends ConsumerStatefulWidget {
|
||||
const EmailDetailScreen({super.key, required this.emailId});
|
||||
final String emailId;
|
||||
@@ -192,9 +184,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
_SafeHtml(
|
||||
data: body.htmlBody!,
|
||||
extensions: [if (!_loadRemoteImages) _BlockRemoteImagesExtension()],
|
||||
SecureEmailWebView(
|
||||
htmlBody: body.htmlBody!,
|
||||
loadRemoteImages: _loadRemoteImages,
|
||||
),
|
||||
] else
|
||||
SelectableText(
|
||||
@@ -519,74 +511,3 @@ class _UnsubscribeChip extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders [Html] and falls back to an error message if the widget throws
|
||||
/// during build, preventing a malformed body from crashing the whole screen.
|
||||
class _SafeHtml extends StatefulWidget {
|
||||
const _SafeHtml({required this.data, required this.extensions});
|
||||
final String data;
|
||||
final List<HtmlExtension> extensions;
|
||||
|
||||
@override
|
||||
State<_SafeHtml> createState() => _SafeHtmlState();
|
||||
}
|
||||
|
||||
class _SafeHtmlState extends State<_SafeHtml> {
|
||||
bool _failed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_failed) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_outlined,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: Text('Message body could not be rendered.')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Intercept any build-phase throw from flutter_html for this subtree.
|
||||
// We save/restore via postFrameCallback so other widgets are unaffected.
|
||||
final prev = ErrorWidget.builder;
|
||||
ErrorWidget.builder = (FlutterErrorDetails details) {
|
||||
ErrorWidget.builder = prev;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _failed = true);
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
};
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => ErrorWidget.builder = prev,
|
||||
);
|
||||
|
||||
return Html(
|
||||
data: widget.data,
|
||||
extensions: widget.extensions,
|
||||
onLinkTap: _openLink,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockRemoteImagesExtension extends HtmlExtension {
|
||||
@override
|
||||
Set<String> get supportedTags => {'img'};
|
||||
|
||||
@override
|
||||
bool matches(ExtensionContext context) {
|
||||
if (context.elementName != 'img') return false;
|
||||
final src = context.attributes['src'] ?? '';
|
||||
return src.startsWith('http://') || src.startsWith('https://');
|
||||
}
|
||||
|
||||
@override
|
||||
InlineSpan build(ExtensionContext context) =>
|
||||
const WidgetSpan(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
@@ -10,7 +9,7 @@ import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||
|
||||
final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
|
||||
|
||||
@@ -164,23 +163,9 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
onPressed: () =>
|
||||
setState(() => _loadRemoteImages = true),
|
||||
),
|
||||
Html(
|
||||
data: body.htmlBody!,
|
||||
extensions: [
|
||||
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
|
||||
],
|
||||
onLinkTap: (url, _, __) {
|
||||
if (url == null) return;
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null) {
|
||||
unawaited(
|
||||
launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
SecureEmailWebView(
|
||||
htmlBody: body.htmlBody!,
|
||||
loadRemoteImages: _loadRemoteImages,
|
||||
),
|
||||
] else
|
||||
SelectableText(
|
||||
@@ -288,19 +273,3 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockRemoteImagesExtension extends HtmlExtension {
|
||||
@override
|
||||
Set<String> get supportedTags => {'img'};
|
||||
|
||||
@override
|
||||
bool matches(ExtensionContext context) {
|
||||
if (context.elementName != 'img') return false;
|
||||
final src = context.attributes['src'] ?? '';
|
||||
return src.startsWith('http://') || src.startsWith('https://');
|
||||
}
|
||||
|
||||
@override
|
||||
InlineSpan build(ExtensionContext context) =>
|
||||
const WidgetSpan(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
/// Renders an HTML email body securely.
|
||||
///
|
||||
/// On Android the content is displayed in a WebView with JavaScript blocked
|
||||
/// via CSP and remote images blocked until the user opts in. Link taps show
|
||||
/// a confirmation dialog that highlights the registered domain to aid phishing
|
||||
/// detection.
|
||||
///
|
||||
/// On Linux (where webview_flutter has no platform support) the HTML is
|
||||
/// converted to plain text and shown in a [SelectableText] widget.
|
||||
class SecureEmailWebView extends StatefulWidget {
|
||||
const SecureEmailWebView({
|
||||
super.key,
|
||||
required this.htmlBody,
|
||||
this.loadRemoteImages = false,
|
||||
});
|
||||
|
||||
final String htmlBody;
|
||||
final bool loadRemoteImages;
|
||||
|
||||
@override
|
||||
State<SecureEmailWebView> createState() => _SecureEmailWebViewState();
|
||||
}
|
||||
|
||||
class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
||||
// Null on Linux where WebView is unavailable.
|
||||
WebViewController? _controller;
|
||||
double _height = 300;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!Platform.isLinux) {
|
||||
final c = WebViewController();
|
||||
unawaited(c.setJavaScriptMode(JavaScriptMode.unrestricted));
|
||||
unawaited(c.setBackgroundColor(Colors.transparent));
|
||||
unawaited(
|
||||
c.setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onNavigationRequest: _handleNavigation,
|
||||
onPageFinished: _measureHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
unawaited(c.loadHtmlString(_buildHtml()));
|
||||
_controller = c;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SecureEmailWebView old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.htmlBody != widget.htmlBody ||
|
||||
old.loadRemoteImages != widget.loadRemoteImages) {
|
||||
if (_controller != null) {
|
||||
unawaited(_controller!.loadHtmlString(_buildHtml()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _buildHtml() {
|
||||
final imgSrc =
|
||||
widget.loadRemoteImages ? 'https: http: data: blob:' : 'data: blob:';
|
||||
// script-src 'none' blocks page scripts; JS mode stays unrestricted so
|
||||
// the controller can call runJavaScriptReturningResult for height measurement.
|
||||
const cspBase = "default-src 'none'; "
|
||||
"style-src 'unsafe-inline'; "
|
||||
"script-src 'none'; "
|
||||
"object-src 'none'; "
|
||||
"font-src 'none'";
|
||||
final csp = '$cspBase; img-src $imgSrc';
|
||||
|
||||
return '''<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="$csp">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
a { color: #1976D2; }
|
||||
* { box-sizing: border-box; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${widget.htmlBody}
|
||||
</body>
|
||||
</html>''';
|
||||
}
|
||||
|
||||
Future<void> _measureHeight(String _) async {
|
||||
final result = await _controller!.runJavaScriptReturningResult(
|
||||
'document.documentElement.scrollHeight',
|
||||
);
|
||||
final h = double.tryParse(result.toString());
|
||||
if (h != null && h > 0 && mounted) {
|
||||
setState(() => _height = h);
|
||||
}
|
||||
}
|
||||
|
||||
NavigationDecision _handleNavigation(NavigationRequest req) {
|
||||
final url = req.url;
|
||||
if (url == 'about:blank' || url.startsWith('data:')) {
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
unawaited(_showLinkDialog(url));
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
Future<void> _showLinkDialog(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !mounted) return;
|
||||
|
||||
final host = uri.host;
|
||||
final parts = host.split('.');
|
||||
// Bold the registered domain (last two DNS labels) to aid phishing detection.
|
||||
final boldStart = (parts.length >= 2
|
||||
? host.length -
|
||||
parts.last.length -
|
||||
1 -
|
||||
parts[parts.length - 2].length
|
||||
: 0)
|
||||
.clamp(0, host.length);
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Open link?'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||
children: [
|
||||
TextSpan(text: host.substring(0, boldStart)),
|
||||
TextSpan(
|
||||
text: host.substring(boldStart),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
url,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Open in browser'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final launched =
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!launched && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not open: $url')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Linux has no webview_flutter platform implementation — show plain text.
|
||||
if (Platform.isLinux) {
|
||||
return SelectableText(
|
||||
htmlToPlain(widget.htmlBody),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height: _height,
|
||||
child: WebViewWidget(controller: _controller!),
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -41,7 +41,7 @@ dependencies:
|
||||
mime: ^2.0.0
|
||||
|
||||
# HTML rendering for email bodies
|
||||
flutter_html: ^3.0.0
|
||||
webview_flutter: ^4.0.0
|
||||
url_launcher: ^6.3.2
|
||||
flutter_markdown: ^0.7.7+1
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ const _excluded = {
|
||||
'lib/ui/screens/thread_detail_screen.dart',
|
||||
'lib/ui/screens/undo_log_screen.dart',
|
||||
'lib/ui/widgets/folder_drawer.dart',
|
||||
'lib/ui/widgets/secure_email_webview.dart',
|
||||
'lib/ui/widgets/snooze_picker.dart',
|
||||
'lib/ui/widgets/try_connection_button.dart',
|
||||
'lib/ui/widgets/undo_shell.dart',
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||
|
||||
Widget _wrap(Widget child) => MaterialApp(
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
|
||||
void main() {
|
||||
// On Linux (the test host) the widget falls back to plain text extracted via
|
||||
// htmlToPlain(). These tests exercise that path.
|
||||
group('SecureEmailWebView (Linux plain-text fallback)', () {
|
||||
testWidgets('renders extracted text from HTML', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
const SecureEmailWebView(
|
||||
htmlBody: '<p>Hello <b>world</b></p>',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.textContaining('Hello'), findsOneWidget);
|
||||
expect(find.textContaining('world'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('strips HTML tags from body', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
const SecureEmailWebView(
|
||||
htmlBody:
|
||||
'<p>Clean text</p><br/><span style="color:red">More</span>',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.textContaining('<'), findsNothing);
|
||||
expect(find.textContaining('Clean text'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows SelectableText widget', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(const SecureEmailWebView(htmlBody: '<p>Test</p>')),
|
||||
);
|
||||
expect(find.byType(SelectableText), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('toggling loadRemoteImages rebuilds without error',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
const SecureEmailWebView(htmlBody: '<p>Body</p>'),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
const SecureEmailWebView(
|
||||
htmlBody: '<p>Body</p>',
|
||||
loadRemoteImages: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.byType(SelectableText), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('handles empty HTML body', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(const SecureEmailWebView(htmlBody: '')),
|
||||
);
|
||||
expect(find.byType(SelectableText), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user