URI and CSS Sanitization

Two of the most common XSS vectors in HTML are dangerous URIs (href="javascript:...") and CSS that loads remote resources. Loofah handles both inside the attribute-cleaning step of its sanitizers, and exposes the underlying functions so you can use them on plain strings. This page documents Loofah::HTML5::Scrub.allowed_uri? and Loofah::HTML5::Scrub.scrub_css in detail.

allowed_uri?

Loofah::HTML5::Scrub.allowed_uri?(uri_string) # => true or false

Returns true if the string is safe to use as a URI attribute value, false otherwise. It needs no DOM node, so you can validate URLs from JSON payloads, Markdown links, or form fields with exactly the same rules the sanitizers use. (It was extracted from the DOM-based code in version 2.25.0.)

Loofah::HTML5::Scrub.allowed_uri?("https://example.com")        # => true
Loofah::HTML5::Scrub.allowed_uri?("mailto:[email protected]")     # => true
Loofah::HTML5::Scrub.allowed_uri?("/path/to/file")              # => true
Loofah::HTML5::Scrub.allowed_uri?("#anchor")                    # => true
Loofah::HTML5::Scrub.allowed_uri?("javascript:alert(1)")        # => false
Loofah::HTML5::Scrub.allowed_uri?("JavaScript:alert(1)")        # => false
Loofah::HTML5::Scrub.allowed_uri?("java\tscript:alert(1)")      # => false
Loofah::HTML5::Scrub.allowed_uri?("javascript&#58alert(1)")     # => false
Loofah::HTML5::Scrub.allowed_uri?("java	script:alert(1)")   # => false

The algorithm

The check normalizes the string the way a browser would (or more aggressively), then looks for a scheme:

  1. Reject invalid encodings. If the string has an invalid byte sequence for its encoding, or cannot be transcoded to UTF-8, return false.
  2. Transcode to UTF-8. Strings in ISO-8859-1, UTF-16LE, US-ASCII, or binary are converted first, so the same rules apply regardless of input encoding.
  3. Strip control characters matching /[`\u0000- \u007f\u0080-ā]/. This removes NUL, tabs, newlines, spaces, the backtick, DEL, and a range of Latin-1 characters.
  4. Decode HTML character references with CGI.unescapeHTML, then decode numeric references that lack the trailing semicolon (:, :), which browsers still honor. Over-long references (more than 7 decimal or 6 hexadecimal significant digits) are left alone.
  5. Strip control characters again, since decoding can produce new ones.
  6. Remove 	 and 
, which CGI.unescapeHTML does not decode but browsers strip from URIs. (Wrong-cased forms such as &tab; are not decoded by browsers either, and are left in place.)
  7. Replace : with :.
  8. Lower-case the string.
  9. Find a scheme. If the string starts with a scheme ([a-z][a-z0-9+\-.]*) followed by a protocol separator (:, :, :, %3A, or %3A, with optional leading zeros in the numeric forms), the scheme must be in ALLOWED_PROTOCOLS. If it is data, the media type must also pass the check below.
  10. No scheme means relative. Strings without a recognizable scheme (/path, file.html, #anchor, ?q=1) are allowed.

The input string is not modified, even if it is frozen.

Note: "Allowed" only means "not a dangerous scheme". allowed_uri? does not validate that the string is a well-formed URL, and relative URLs are always accepted.

data: URIs

For data: URIs the media type is parsed following RFC 2397 and the WHATWG data: URL rules (since 2.25.2):

URI Result Why
data:image/png;base64,iVBOR... allowed image/png is in the list
data:text/plain,hello allowed
data:,hello allowed Omitted media type means text/plain
data:;base64,aGVsbG8= allowed Omitted media type means text/plain
data:image/png:text/html,x allowed Malformed media type is treated as text/plain
data:text/html;base64,... rejected text/html is not in the list
data:image/svg+xml;base64,... rejected SVG can contain script
data:foo rejected No comma
data:image/xxx rejected No comma

The allowed media types are image/gif, image/jpeg, image/png, text/css, and text/plain. A ;base64 suffix and any ;parameter are ignored when finding the media type.

Inside the sanitizers

During :strip, :prune, :escape, and the attribute cleaning done by Loofah::HTML5::Scrub.scrub_attributes, every attribute in ATTR_VAL_IS_URI is passed through allowed_uri?. If the check fails, the attribute is removed; the element stays.

html = "<a href='jimbo://jim.jim/'>bad</a><a href='http://jim.jim/'>fine</a>"
Loofah.html5_fragment(html).scrub!(:prune).to_s
# => "<a>bad</a><a href=\"http://jim.jim/\">fine</a>"

On CRuby (libxml2), after cleaning, spaces and double quotes in href, action, src, and in name on <a> elements are percent-encoded (%20, %22). This works around libxml2 serializing those attributes without escaping (CVE-2018-8048); see Internals.

scrub_css

Loofah::HTML5::Scrub.scrub_css(style_string) # => String

Sanitizes the contents of a style attribute. The sanitizers call it on every style attribute of allowed elements, and Loofah::Helpers.sanitize_css is a direct wrapper.

Loofah::HTML5::Scrub.scrub_css("display:block;background-image:url(http://example.com/foo.jpg);background-color:blue")
# => "display:block;background-color:blue;"

The algorithm

The style string is tokenized with the Crass CSS parser. For each declaration:

  1. Skip non-properties. Anything Crass does not parse as a property declaration (comments, garbage) is dropped.
  2. Drop declarations containing url(...). If any of the value's tokens is a URL token (or a malformed URL token), the whole declaration is removed. This blocks remote images such as background-image:url(...).
  3. Check the property name (lower-cased). It must be in ALLOWED_CSS_PROPERTIES or ALLOWED_SVG_PROPERTIES, or the part before its first - must be one of the shorthand families background, border, margin, padding. So margin-left, padding-top, and border-radius are allowed even though they are not listed individually.
  4. Filter each token in the value:

    Token Kept when
    Whitespace Always; any run becomes a single space
    Quoted string The quotes are balanced and the content contains no other quote character
    Function Its name (lower-cased) is in ALLOWED_CSS_FUNCTIONS
    Identifier (keyword) For non-shorthand properties: always. For the shorthand families: when it is an allowed keyword or color, or looks like a hex color, rgb(...) fragment, or number with a CSS unit
    Anything else (numbers, dimensions, #hex, ,, /, ...) Always, as written
  5. Drop empty declarations. If no tokens survive, the declaration is removed.

  6. Keep !important if it was present.
  7. Re-serialize as name:value;, with no space after the colon and no space between declarations.

Leading and trailing whitespace in a value is trimmed, and whitespace between tokens is preserved but collapsed (version 2.21.4 stopped inserting spaces that were not there).

Examples

From the project's tests:

Input Output
background: #ABC012 background:#ABC012;
background-color: linear-gradient(transparent 50%, #ffff66 50%); background-color:linear-gradient(transparent 50%, #ffff66 50%);
background-color: haxxor-fun(transparent 50%, #ffff66 50%); (empty)
font-family:'Avenir Next'; font-family:'Avenir Next';
font-family:'AvenirNext"-Regular'; (empty), embedded quote
font: 13px/1.5 Arial, sans-serif; font:13px/1.5 Arial, sans-serif;
font: \n\t 12px \n\t Arial \n\t ; font:12px Arial;
background-color:blurple; (empty), not a color
border-collapse: not-allowed; (empty), not an allowed keyword
border-collapse: collapse; border-collapse:collapse;

Note: Identifier values are only filtered for the shorthand families. For other allowed properties any keyword passes, so display: whatever is kept as display:whatever;. That is harmless for security (the property list is the safety boundary), but it means scrub_css is not a CSS validator.

Note: If every declaration is rejected, scrub_css returns an empty string, and the sanitizer then removes the now-empty style attribute entirely.

Using Both Outside Loofah Documents

Both functions are module methods on Loofah::HTML5::Scrub, available after require "loofah". They work on every platform, including JRuby, because they do not depend on Nokogiri's HTML5 parser.

require "loofah"

def safe_link(url)
  Loofah::HTML5::Scrub.allowed_uri?(url) ? url : "#"
end

def safe_style(style)
  Loofah::HTML5::Scrub.scrub_css(style.to_s)
end

See Also