Sanitize Html message content

This commit is contained in:
dab246
2021-08-24 13:41:21 +07:00
committed by Dat H. Pham
parent 842072e0d6
commit 4afb309ad4
10 changed files with 431 additions and 53 deletions
+4
View File
@@ -6,6 +6,7 @@ export 'presentation/extensions/url_extension.dart';
export 'presentation/extensions/capitalize_extension.dart';
export 'presentation/extensions/list_extensions.dart';
export 'domain/extensions/datetime_extension.dart';
export 'presentation/extensions/html_extension.dart';
// Utils
export 'presentation/utils/theme_utils.dart';
@@ -44,3 +45,6 @@ export 'data/network/dio_client.dart';
export 'presentation/state/success.dart';
export 'presentation/state/failure.dart';
export 'presentation/state/app_state.dart';
// Validator
export 'presentation/validator/html_message_purifier.dart';
@@ -0,0 +1,15 @@
extension HtmlExtension on String {
String removeFontSizeZeroPixel() => replaceAll(RegExp('font-size:0px;'), '');
String removeHeightZeroPixel() => replaceAll(RegExp('height:0px;'), '');
String removeWidthZeroPixel() => replaceAll(RegExp('width:0px;'), '');
String removeMaxWidthZeroPixel() => replaceAll(RegExp('max-width:0px;'), '');
String removeMaxHeightZeroPixel() => replaceAll(RegExp('max-height:0px;'), '');
String changeStyleBackground() => replaceAll(RegExp('background:'), 'background-color:');
}
@@ -0,0 +1,42 @@
import 'sane_html_validator.dart' show SaneHtmlValidator;
import 'package:core/core.dart';
class HtmlMessagePurifier {
String purifyHtmlMessage(
String html,
{
Set<String>? allowElementIds,
Set<String>? allowClassNames,
Set<String>? allowAttributes
}
) {
return sanitizeHtml(
html,
allowElementId: (elementId) => allowElementIds != null ? allowElementIds.contains(elementId) : false,
allowClassName: (className) => allowClassNames != null ? allowClassNames.contains(className) : false,
allowAttributes: (attribute) => allowAttributes != null ? allowAttributes.contains(attribute) : false)
.changeStyleBackground()
.removeFontSizeZeroPixel()
.removeMaxHeightZeroPixel()
.removeMaxWidthZeroPixel()
.removeHeightZeroPixel()
.removeWidthZeroPixel();
}
String sanitizeHtml(
String htmlString,
{
bool Function(String)? allowElementId,
bool Function(String)? allowClassName,
bool Function(String)? allowAttributes,
Iterable<String>? Function(String)? addLinkRel
}
) {
return SaneHtmlValidator(
allowElementId: allowElementId,
allowClassName: allowClassName,
allowAttributes: allowAttributes,
addLinkRel: addLinkRel,
).sanitize(htmlString);
}
}
@@ -0,0 +1,274 @@
import 'package:html/dom.dart';
import 'package:html/parser.dart' as html_parser;
final _allowedElements = <String>{
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'H7',
'H8',
'BR',
'B',
'I',
'STRONG',
'EM',
'A',
'PRE',
'CODE',
'IMG',
'TT',
'DIV',
'INS',
'DEL',
'SUP',
'SUB',
'P',
'OL',
'UL',
'TABLE',
'THEAD',
'TBODY',
'TFOOT',
'BLOCKQUOTE',
'DL',
'DT',
'DD',
'KBD',
'Q',
'SAMP',
'VAR',
'HR',
'RUBY',
'RT',
'RP',
'LI',
'TR',
'TD',
'TH',
'S',
'STRIKE',
'SUMMARY',
'DETAILS',
'CAPTION',
'FIGURE',
'FIGCAPTION',
'ABBR',
'BDO',
'CITE',
'DFN',
'MARK',
'SMALL',
'SPAN',
'TIME',
'WBR',
};
final _alwaysAllowedAttributes = <String>{
'abbr',
'accept',
'accept-charset',
'accesskey',
'action',
'align',
'alt',
'aria-describedby',
'aria-hidden',
'aria-label',
'aria-labelledby',
'axis',
'border',
'cellpadding',
'cellspacing',
'char',
'charoff',
'charset',
'checked',
'clear',
'cols',
'colspan',
'color',
'compact',
'coords',
'datetime',
'dir',
'disabled',
'enctype',
'for',
'frame',
'headers',
'height',
'hreflang',
'hspace',
'ismap',
'label',
'lang',
'maxlength',
'media',
'method',
'multiple',
'name',
'nohref',
'noshade',
'nowrap',
'open',
'prompt',
'readonly',
'rel',
'rev',
'rows',
'rowspan',
'rules',
'scope',
'selected',
'shape',
'size',
'span',
'start',
'summary',
'tabindex',
'target',
'title',
'type',
'usemap',
'valign',
'value',
'vspace',
'width',
'itemprop',
};
bool _alwaysAllowed(String _) => true;
bool _validLink(String url) {
try {
final uri = Uri.parse(url);
return uri.isScheme('https') ||
uri.isScheme('http') ||
uri.isScheme('mailto') ||
!uri.hasScheme;
} on FormatException {
return false;
}
}
bool _validUrl(String url) {
try {
final uri = Uri.parse(url);
return uri.isScheme('https') || uri.isScheme('http') || !uri.hasScheme;
} on FormatException {
return false;
}
}
final _citeAttributeValidator = <String, bool Function(String)>{
'cite': _validUrl,
};
final _elementAttributeValidators =
<String, Map<String, bool Function(String)>>{
'A': {
'href': _validLink,
},
'IMG': {
'src': _alwaysAllowed,
'longdesc': _validUrl,
},
'DIV': {
'itemscope': _alwaysAllowed,
'itemtype': _alwaysAllowed,
},
'BLOCKQUOTE': _citeAttributeValidator,
'DEL': _citeAttributeValidator,
'INS': _citeAttributeValidator,
'Q': _citeAttributeValidator,
};
/// An implementation of [html.NodeValidator] that only allows sane HTML tags
/// and attributes protecting against XSS.
///
/// Modeled after the [rules employed by Github][1] when sanitizing GFM (Github
/// Flavored Markdown). Notably this excludes CSS styles and other tags that
/// easily interferes with the rest of the page.
///
/// [1]: https://github.com/jch/html-pipeline/blob/master/lib/html/pipeline/sanitization_filter.rb
class SaneHtmlValidator {
final bool Function(String)? allowElementId;
final bool Function(String)? allowClassName;
final bool Function(String)? allowAttributes;
final Iterable<String>? Function(String)? addLinkRel;
SaneHtmlValidator({
required this.allowElementId,
required this.allowClassName,
required this.allowAttributes,
required this.addLinkRel,
});
String sanitize(String htmlString) {
final root = html_parser.parseFragment(htmlString);
_sanitize(root);
return root.outerHtml;
}
void _sanitize(Node node) {
if (node is Element) {
final tagName = node.localName!.toUpperCase();
if (!_allowedElements.contains(tagName)) {
node.remove();
return;
}
node.attributes.removeWhere((k, v) {
final attrName = k.toString();
if (attrName == 'id') {
return allowElementId == null || !allowElementId!(v);
}
if (attrName == 'class') {
if (allowClassName == null) return true;
node.classes.removeWhere((cn) => !allowClassName!(cn));
return node.classes.isEmpty;
}
return !_isAttributeAllowed(tagName, attrName, v);
});
if (tagName == 'A') {
final href = node.attributes['href'];
if (href != null && addLinkRel != null) {
final rels = addLinkRel!(href);
if (rels != null && rels.isNotEmpty) {
node.attributes['rel'] = rels.join(' ');
}
}
}
}
if (node.hasChildNodes()) {
// doing it in reverse order, because we could otherwise skip one, when a
// node is removed...
for (var i = node.nodes.length - 1; i >= 0; i--) {
_sanitize(node.nodes[i]);
}
}
}
bool _isAttributeAllowed(String tagName, String attrName, String value) {
if (_alwaysAllowedAttributes.contains(attrName)) return true;
if (allowAttributes != null && allowAttributes!(attrName)) return true;
// Special validators for special attributes on special tags (href/src/cite)
final attributeValidators = _elementAttributeValidators[tagName];
if (attributeValidators == null) {
return false;
}
final validator = attributeValidators[attrName];
if (validator == null) {
return false;
}
return validator(value);
}
}
+2
View File
@@ -42,6 +42,8 @@ dependencies:
built_collection: 5.1.0
html: 0.15.0
dev_dependencies:
flutter_test:
sdk: flutter
+80
View File
@@ -0,0 +1,80 @@
import 'package:core/presentation/validator/sane_html_validator.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('sane_html_validator test', () {
test('sanitize should return clean html not contain style attribute', () async {
final saneHtmlValidator = SaneHtmlValidator(
allowAttributes: null,
allowClassName: null,
allowElementId: null,
addLinkRel: null
);
final cleanHtml = saneHtmlValidator.sanitize('<a style="background-color: #FF0000" class="className" id="idElement" href="javascript:alert()">Hello</a>');
expect(cleanHtml, isNot(contains('style')));
expect(cleanHtml, isNot(contains('className')));
expect(cleanHtml, isNot(contains('idElement')));
expect(cleanHtml, isNot(contains('href')));
});
test('sanitize should return clean html contain style attribute', () async {
final saneHtmlValidator = SaneHtmlValidator(
allowAttributes: (attribute) => attribute == 'style',
allowClassName: null,
allowElementId: null,
addLinkRel: null
);
final cleanHtml = saneHtmlValidator.sanitize('<a style="background-color: #FF0000">Hello</a>');
expect(cleanHtml, contains('style'));
});
test('sanitize should return clean html contain test class name', () async {
final saneHtmlValidator = SaneHtmlValidator(
allowAttributes: null,
allowClassName: (className) => className == 'test',
allowElementId: null,
addLinkRel: null
);
final cleanHtml = saneHtmlValidator.sanitize('<span class="test">Hello</span');
expect(cleanHtml, contains('test'));
});
test('sanitize should return clean html contain test element id', () async {
final saneHtmlValidator = SaneHtmlValidator(
allowAttributes: null,
allowClassName: null,
allowElementId: (elementId) => elementId == 'test',
addLinkRel: null
);
final cleanHtml = saneHtmlValidator.sanitize('<span id="test">Hello</span');
expect(cleanHtml, contains('test'));
});
test('sanitize should return clean html contain rel="nofollow" in tag', () async {
final saneHtmlValidator = SaneHtmlValidator(
allowAttributes: null,
allowClassName: null,
allowElementId: null,
addLinkRel: (href) => href == 'http://example.com/test.jpg' ? ['nofollow'] : null,
);
final cleanHtml = saneHtmlValidator.sanitize('<a href="http://example.com/test.jpg">Hello</a>');
expect(cleanHtml, contains('rel="nofollow"'));
});
});
}
@@ -18,6 +18,7 @@ class EmailView extends GetWidget {
final emailController = Get.find<EmailController>();
final responsiveUtils = Get.find<ResponsiveUtils>();
final imagePaths = Get.find<ImagePaths>();
final htmlMessagePurifier = Get.find<HtmlMessagePurifier>();
@override
Widget build(BuildContext context) {
@@ -192,8 +193,8 @@ class EmailView extends GetWidget {
itemCount: messageContents.length,
itemBuilder: (context, index) =>
MessageContentTileBuilder(
htmlMessagePurifier,
messageContents[index],
imagePaths,
attachmentsInline,
emailController.mailboxDashBoardController.sessionCurrent,
emailController.mailboxDashBoardController.accountId.value)
@@ -2,27 +2,24 @@
import 'package:core/core.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:jmap_dart_client/jmap/account_id.dart';
import 'package:jmap_dart_client/jmap/core/session/session.dart';
import 'package:photo_view/photo_view.dart';
import 'package:tmail_ui_user/features/email/presentation/model/attachment_file.dart';
import 'package:tmail_ui_user/features/email/presentation/model/message_content.dart';
import 'package:tmail_ui_user/features/email/presentation/model/text_format.dart';
import 'package:sanitize_html/sanitize_html.dart' show sanitizeHtml;
class MessageContentTileBuilder {
final MessageContent _messageContent;
final ImagePaths _imagePaths;
final List<AttachmentFile> _attachmentInlines;
final Session? _session;
final AccountId? _accountId;
final HtmlMessagePurifier _htmlMessagePurifier;
MessageContentTileBuilder(
this._htmlMessagePurifier,
this._messageContent,
this._imagePaths,
this._attachmentInlines,
this._session,
this._accountId,
@@ -39,10 +36,9 @@ class MessageContentTileBuilder {
child: _messageContent.textFormat == TextFormat.PLAIN
? Text('${_messageContent.content}', style: TextStyle(fontSize: 12, color: AppColor.mailboxTextColor))
: HtmlWidget(
cleanHtmlMessage(_messageContent.content),
getHtmlMessageText(),
textStyle: TextStyle(fontSize: 12, color: AppColor.mailboxTextColor),
buildAsync: true,
factoryBuilder: () => _PopupPhotoViewWidgetFactory(_imagePaths),
buildAsyncBuilder: (_, snapshot) => snapshot.data ?? SizedBox(
height: 30,
width: 30,
@@ -57,47 +53,11 @@ class MessageContentTileBuilder {
final message = (_attachmentInlines.isNotEmpty && _session != null && _accountId != null && _messageContent.hasImageInlineWithCid())
? '${_messageContent.getContentHasInlineAttachment(_session!, _accountId!, _attachmentInlines)}'
: '${_messageContent.content}';
return message;
}
String cleanHtmlMessage(String message) => hasTagTable(getHtmlMessageText()) ? sanitizeHtml(getHtmlMessageText()) : message;
final trustAsHtml = _htmlMessagePurifier.purifyHtmlMessage(
message,
allowAttributes: {'style', 'input', 'form'});
bool hasTagTable(String html) => html.toLowerCase().contains('</table>');
}
class _PopupPhotoViewWidgetFactory extends WidgetFactory {
final ImagePaths _imagePaths;
_PopupPhotoViewWidgetFactory(this._imagePaths);
@override
Widget? buildImageWidget(BuildMetadata meta, {String? semanticLabel, required String url}) {
final built = super.buildImageWidget(meta, semanticLabel: semanticLabel, url: url);
if (built is Image) {
return Builder(
builder: (context) => GestureDetector(
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => Scaffold(
body: Stack(
children: [
PhotoView(
heroAttributes: PhotoViewHeroAttributes(tag: url),
imageProvider: built.image),
Padding(
padding: EdgeInsets.only(left: 16, top: 35),
child: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: SvgPicture.asset(_imagePaths.icCloseMailbox, width: 30, height: 30, fit: BoxFit.fill)))
],
),
))),
child: Hero(tag: url, child: built),
),
);
}
return built;
return trustAsHtml;
}
}
@@ -10,6 +10,7 @@ class CoreBindings extends Bindings {
_bindingAppImagePaths();
_bindingResponsiveManager();
_bindingKeyboardManager();
_bindingValiadtor();
}
void _bindingAppImagePaths() {
@@ -27,4 +28,8 @@ class CoreBindings extends Bindings {
void _bindingKeyboardManager() {
Get.put(KeyboardUtils());
}
void _bindingValiadtor() {
Get.put(HtmlMessagePurifier());
}
}
-5
View File
@@ -89,11 +89,6 @@ dependencies:
# flutter_widget_from_html
flutter_widget_from_html: 0.6.2
# photo_view
photo_view: 0.12.0
sanitize_html: 2.0.0
dev_dependencies:
flutter_test:
sdk: flutter