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"'));
});
});
}