fixup! TF-4224 Reduce ReDoS vulnerability
This commit is contained in:
@@ -21,10 +21,23 @@ class StringConvert {
|
|||||||
);
|
);
|
||||||
static final _asciiArtRegex = RegExp(r'[+\-|/\\=]');
|
static final _asciiArtRegex = RegExp(r'[+\-|/\\=]');
|
||||||
static final _namedAddressRegex = RegExp(r'''(?:(?:"([^"]+)"|'([^']+)')\s*)?<([^>]+)>''');
|
static final _namedAddressRegex = RegExp(r'''(?:(?:"([^"]+)"|'([^']+)')\s*)?<([^>]+)>''');
|
||||||
|
static final _emailLocalhostRegex = RegExp(
|
||||||
|
r'^(?:"[^"\r\n]+"|[^<>()[\]\\.,;:\s@"]+(?:\.[^<>()[\]\\.,;:\s@"]+)*)@localhost$',
|
||||||
|
);
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static RegExp get base64ValidationRegex => _base64ValidationRegex;
|
static RegExp get base64ValidationRegex => _base64ValidationRegex;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static RegExp get emailLocalhostRegex => _emailLocalhostRegex;
|
||||||
|
|
||||||
|
/// Checks if the given email address is a localhost email
|
||||||
|
/// Returns true if the email ends with @localhost
|
||||||
|
static bool isEmailLocalhost(String email) {
|
||||||
|
final normalized = email.trim();
|
||||||
|
return _emailLocalhostRegex.hasMatch(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
static String? writeEmptyToNull(String text) {
|
static String? writeEmptyToNull(String text) {
|
||||||
if (text.isEmpty) return null;
|
if (text.isEmpty) return null;
|
||||||
return text;
|
return text;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import 'package:core/data/network/dio_client.dart';
|
||||||
import 'package:core/presentation/utils/html_transformer/dom/image_transformers.dart';
|
import 'package:core/presentation/utils/html_transformer/dom/image_transformers.dart';
|
||||||
|
import 'package:core/presentation/utils/html_transformer/dom/normalize_line_height_in_style_transformer.dart';
|
||||||
import 'package:core/utils/html/html_utils.dart';
|
import 'package:core/utils/html/html_utils.dart';
|
||||||
import 'package:core/utils/string_convert.dart';
|
import 'package:core/utils/string_convert.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:html/parser.dart' as html_parser;
|
||||||
|
import 'package:mockito/mockito.dart';
|
||||||
|
|
||||||
|
class MockDioClient extends Mock implements DioClient {}
|
||||||
|
|
||||||
/// Tests to verify that ReDoS (Regular Expression Denial of Service) vulnerabilities
|
/// Tests to verify that ReDoS (Regular Expression Denial of Service) vulnerabilities
|
||||||
/// have been properly patched. These tests use inputs that would cause catastrophic
|
/// have been properly patched. These tests use inputs that would cause catastrophic
|
||||||
@@ -65,30 +71,130 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('NormalizeLineHeightInStyleTransformer tests regex patterns in isolation', () {
|
group('NormalizeLineHeightInStyleTransformer', () {
|
||||||
test('should handle very long style strings efficiently', () {
|
late NormalizeLineHeightInStyleTransformer transformer;
|
||||||
|
late MockDioClient mockDioClient;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
transformer = const NormalizeLineHeightInStyleTransformer();
|
||||||
|
mockDioClient = MockDioClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle very long style strings efficiently', () async {
|
||||||
|
// Create HTML with very long style attribute
|
||||||
final longStyle = 'color: red; ' * 10000 + 'line-height: 1px;';
|
final longStyle = 'color: red; ' * 10000 + 'line-height: 1px;';
|
||||||
|
final htmlContent = '<div style="$longStyle">Content</div>';
|
||||||
|
final document = html_parser.parse(htmlContent);
|
||||||
|
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final normalized = longStyle.replaceAll(
|
await transformer.process(
|
||||||
RegExp(r'line-height\s*:\s*(?:1px|100%)\s*;?', caseSensitive: false),
|
document: document,
|
||||||
'',
|
dioClient: mockDioClient,
|
||||||
);
|
);
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
|
|
||||||
expect(stopwatch.elapsedMilliseconds, lessThan(100));
|
expect(stopwatch.elapsedMilliseconds, lessThan(100));
|
||||||
expect(normalized, isNot(contains('line-height')));
|
|
||||||
|
// Verify the transformer removed line-height
|
||||||
|
final divElement = document.querySelector('div');
|
||||||
|
final resultStyle = divElement?.attributes['style'] ?? '';
|
||||||
|
expect(resultStyle, isNot(contains('line-height')));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle many consecutive spaces efficiently', () {
|
test('should handle many consecutive spaces efficiently', () async {
|
||||||
final manySpaces = 'text${' ' * 10000}more text';
|
// Create HTML with many spaces in style that includes line-height
|
||||||
|
final manySpaces = 'color:${' ' * 10000}red; line-height: 1px;';
|
||||||
|
final htmlContent = '<p style="$manySpaces">Text</p>';
|
||||||
|
final document = html_parser.parse(htmlContent);
|
||||||
|
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final normalized = manySpaces.replaceAll(RegExp(r' {2,}'), ' ');
|
await transformer.process(
|
||||||
|
document: document,
|
||||||
|
dioClient: mockDioClient,
|
||||||
|
);
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
|
|
||||||
expect(stopwatch.elapsedMilliseconds, lessThan(100));
|
expect(stopwatch.elapsedMilliseconds, lessThan(100));
|
||||||
expect(normalized, equals('text more text'));
|
|
||||||
|
// After removing line-height, spaces should be normalized
|
||||||
|
final pElement = document.querySelector('p');
|
||||||
|
final resultStyle = pElement?.attributes['style'] ?? '';
|
||||||
|
expect(resultStyle, isNot(contains('line-height')));
|
||||||
|
// Multiple consecutive spaces should be normalized to single space
|
||||||
|
expect(resultStyle, isNot(matches(r' {2,}')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove line-height: 1px and 100% patterns', () async {
|
||||||
|
final testCases = [
|
||||||
|
'<div style="line-height: 1px; color: blue;">Test 1px</div>',
|
||||||
|
'<div style="line-height: 100%; color: red;">Test 100%</div>',
|
||||||
|
'<div style="line-height:1px">No spaces</div>',
|
||||||
|
'<div style="LINE-HEIGHT: 1PX;">Case insensitive</div>',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final htmlContent in testCases) {
|
||||||
|
final document = html_parser.parse(htmlContent);
|
||||||
|
await transformer.process(
|
||||||
|
document: document,
|
||||||
|
dioClient: mockDioClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final divElement = document.querySelector('div');
|
||||||
|
final resultStyle = divElement?.attributes['style'] ?? '';
|
||||||
|
expect(resultStyle, isNot(contains('line-height')),
|
||||||
|
reason: 'Should remove line-height from: $htmlContent');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should preserve other line-height values', () async {
|
||||||
|
final testCases = [
|
||||||
|
'<div style="line-height: 1.5; color: blue;">Test 1.5</div>',
|
||||||
|
'<div style="line-height: 20px; color: red;">Test 20px</div>',
|
||||||
|
'<div style="line-height: normal;">Test normal</div>',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final htmlContent in testCases) {
|
||||||
|
final document = html_parser.parse(htmlContent);
|
||||||
|
await transformer.process(
|
||||||
|
document: document,
|
||||||
|
dioClient: mockDioClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final divElement = document.querySelector('div');
|
||||||
|
final resultStyle = divElement?.attributes['style'] ?? '';
|
||||||
|
expect(resultStyle, contains('line-height'),
|
||||||
|
reason: 'Should preserve line-height from: $htmlContent');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove style attribute when empty after transformation', () async {
|
||||||
|
const htmlContent = '<div style="line-height: 1px;">Empty after</div>';
|
||||||
|
final document = html_parser.parse(htmlContent);
|
||||||
|
|
||||||
|
await transformer.process(
|
||||||
|
document: document,
|
||||||
|
dioClient: mockDioClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final divElement = document.querySelector('div');
|
||||||
|
expect(divElement?.attributes.containsKey('style'), isFalse,
|
||||||
|
reason: 'Should remove empty style attribute');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should normalize multiple spaces after removing line-height', () async {
|
||||||
|
const htmlContent = '<div style="color: red; line-height: 1px; font-size: 12px;">Multi-space</div>';
|
||||||
|
final document = html_parser.parse(htmlContent);
|
||||||
|
|
||||||
|
await transformer.process(
|
||||||
|
document: document,
|
||||||
|
dioClient: mockDioClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final divElement = document.querySelector('div');
|
||||||
|
final resultStyle = divElement?.attributes['style'] ?? '';
|
||||||
|
expect(resultStyle, isNot(contains(' '))); // No double spaces
|
||||||
|
expect(resultStyle, contains('color: red'));
|
||||||
|
expect(resultStyle, contains('font-size: 12px'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -366,35 +472,53 @@ void main() {
|
|||||||
expect(result, contains('another@test.com'));
|
expect(result, contains('another@test.com'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Should handle emails with special characters and edge cases', () {
|
test('Should handle emails with special characters - valid cases', () {
|
||||||
final specialCharEmails = [
|
// Valid emails that should be extracted successfully
|
||||||
// Valid special characters in local part
|
final validEmailInputs = {
|
||||||
'user+tag@example.com',
|
'user+tag@example.com': 'user+tag@example.com',
|
||||||
'user.name@example.com',
|
'user.name@example.com': 'user.name@example.com',
|
||||||
'user_name@example.com',
|
'user_name@example.com': 'user_name@example.com',
|
||||||
'user-name@example.com',
|
'user-name@example.com': 'user-name@example.com',
|
||||||
'user123@example.com',
|
'user123@example.com': 'user123@example.com',
|
||||||
'123user@example.com',
|
'123user@example.com': '123user@example.com',
|
||||||
// International domain
|
'user@example.co.uk': 'user@example.co.uk',
|
||||||
'user@example.co.uk',
|
'user@sub.domain.example.com': 'user@sub.domain.example.com',
|
||||||
'user@sub.domain.example.com',
|
'a.b.c.d@example.com': 'a.b.c.d@example.com',
|
||||||
// Quoted local part (valid but rare)
|
'user@例え.jp': 'user@例え.jp',
|
||||||
|
'${'a' * 64}@example.com': '${'a' * 64}@example.com',
|
||||||
|
'Contact us at support@example.com for help': 'support@example.com',
|
||||||
|
'Multiple emails: alice@test.com, bob@test.org': contains('alice@test.com'),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (final entry in validEmailInputs.entries) {
|
||||||
|
final input = entry.key;
|
||||||
|
final expectedMatch = entry.value;
|
||||||
|
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
final result = StringConvert.extractEmailAddress(input);
|
||||||
|
stopwatch.stop();
|
||||||
|
|
||||||
|
expect(stopwatch.elapsedMilliseconds, lessThan(100),
|
||||||
|
reason: 'Should process quickly: $input');
|
||||||
|
|
||||||
|
if (expectedMatch is String) {
|
||||||
|
expect(result, contains(expectedMatch),
|
||||||
|
reason: 'Should extract "$expectedMatch" from: $input');
|
||||||
|
} else {
|
||||||
|
expect(result, expectedMatch,
|
||||||
|
reason: 'Should match expected pattern from: $input');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should handle quoted email addresses', () {
|
||||||
|
// Quoted email addresses have special handling in email specs
|
||||||
|
const quotedEmails = [
|
||||||
'"user@name"@example.com',
|
'"user@name"@example.com',
|
||||||
'"user name"@example.com',
|
'"user name"@example.com',
|
||||||
// Edge cases with dots
|
|
||||||
'a.b.c.d@example.com',
|
|
||||||
'user.@example.com', // Invalid but should handle gracefully
|
|
||||||
'.user@example.com', // Invalid but should handle gracefully
|
|
||||||
// Unicode/international emails (if supported)
|
|
||||||
'user@例え.jp',
|
|
||||||
// Very long local part
|
|
||||||
'${'a' * 64}@example.com',
|
|
||||||
// Mixed with other text
|
|
||||||
'Contact us at support@example.com for help',
|
|
||||||
'Multiple emails: alice@test.com, bob@test.org',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (final input in specialCharEmails) {
|
for (final input in quotedEmails) {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final result = StringConvert.extractEmailAddress(input);
|
final result = StringConvert.extractEmailAddress(input);
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
@@ -403,11 +527,39 @@ void main() {
|
|||||||
expect(stopwatch.elapsedMilliseconds, lessThan(100),
|
expect(stopwatch.elapsedMilliseconds, lessThan(100),
|
||||||
reason: 'Should process quickly: $input');
|
reason: 'Should process quickly: $input');
|
||||||
|
|
||||||
// If it looks like a valid email, should extract something
|
// Should extract something (even if split) - no crash
|
||||||
if (input.contains('@') && !input.startsWith('.') && !input.endsWith('.')) {
|
expect(result, isA<List<String>>(),
|
||||||
expect(result, isNotEmpty,
|
reason: 'Should return a list for: $input');
|
||||||
reason: 'Should extract email from: $input');
|
expect(result, isNotEmpty,
|
||||||
}
|
reason: 'Should extract something from: $input');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should gracefully handle invalid or edge case emails', () {
|
||||||
|
// Invalid emails or edge cases - should handle gracefully without crashing
|
||||||
|
const edgeCaseInputs = [
|
||||||
|
'user.@example.com', // Dot before @
|
||||||
|
'.user@example.com', // Leading dot
|
||||||
|
'user..name@example.com', // Consecutive dots
|
||||||
|
'@example.com', // Missing local part
|
||||||
|
'user@', // Missing domain
|
||||||
|
'user', // No @ symbol
|
||||||
|
'', // Empty string
|
||||||
|
' ', // Only whitespace
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final input in edgeCaseInputs) {
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
final result = StringConvert.extractEmailAddress(input);
|
||||||
|
stopwatch.stop();
|
||||||
|
|
||||||
|
// Performance check - should handle gracefully without hanging
|
||||||
|
expect(stopwatch.elapsedMilliseconds, lessThan(100),
|
||||||
|
reason: 'Should process quickly even for invalid: $input');
|
||||||
|
|
||||||
|
// Result should be a list (even if empty) - no crash
|
||||||
|
expect(result, isA<List<String>>(),
|
||||||
|
reason: 'Should return a list for: $input');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -446,17 +598,12 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('AppUtils._emailLocalhostRegex', () {
|
group('StringConvert.isEmailLocalhost', () {
|
||||||
final localhostRegex = RegExp(
|
|
||||||
r'^(?:"[^"\r\n]+"|[^<>()[\]\\.,;:\s@"]+(?:\.[^<>()[\]\\.,;:\s@"]+)*)@localhost$',
|
|
||||||
);
|
|
||||||
test('should handle very long email addresses efficiently', () {
|
test('should handle very long email addresses efficiently', () {
|
||||||
final longEmail = '${'a' * 10000}@localhost';
|
final longEmail = '${'a' * 10000}@localhost';
|
||||||
|
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
// Note: We can't directly test AppUtils.isEmailLocalhost in core tests
|
final result = StringConvert.isEmailLocalhost(longEmail);
|
||||||
// But we can test the regex pattern
|
|
||||||
final result = localhostRegex.hasMatch(longEmail);
|
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
|
|
||||||
expect(stopwatch.elapsedMilliseconds, lessThan(100));
|
expect(stopwatch.elapsedMilliseconds, lessThan(100));
|
||||||
@@ -471,7 +618,7 @@ void main() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (final email in validEmails) {
|
for (final email in validEmails) {
|
||||||
expect(localhostRegex.hasMatch(email.trim()), isTrue,
|
expect(StringConvert.isEmailLocalhost(email), isTrue,
|
||||||
reason: 'Should match: $email');
|
reason: 'Should match: $email');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -485,7 +632,7 @@ void main() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (final email in invalidEmails) {
|
for (final email in invalidEmails) {
|
||||||
expect(localhostRegex.hasMatch(email.trim()), isFalse,
|
expect(StringConvert.isEmailLocalhost(email), isFalse,
|
||||||
reason: 'Should not match: $email');
|
reason: 'Should not match: $email');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ import 'package:tmail_ui_user/main/universal_import/html_stub.dart' as html;
|
|||||||
class AppUtils {
|
class AppUtils {
|
||||||
const AppUtils._();
|
const AppUtils._();
|
||||||
|
|
||||||
static final _emailLocalhostRegex = RegExp(
|
|
||||||
r'^(?:"[^"\r\n]+"|[^<>()[\]\\.,;:\s@"]+(?:\.[^<>()[\]\\.,;:\s@"]+)*)@localhost$',
|
|
||||||
);
|
|
||||||
|
|
||||||
static Future<void> loadEnvFile() async {
|
static Future<void> loadEnvFile() async {
|
||||||
await dotenv.load(fileName: AppConfig.envFileName);
|
await dotenv.load(fileName: AppConfig.envFileName);
|
||||||
final mapEnvData = Map<String, String>.from(dotenv.env);
|
final mapEnvData = Map<String, String>.from(dotenv.env);
|
||||||
@@ -56,8 +52,7 @@ class AppUtils {
|
|||||||
static TextDirection getCurrentDirection(BuildContext context) => Directionality.maybeOf(context) ?? TextDirection.ltr;
|
static TextDirection getCurrentDirection(BuildContext context) => Directionality.maybeOf(context) ?? TextDirection.ltr;
|
||||||
|
|
||||||
static bool isEmailLocalhost(String email) {
|
static bool isEmailLocalhost(String email) {
|
||||||
final normalized = email.trim();
|
return StringConvert.isEmailLocalhost(email);
|
||||||
return _emailLocalhostRegex.hasMatch(normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void copyEmailAddressToClipboard(BuildContext context, String emailAddress) {
|
static void copyEmailAddressToClipboard(BuildContext context, String emailAddress) {
|
||||||
|
|||||||
Reference in New Issue
Block a user