[];
- final pattern = RegExp(r'''(?:(?:"([^"]+)"|'([^']+)')\s*)?<([^>]+)>''');
-
int currentIndex = 0;
// Use a for-in loop directly on the iterable to save memory
- for (final match in pattern.allMatches(input)) {
+ for (final match in _namedAddressRegex.allMatches(input)) {
if (match.start > currentIndex) {
final between = input.substring(currentIndex, match.start);
results.addAll(_splitPlainAddresses(between, emailSeparatorPattern));
diff --git a/core/test/utils/redos_vulnerability_test.dart b/core/test/utils/redos_vulnerability_test.dart
new file mode 100644
index 000000000..c39899597
--- /dev/null
+++ b/core/test/utils/redos_vulnerability_test.dart
@@ -0,0 +1,384 @@
+import 'package:core/presentation/utils/html_transformer/dom/image_transformers.dart';
+import 'package:core/utils/html/html_utils.dart';
+import 'package:core/utils/string_convert.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+/// Tests to verify that ReDoS (Regular Expression Denial of Service) vulnerabilities
+/// have been properly patched. These tests use inputs that would cause catastrophic
+/// backtracking in the old regex patterns.
+void main() {
+ group('ReDoS Vulnerability Patches -', () {
+ group('DomTransformer._backgroundImageRegex', () {
+ const transformer = ImageTransformer();
+
+ test('should handle very long background-image style without freezing', () {
+ // Create a malicious input that would cause catastrophic backtracking
+ // in the old pattern: r'background-image:\s*url\(([^)]+)\).*?'
+ final maliciousStyle = 'background-image: url(${'a' * 10000})';
+
+ final stopwatch = Stopwatch()..start();
+ final result = transformer.findImageUrlFromStyleTag(maliciousStyle);
+ stopwatch.stop();
+
+ // Should complete within a reasonable time (< 100ms)
+ expect(stopwatch.elapsedMilliseconds, lessThan(100),
+ reason: 'Regex should not cause catastrophic backtracking');
+ expect(result, isNotNull);
+ });
+
+ test('should handle style with many nested parentheses', () {
+ final style = 'background-image: url(${'(' * 1000}example.jpg${')' * 1000})';
+
+ final stopwatch = Stopwatch()..start();
+ final result = transformer.findImageUrlFromStyleTag(style);
+ stopwatch.stop();
+
+ expect(stopwatch.elapsedMilliseconds, lessThan(100));
+ expect(result, isNotNull);
+ });
+
+ test('should reject malformed background-image patterns quickly', () {
+ // Input with many whitespace and incomplete patterns
+ final maliciousInput = 'background-image:${' ' * 10000}url(';
+
+ final stopwatch = Stopwatch()..start();
+ final result = transformer.findImageUrlFromStyleTag(maliciousInput);
+ stopwatch.stop();
+
+ expect(stopwatch.elapsedMilliseconds, lessThan(100));
+ expect(result, isNull);
+ });
+
+ test('should correctly parse valid background-image patterns', () {
+ const validPatterns = [
+ 'background-image: url(example.com/image.jpg)',
+ 'background-image: url("example.com/image.jpg")',
+ "background-image: url('example.com/image.jpg')",
+ 'background-image:url(example.com/image.jpg)',
+ ];
+
+ for (final pattern in validPatterns) {
+ final result = transformer.findImageUrlFromStyleTag(pattern);
+ expect(result, isNotNull, reason: 'Should parse: $pattern');
+ expect(result!.value2, contains('example.com/image.jpg'));
+ }
+ });
+ });
+
+ group('NormalizeLineHeightInStyleTransformer', () {
+ test('should handle very long style strings efficiently', () {
+ final longStyle = 'color: red; ' * 10000 + 'line-height: 1px;';
+
+ final stopwatch = Stopwatch()..start();
+ final normalized = longStyle.replaceAll(
+ RegExp(r'line-height\s*:\s*(?:1px|100%)\s*;?', caseSensitive: false),
+ '',
+ );
+ stopwatch.stop();
+
+ expect(stopwatch.elapsedMilliseconds, lessThan(100));
+ expect(normalized, isNot(contains('line-height')));
+ });
+
+ test('should handle many consecutive spaces efficiently', () {
+ final manySpaces = 'text${' ' * 10000}more text';
+
+ final stopwatch = Stopwatch()..start();
+ final normalized = manySpaces.replaceAll(RegExp(r' {2,}'), ' ');
+ stopwatch.stop();
+
+ expect(stopwatch.elapsedMilliseconds, lessThan(100));
+ expect(normalized, equals('text more text'));
+ });
+ });
+
+ group('HtmlUtils regex patterns', () {
+ test('_htmlStartTagRegex should handle malicious tag patterns', () {
+ // Old pattern: r'<[a-zA-Z][^>]*>' could cause issues with many attributes
+ final maliciousTag = '';
+
+ final stopwatch = Stopwatch()..start();
+ HtmlUtils.addQuoteToggle(maliciousTag);
+ stopwatch.stop();
+
+ expect(stopwatch.elapsedMilliseconds, lessThan(100));
+ });
+
+ test('_htmlEndTagRegex should have bounded quantifier', () {
+ // Old pattern had unbounded quantifier: r'[a-zA-Z][^>]*>'
+ // New pattern has max 128 chars: r'[a-zA-Z][^>]{0,128}>'
+ final longEndTag = '
';
+
+ final stopwatch = Stopwatch()..start();
+ HtmlUtils.addQuoteToggle(longEndTag);
+ stopwatch.stop();
+
+ expect(stopwatch.elapsedMilliseconds, lessThan(100));
+ });
+
+ test('_urlRegex should handle long URLs efficiently', () {
+ // Old pattern: r'(?:(?:https?:\/\/)|...)(?!\.)(?!.*\.\.)([^\s<]+[^<.,:;\"\'\)\]\s!?])'
+ // had potential for catastrophic backtracking
+ final longUrl = 'https://${'a' * 10000}.com${'b' * 10000}';
+
+ final stopwatch = Stopwatch()..start();
+ HtmlUtils.wrapPlainTextLinks('$longUrl
');
+ stopwatch.stop();
+
+ expect(stopwatch.elapsedMilliseconds, lessThan(200));
+ });
+
+ test('_urlRegex should correctly match valid URLs', () {
+ const validUrls = [
+ 'https://example.com',
+ 'http://test.org',
+ 'ftp://files.com',
+ 'mailto:test@example.com',
+ 'file:///path/to/file',
+ 'www.example.com',
+ ];
+
+ for (final url in validUrls) {
+ final html = 'Visit $url today
';
+ final result = HtmlUtils.wrapPlainTextLinks(html);
+ expect(result, contains(' '"User $i" ',
+ ).join(', ');
+
+ final stopwatch = Stopwatch()..start();
+ final result = StringConvert.extractNamedAddresses(manyAddresses);
+ stopwatch.stop();
+
+ expect(stopwatch.elapsedMilliseconds, lessThan(500));
+ expect(result.length, equals(1000));
+ });
+
+ test('extractEmailAddress should handle malicious separator patterns', () {
+ // Old pattern could have issues with many mixed separators
+ final maliciousInput = 'email@test.com${', ; ' * 10000}another@test.com';
+
+ final stopwatch = Stopwatch()..start();
+ final result = StringConvert.extractEmailAddress(maliciousInput);
+ stopwatch.stop();
+
+ expect(stopwatch.elapsedMilliseconds, lessThan(100));
+ expect(result, contains('email@test.com'));
+ expect(result, contains('another@test.com'));
+ });
+
+ test('isTextTable should correctly identify valid tables', () {
+ const validTables = [
+ '''
+| Header 1 | Header 2 |
+|----------|----------|
+| Cell 1 | Cell 2 |
+''',
+ '''
++--------+--------+
+| Cell 1 | Cell 2 |
++--------+--------+
+''',
+ ];
+
+ for (final table in validTables) {
+ expect(StringConvert.isTextTable(table), isTrue,
+ reason: 'Should identify as table');
+ }
+ });
+
+ test('isTextTable should correctly reject non-tables', () {
+ const nonTables = [
+ 'Just plain text',
+ 'Single line with | pipes',
+ '',
+ ' \n ',
+ ];
+
+ for (final text in nonTables) {
+ expect(StringConvert.isTextTable(text), isFalse,
+ reason: 'Should not identify as table: $text');
+ }
+ });
+ });
+
+ group('AppUtils._emailLocalhostRegex', () {
+ test('should handle very long email addresses efficiently', () {
+ final longEmail = '${'a' * 10000}@localhost';
+
+ final stopwatch = Stopwatch()..start();
+ // Note: We can't directly test AppUtils.isEmailLocalhost in core tests
+ // But we can test the regex pattern
+ final regex = RegExp(
+ r'^(?:"[^"\r\n]+"|[^<>()[\]\\.,;:\s@"]+(?:\.[^<>()[\]\\.,;:\s@"]+)*)@localhost$',
+ );
+ final result = regex.hasMatch(longEmail);
+ stopwatch.stop();
+
+ expect(stopwatch.elapsedMilliseconds, lessThan(100));
+ expect(result, isTrue);
+ });
+
+ test('should correctly match valid localhost emails', () {
+ final regex = RegExp(
+ r'^(?:"[^"\r\n]+"|[^<>()[\]\\.,;:\s@"]+(?:\.[^<>()[\]\\.,;:\s@"]+)*)@localhost$',
+ );
+
+ const validEmails = [
+ 'user@localhost',
+ 'user.name@localhost',
+ '"User Name"@localhost',
+ ];
+
+ for (final email in validEmails) {
+ expect(regex.hasMatch(email.trim()), isTrue,
+ reason: 'Should match: $email');
+ }
+ });
+
+ test('should reject invalid localhost emails', () {
+ final regex = RegExp(
+ r'^(?:"[^"\r\n]+"|[^<>()[\]\\.,;:\s@"]+(?:\.[^<>()[\]\\.,;:\s@"]+)*)@localhost$',
+ );
+
+ const invalidEmails = [
+ 'user@localhost.com',
+ '@localhost',
+ 'user@',
+ 'not-an-email',
+ ];
+
+ for (final email in invalidEmails) {
+ expect(regex.hasMatch(email.trim()), isFalse,
+ reason: 'Should not match: $email');
+ }
+ });
+ });
+
+ group('Performance stress tests', () {
+ test('all regex patterns should handle edge case inputs within time limit', () {
+ // Create various edge case inputs
+ final edgeCases = [
+ 'x' * 100000, // Very long string
+ '(' * 1000 + ')' * 1000, // Many nested parentheses
+ ' ' * 100000, // Many spaces
+ '\n' * 10000, // Many newlines
+ '|||||||' * 10000, // Many pipes
+ '-------' * 10000, // Many dashes
+ ];
+
+ for (final input in edgeCases) {
+ final stopwatch = Stopwatch()..start();
+
+ // Test various functions with edge case input
+ try {
+ StringConvert.extractEmailAddress(input);
+ StringConvert.isTextTable('$input\n$input');
+ HtmlUtils.extractPlainText(input);
+ } catch (e) {
+ // It's OK if these fail, we're just testing performance
+ }
+
+ stopwatch.stop();
+
+ expect(stopwatch.elapsedMilliseconds, lessThan(1000),
+ reason: 'Should process edge case within 1 second');
+ }
+ });
+ });
+
+ group('Regex initialization optimization', () {
+ test('regex patterns should be reused, not recreated', () {
+ // This test verifies that regex patterns are static and reused
+ // We can't directly test if they're the same object, but we can
+ // verify performance doesn't degrade with multiple calls
+
+ const iterations = 1000;
+ final stopwatch = Stopwatch()..start();
+
+ for (var i = 0; i < iterations; i++) {
+ StringConvert.isTextTable('| a | b |\n|---|---|\n| c | d |');
+ HtmlUtils.wrapPlainTextLinks('https://example.com
');
+ StringConvert.extractEmailAddress('test@example.com, test2@example.com');
+ }
+
+ stopwatch.stop();
+
+ // If regex patterns were being recreated each time, this would be much slower
+ expect(stopwatch.elapsedMilliseconds, lessThan(500),
+ reason: 'Multiple calls should be fast due to regex reuse');
+ });
+ });
+ });
+}
diff --git a/lib/main/utils/app_utils.dart b/lib/main/utils/app_utils.dart
index f082b060b..de17233f6 100644
--- a/lib/main/utils/app_utils.dart
+++ b/lib/main/utils/app_utils.dart
@@ -14,6 +14,10 @@ import 'package:tmail_ui_user/main/universal_import/html_stub.dart' as html;
class AppUtils {
const AppUtils._();
+ static final _emailLocalhostRegex = RegExp(
+ r'^(?:"[^"\r\n]+"|[^<>()[\]\\.,;:\s@"]+(?:\.[^<>()[\]\\.,;:\s@"]+)*)@localhost$',
+ );
+
static Future loadEnvFile() async {
await dotenv.load(fileName: AppConfig.envFileName);
final mapEnvData = Map.from(dotenv.env);
@@ -53,9 +57,7 @@ class AppUtils {
static bool isEmailLocalhost(String email) {
final normalized = email.trim();
- return RegExp(
- r'^(?:"[^"\r\n]+"|[^<>()[\]\\.,;:\s@"]+(?:\.[^<>()[\]\\.,;:\s@"]+)*)@localhost$'
- ).hasMatch(normalized);
+ return _emailLocalhostRegex.hasMatch(normalized);
}
static void copyEmailAddressToClipboard(BuildContext context, String emailAddress) {