Implement CSS class whitelisting

This commit is contained in:
Mark Bell
2017-06-16 13:05:51 +01:00
parent 8d6e83a65c
commit 6a07682267
5 changed files with 191 additions and 2 deletions

View File

@@ -162,4 +162,34 @@ namespace Ganss.XSS
/// </value>
public IComment Comment { get; set; }
}
/// <summary>
/// Provides data for the <see cref="HtmlSanitizer.RemovingCssClass"/> event.
/// </summary>
public class RemovingCssClassEventArgs : CancelEventArgs
{
/// <summary>
/// Gets or sets the tag containing the CSS class to be removed.
/// </summary>
/// <value>
/// The tag.
/// </value>
public IElement Tag { get; set; }
/// <summary>
/// Gets or sets the CSS class to be removed.
/// </summary>
/// <value>
/// The CSS class.
/// </value>
public string CssClass { get; set; }
/// <summary>
/// Gets or sets the reason why the CSS class will be removed.
/// </summary>
/// <value>
/// The reason.
/// </value>
public RemoveReason Reason { get; set; }
}
}

View File

@@ -59,8 +59,9 @@ namespace Ganss.XSS
/// <param name="allowedAttributes">The allowed HTML attributes such as "href" and "alt". When <c>null</c>, uses <see cref="DefaultAllowedAttributes"/></param>
/// <param name="uriAttributes">The HTML attributes that can contain a URI such as "href". When <c>null</c>, uses <see cref="DefaultUriAttributes"/></param>
/// <param name="allowedCssProperties">The allowed CSS properties such as "font" and "margin". When <c>null</c>, uses <see cref="DefaultAllowedCssProperties"/></param>
/// <param name="allowedCssClasses">CSS class names which are allowed in the value of a class attribute. When <c>null</c>, any class names are allowed.</param>
public HtmlSanitizer(IEnumerable<string> allowedTags = null, IEnumerable<string> allowedSchemes = null,
IEnumerable<string> allowedAttributes = null, IEnumerable<string> uriAttributes = null, IEnumerable<string> allowedCssProperties = null)
IEnumerable<string> allowedAttributes = null, IEnumerable<string> uriAttributes = null, IEnumerable<string> allowedCssProperties = null, IEnumerable<string> allowedCssClasses = null)
{
AllowedTags = new HashSet<string>(allowedTags ?? DefaultAllowedTags, StringComparer.OrdinalIgnoreCase);
AllowedSchemes = new HashSet<string>(allowedSchemes ?? DefaultAllowedSchemes, StringComparer.OrdinalIgnoreCase);
@@ -68,6 +69,7 @@ namespace Ganss.XSS
UriAttributes = new HashSet<string>(uriAttributes ?? DefaultUriAttributes, StringComparer.OrdinalIgnoreCase);
AllowedCssProperties = new HashSet<string>(allowedCssProperties ?? DefaultAllowedCssProperties, StringComparer.OrdinalIgnoreCase);
AllowedAtRules = new HashSet<CssRuleType>(DefaultAllowedAtRules);
AllowedCssClasses = allowedCssClasses != null ? new HashSet<string>(allowedCssClasses) : null;
}
/// <summary>
@@ -284,6 +286,14 @@ namespace Ganss.XSS
set { _disallowedCssPropertyValue = value; }
}
/// <summary>
/// Gets or sets the allowed CSS classes.
/// </summary>
/// <value>
/// The allowed CSS classes.
/// </value>
public ISet<string> AllowedCssClasses { get; private set; }
/// <summary>
/// Occurs for every node after sanitizing.
/// </summary>
@@ -308,6 +318,10 @@ namespace Ganss.XSS
/// Occurs before a comment is removed.
/// </summary>
public event EventHandler<RemovingCommentEventArgs> RemovingComment;
/// <summary>
/// Occurs before a CSS class is removed.
/// </summary>
public event EventHandler<RemovingCssClassEventArgs> RemovingCssClass;
/// <summary>
/// Raises the <see cref="E:PostProcessNode" /> event.
@@ -368,6 +382,15 @@ namespace Ganss.XSS
/// </summary>
public static readonly Regex DefaultDisallowedCssPropertyValue = new Regex(@"[<>]", RegexOptions.Compiled);
/// <summary>
/// Raises the <see cref="E:RemovingCSSClass" /> event.
/// </summary>
/// <param name="e">The <see cref="RemovingCSSClass"/> instance containing the event data.</param>
protected virtual void OnRemovingCssClass(RemovingCssClassEventArgs e)
{
RemovingCssClass?.Invoke(this, e);
}
/// <summary>
/// Return all nested subnodes of a node.
/// </summary>
@@ -489,15 +512,35 @@ namespace Ganss.XSS
// sanitize the style attribute
SanitizeStyle(tag, baseUrl);
var checkClasses = AllowedCssClasses != null;
var allowedTags = AllowedCssClasses?.ToArray() ?? new string[0];
// sanitize the value of the attributes
foreach (var attribute in tag.Attributes.ToList())
{
// The '& Javascript include' is a possible method to execute Javascript and can lead to XSS.
// (see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#.26_JavaScript_includes)
if (attribute.Value.Contains("&{"))
{
RemoveAttribute(tag, attribute, RemoveReason.NotAllowedValue);
}
else
tag.SetAttribute(attribute.Name, attribute.Value);
{
if (checkClasses && attribute.Name == "class")
{
var removedClasses = tag.ClassList.Except(allowedTags).ToArray();
foreach(var removedClass in removedClasses)
RemoveCssClass(tag, removedClass, RemoveReason.NotAllowedCssClass);
if (!tag.ClassList.Any())
RemoveAttribute(tag, attribute, RemoveReason.ClassAttributeEmpty);
}
else
{
tag.SetAttribute(attribute.Name, attribute.Value);
}
}
}
}
@@ -834,5 +877,18 @@ namespace Ganss.XSS
OnRemovingAtRule(e);
return !e.Cancel;
}
/// <summary>
/// Removes a CSS class from a class attribute.
/// </summary>
/// <param name="tag">Tag the style belongs to</param>
/// <param name="rule">Rule to be removed</param>
/// <returns>true, if the rule can be removed; false, otherwise.</returns>
private void RemoveCssClass(IElement tag, string cssClass, RemoveReason reason)
{
var e = new RemovingCssClassEventArgs { Tag = tag, CssClass = cssClass, Reason = reason };
OnRemovingCssClass(e);
if (!e.Cancel) tag.ClassList.Remove(cssClass);
}
}
}

View File

@@ -64,6 +64,13 @@ namespace Ganss.XSS
/// </value>
Regex DisallowCssPropertyValue { get; set; }
/// Gets or sets the allowed CSS classes.
/// </summary>
/// <value>
/// The allowed CSS classes.
/// </value>
ISet<string> AllowedCssClasses { get; }
/// <summary>
/// Occurs for every node after sanitizing.
/// </summary>
@@ -84,6 +91,11 @@ namespace Ganss.XSS
/// </summary>
event EventHandler<RemovingStyleEventArgs> RemovingStyle;
/// <summary>
/// Occurs before a CSS class is removed.
/// </summary>
event EventHandler<RemovingCssClassEventArgs> RemovingCssClass;
/// <summary>
/// Sanitizes the specified HTML.
/// </summary>

View File

@@ -25,5 +25,13 @@
/// Value is not allowed or harmful
/// </summary>
NotAllowedValue,
/// <summary>
/// CSS Class is not allowed
/// </summary>
NotAllowedCssClass,
/// <summary>
/// The class attribute is empty
/// </summary>
ClassAttributeEmpty
}
}

View File

@@ -2499,6 +2499,44 @@ rl(javascript:alert(""foo""))'>";
Assert.Equal(RemoveReason.NotAllowedTag, actual);
}
[Fact]
public void RemoveEventForNotAllowedCssClass()
{
RemoveReason? reason = null;
string removedClass = null;
var s = new HtmlSanitizer(allowedAttributes: new[] { "class" }, allowedCssClasses: new[] { "good" });
s.RemovingCssClass += (sender, args) =>
{
reason = args.Reason;
removedClass = args.CssClass;
};
s.Sanitize(@"<div class=""good bad"">Test</div>");
Assert.Equal("bad", removedClass);
Assert.Equal(RemoveReason.NotAllowedCssClass, reason);
}
[Fact]
public void RemoveEventForEmptyClassAttributeAfterClassRemoval()
{
RemoveReason? reason = null;
string attributeName = null;
var s = new HtmlSanitizer(allowedAttributes: new[] { "class" }, allowedCssClasses: new[] { "other" });
s.RemovingAttribute += (sender, args) =>
{
attributeName = args.Attribute.Name;
reason = args.Reason;
};
s.Sanitize(@"<div class=""good bad"">Test</div>");
Assert.Equal("class", attributeName);
Assert.Equal(RemoveReason.ClassAttributeEmpty, reason);
}
[Fact]
public void DocumentTest()
{
@@ -2844,9 +2882,31 @@ zqy1QY1kkPOuMvKWvvmFIwClI2393jVVcp91eda4+J+fIYDbfJa7RY5YcNrZhTuV//9k="">
Assert.Equal(0, failures);
}
}
[Fact]
public void AllowAllClassesByDefaultTest()
{
var sanitizer = new HtmlSanitizer(allowedAttributes: new[] { "class" });
var html = @"<div class=""good bad"">Test</div>";
var actual = sanitizer.Sanitize(html);
Assert.Equal(@"<div class=""good bad"">Test</div>", actual);
}
[Fact]
public void AllowClassesTest()
{
var sanitizer = new HtmlSanitizer(allowedAttributes: new[] { "class" }, allowedCssClasses: new[] { "good" });
var html = @"<div class=""good bad"">Test</div>";
var actual = sanitizer.Sanitize(html);
Assert.Equal(@"<div class=""good"">Test</div>", actual);
}
[Fact]
public void AllowClassesUsingEventTest()
{
var sanitizer = new HtmlSanitizer();
sanitizer.RemovingAttribute += (s, e) =>
@@ -2864,6 +2924,29 @@ zqy1QY1kkPOuMvKWvvmFIwClI2393jVVcp91eda4+J+fIYDbfJa7RY5YcNrZhTuV//9k="">
Assert.Equal(@"<div class=""good"">Test</div>", actual);
}
[Fact]
public void RemoveClassAttributeIfNoAllowedClassesTest()
{
// Empty array for allowed classes = no classes allowed
var sanitizer = new HtmlSanitizer(allowedAttributes: new[] { "class" }, allowedCssClasses: new string[0]);
var html = @"<div class=""good bad"">Test</div>";
var actual = sanitizer.Sanitize(html);
Assert.Equal(@"<div>Test</div>", actual);
}
[Fact]
public void RemoveClassAttributeIfEmptyTest()
{
var sanitizer = new HtmlSanitizer(allowedAttributes: new[] { "class" }, allowedCssClasses: new[] { "other" });
var html = @"<div class=""good bad"">Test</div>";
var actual = sanitizer.Sanitize(html);
Assert.Equal(@"<div>Test</div>", actual);
}
[Fact]
public void TextTest()
{