Skip to content

Commit

Permalink
Sanitize Html message content
Browse files Browse the repository at this point in the history
  • Loading branch information
dab246 authored and hoangdat committed Aug 25, 2021
1 parent 842072e commit 4afb309
Show file tree
Hide file tree
Showing 10 changed files with 431 additions and 53 deletions.
4 changes: 4 additions & 0 deletions core/lib/core.dart
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
15 changes: 15 additions & 0 deletions core/lib/presentation/extensions/html_extension.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:');
}
42 changes: 42 additions & 0 deletions core/lib/presentation/validator/html_message_purifier.dart
@@ -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);
}
}
274 changes: 274 additions & 0 deletions core/lib/presentation/validator/sane_html_validator.dart
@@ -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 changes: 2 additions & 0 deletions core/pubspec.yaml
Expand Up @@ -42,6 +42,8 @@ dependencies:

built_collection: 5.1.0

html: 0.15.0

dev_dependencies:
flutter_test:
sdk: flutter
Expand Down

0 comments on commit 4afb309

Please sign in to comment.