TF-4224 Reduce ReDoS vulnerability
This commit is contained in:
@@ -27,7 +27,7 @@ abstract class DomTransformer {
|
|||||||
|
|
||||||
Tuple2<String, String>? findImageUrlFromStyleTag(String style) {
|
Tuple2<String, String>? findImageUrlFromStyleTag(String style) {
|
||||||
try {
|
try {
|
||||||
final regExp = RegExp(r'background-image:\s*url\(([^)]+)\).*?');
|
final regExp = RegExp(r'''\bbackground-image\s*:\s*url\(\s*['"]?([^' ")]+)['"]?\s*\)''');
|
||||||
final match = regExp.firstMatch(style);
|
final match = regExp.firstMatch(style);
|
||||||
if (match == null) {
|
if (match == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+3
-2
@@ -15,8 +15,9 @@ class NormalizeLineHeightInStyleTransformer extends DomTransformer {
|
|||||||
Map<String, String>? mapUrlDownloadCID,
|
Map<String, String>? mapUrlDownloadCID,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
|
final alternation = _removableLineHeights.map(RegExp.escape).join('|');
|
||||||
final pattern = RegExp(
|
final pattern = RegExp(
|
||||||
r'line-height\s*:\s*(?:' + _removableLineHeights.join('|') + r')\s*;?',
|
r'line-height\s*:\s*(?:' + alternation + r')\s*;?',
|
||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ class NormalizeLineHeightInStyleTransformer extends DomTransformer {
|
|||||||
var updatedStyle = originalStyle.replaceAll(pattern, '').trim();
|
var updatedStyle = originalStyle.replaceAll(pattern, '').trim();
|
||||||
|
|
||||||
// Remove extra spaces (>=2 spaces → 1 space)
|
// Remove extra spaces (>=2 spaces → 1 space)
|
||||||
updatedStyle = updatedStyle.replaceAll(RegExp(r'\s{2,}'), ' ');
|
updatedStyle = updatedStyle.replaceAll(RegExp(r' {2,}'), ' ');
|
||||||
|
|
||||||
if (updatedStyle != originalStyle) {
|
if (updatedStyle != originalStyle) {
|
||||||
if (updatedStyle.isEmpty) {
|
if (updatedStyle.isEmpty) {
|
||||||
|
|||||||
@@ -700,8 +700,8 @@ class HtmlUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String addQuoteToggle(String htmlString) {
|
static String addQuoteToggle(String htmlString) {
|
||||||
final likelyHtml = htmlString.contains(RegExp(r'<[a-zA-Z][^>]*>')) && // Contains a start tag
|
final likelyHtml = htmlString.contains(RegExp(r'<[a-zA-Z][^>\s]*[^>]*>')) && // Contains a start tag
|
||||||
htmlString.contains(RegExp(r'</[a-zA-Z][^>]*>')); // Contains an end tag
|
htmlString.contains(RegExp(r'</[a-zA-Z][^>]{0,128}>')); // Contains an end tag
|
||||||
|
|
||||||
if (!likelyHtml) {
|
if (!likelyHtml) {
|
||||||
return htmlString; // Not likely HTML, return original
|
return htmlString; // Not likely HTML, return original
|
||||||
@@ -850,8 +850,9 @@ class HtmlUtils {
|
|||||||
} while (decoded != cleaned && iterations < maxIterations);
|
} while (decoded != cleaned && iterations < maxIterations);
|
||||||
|
|
||||||
// Delete all remaining HTML tags → replace tag with space to avoid text sticking
|
// Delete all remaining HTML tags → replace tag with space to avoid text sticking
|
||||||
|
final String tagsPipe = validTags.map(RegExp.escape).join('|');
|
||||||
final tagRegex = RegExp(
|
final tagRegex = RegExp(
|
||||||
'</?(${validTags.join('|')})(\\s+[^>]*)?>',
|
'</?(?:$tagsPipe)(?:\\s+[^>]*)?>',
|
||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
);
|
);
|
||||||
cleaned = cleaned.replaceAll(tagRegex, ' ');
|
cleaned = cleaned.replaceAll(tagRegex, ' ');
|
||||||
@@ -870,10 +871,9 @@ class HtmlUtils {
|
|||||||
if (container == null) return htmlString;
|
if (container == null) return htmlString;
|
||||||
|
|
||||||
final urlRegex = RegExp(
|
final urlRegex = RegExp(
|
||||||
r'''(?:(?:https?:\/\/)|(?:ftp:\/\/)|(?:mailto:)|(?:file:\/\/)|(?:www\.))(?!\.)(?!.*\.\.)([^\s<]+[^<.,:;\"\'\)\]\s!?])''',
|
r'''(?:https?://|ftp://|mailto:|file://|www\.)[^\s<.]+(?:\.[^\s<.]+)*(?<![.,:;!?"')\]])''',
|
||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
multiLine: true,
|
multiLine: true,
|
||||||
dotAll: true,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_processNode(container, urlRegex);
|
_processNode(container, urlRegex);
|
||||||
@@ -899,6 +899,9 @@ class HtmlUtils {
|
|||||||
};
|
};
|
||||||
|
|
||||||
static void _processNode(dom.Node node, RegExp urlRegex) {
|
static void _processNode(dom.Node node, RegExp urlRegex) {
|
||||||
|
// 1. Anchored with ^ to fail instantly if the start doesn't match
|
||||||
|
// 2. Grouped for clarity
|
||||||
|
final protocolRegex = RegExp(r'^(?:https?|ftp|mailto|file)');
|
||||||
for (var child in node.nodes.toList()) {
|
for (var child in node.nodes.toList()) {
|
||||||
// Skip if node or parent node is in tag to skip
|
// Skip if node or parent node is in tag to skip
|
||||||
final parentTag = child.parent?.localName;
|
final parentTag = child.parent?.localName;
|
||||||
@@ -929,7 +932,7 @@ class HtmlUtils {
|
|||||||
nodes.add(dom.Text(url));
|
nodes.add(dom.Text(url));
|
||||||
} else {
|
} else {
|
||||||
// Normalize href
|
// Normalize href
|
||||||
final href = url.startsWith(RegExp(r'https?|ftp|mailto|file'))
|
final href = url.startsWith(protocolRegex)
|
||||||
? url
|
? url
|
||||||
: 'https://$url';
|
: 'https://$url';
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import 'package:html/parser.dart';
|
|||||||
import 'package:http_parser/http_parser.dart';
|
import 'package:http_parser/http_parser.dart';
|
||||||
|
|
||||||
class StringConvert {
|
class StringConvert {
|
||||||
static const String emailSeparatorPattern = r'[ ,;]+';
|
// Change the pattern to include newlines directly to avoid the .replaceAll() step
|
||||||
|
static const String emailSeparatorPattern = r'[ ,;\n\r\t]+';
|
||||||
|
|
||||||
static String? writeEmptyToNull(String text) {
|
static String? writeEmptyToNull(String text) {
|
||||||
if (text.isEmpty) return null;
|
if (text.isEmpty) return null;
|
||||||
@@ -31,25 +32,28 @@ class StringConvert {
|
|||||||
|
|
||||||
static List<String> extractStrings(String input, String separatorPattern) {
|
static List<String> extractStrings(String input, String separatorPattern) {
|
||||||
try {
|
try {
|
||||||
// Check if the input is URL encoded
|
// 1. URL Decoding
|
||||||
if (input.contains('%')) {
|
if (input.contains('%')) {
|
||||||
input = Uri.decodeComponent(input); // Decode URL encoding
|
input = Uri.decodeComponent(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Efficient Base64 validation: Check length and minimal regex match
|
// 2. Base64 Check - Using a non-regex check first is faster
|
||||||
if (input.length % 4 == 0 && input.contains(RegExp(r'^[A-Za-z0-9+/=]+$'))) {
|
if (input.length % 4 == 0 && !input.contains(' ')) {
|
||||||
try {
|
// Only run regex if basic length/whitespace checks pass
|
||||||
input = utf8.decode(base64.decode(input)); // Decode Base64 encoding
|
if (RegExp(r'^[A-Za-z0-9+/=]+$').hasMatch(input)) {
|
||||||
} catch (_) {
|
try {
|
||||||
// Ignore if decoding fails
|
input = utf8.decode(base64.decode(input));
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore if decoding fails
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Optimized Split
|
||||||
|
// We use the pattern directly and skip the redundant .replaceAll() and .trim()
|
||||||
final RegExp separator = RegExp(separatorPattern);
|
final RegExp separator = RegExp(separatorPattern);
|
||||||
final listStrings = input
|
final listStrings = input
|
||||||
.replaceAll('\n', ' ')
|
|
||||||
.split(separator)
|
.split(separator)
|
||||||
.map((value) => value.trim())
|
|
||||||
.where((value) => value.isNotEmpty)
|
.where((value) => value.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
log('StringConvert::extractStrings:listStrings = $listStrings');
|
log('StringConvert::extractStrings:listStrings = $listStrings');
|
||||||
@@ -128,16 +132,32 @@ class StringConvert {
|
|||||||
text.split('\n').where((line) => line.trim().isNotEmpty).toList();
|
text.split('\n').where((line) => line.trim().isNotEmpty).toList();
|
||||||
if (lines.length < 2) return false;
|
if (lines.length < 2) return false;
|
||||||
|
|
||||||
// Check for Markdown table
|
// 1. Hardened Markdown Separator Regex
|
||||||
final mdSeparatorRegex = RegExp(r'^\|?(\s*:?-+:?\s*\|)+(\s*:?-+:?\s*)\|?$');
|
// We use [ \t]* instead of \s to be specific and avoid newline issues
|
||||||
final isMarkdown = lines.any((line) => mdSeparatorRegex.hasMatch(line));
|
// We ensure the dash sequence is clearly delimited
|
||||||
|
final mdSeparatorRegex = RegExp(
|
||||||
|
r'^\|?(?:[ \t]*:?-+:?[ \t]*\|)+[ \t]*:?-+:?[ \t]*\|?$',
|
||||||
|
multiLine: false);
|
||||||
|
|
||||||
// Check for ASCII art table (with borders made of +, -, |, /, \, =)
|
// 2. Optimized ASCII Check
|
||||||
final asciiArtRegex = RegExp(r'[\+\-\|/\\=]');
|
// We use hasMatch instead of split().length for better performance
|
||||||
final isAsciiArt = lines.every((line) =>
|
final asciiArtRegex = RegExp(r'[+\-|/\\=]');
|
||||||
line.contains(asciiArtRegex) && line.split(asciiArtRegex).length > 1);
|
|
||||||
log('StringConvert::isTextTable:isMarkdown = $isMarkdown | isAscii = $isAsciiArt');
|
bool isMarkdown = false;
|
||||||
return isMarkdown || isAsciiArt;
|
bool allLinesHaveAscii = true;
|
||||||
|
|
||||||
|
for (final line in lines) {
|
||||||
|
if (!isMarkdown && mdSeparatorRegex.hasMatch(line)) {
|
||||||
|
isMarkdown = true;
|
||||||
|
}
|
||||||
|
if (allLinesHaveAscii && !asciiArtRegex.hasMatch(line)) {
|
||||||
|
allLinesHaveAscii = false;
|
||||||
|
}
|
||||||
|
// Early exit if we found a table but also know it's not ASCII art
|
||||||
|
if (isMarkdown && !allLinesHaveAscii) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isMarkdown || (allLinesHaveAscii && lines.length >= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<NamedAddress> extractNamedAddresses(String input) {
|
static List<NamedAddress> extractNamedAddresses(String input) {
|
||||||
@@ -156,12 +176,11 @@ class StringConvert {
|
|||||||
input = input.replaceAll('\n', ' ');
|
input = input.replaceAll('\n', ' ');
|
||||||
final results = <NamedAddress>[];
|
final results = <NamedAddress>[];
|
||||||
|
|
||||||
final pattern = RegExp(r'''(?:"([^"]+)"|'([^']+)'|)\s*<([^>]+)>''');
|
final pattern = RegExp(r'''(?:(?:"([^"]+)"|'([^']+)')\s*)?<([^>]+)>''');
|
||||||
|
|
||||||
int currentIndex = 0;
|
int currentIndex = 0;
|
||||||
final matches = pattern.allMatches(input).toList();
|
// Use a for-in loop directly on the iterable to save memory
|
||||||
|
for (final match in pattern.allMatches(input)) {
|
||||||
for (final match in matches) {
|
|
||||||
if (match.start > currentIndex) {
|
if (match.start > currentIndex) {
|
||||||
final between = input.substring(currentIndex, match.start);
|
final between = input.substring(currentIndex, match.start);
|
||||||
results.addAll(_splitPlainAddresses(between, emailSeparatorPattern));
|
results.addAll(_splitPlainAddresses(between, emailSeparatorPattern));
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class AppUtils {
|
|||||||
static bool isEmailLocalhost(String email) {
|
static bool isEmailLocalhost(String email) {
|
||||||
final normalized = email.trim();
|
final normalized = email.trim();
|
||||||
return RegExp(
|
return RegExp(
|
||||||
r'^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@localhost$'
|
r'^(?:"[^"\r\n]+"|[^<>()[\]\\.,;:\s@"]+(?:\.[^<>()[\]\\.,;:\s@"]+)*)@localhost$'
|
||||||
).hasMatch(normalized);
|
).hasMatch(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user