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:alert(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:
- Reject invalid encodings. If the string has an invalid byte sequence for its encoding, or cannot be transcoded to UTF-8, return
false. - 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.
- Strip control characters matching
/[`\u0000- \u007f\u0080-ā]/. This removes NUL, tabs, newlines, spaces, the backtick, DEL, and a range of Latin-1 characters. - 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. - Strip control characters again, since decoding can produce new ones.
- Remove
	and
, whichCGI.unescapeHTMLdoes not decode but browsers strip from URIs. (Wrong-cased forms such as&tab;are not decoded by browsers either, and are left in place.) - Replace
:with:. - Lower-case the string.
- 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 inALLOWED_PROTOCOLS. If it isdata, the media type must also pass the check below. - 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:
- Skip non-properties. Anything Crass does not parse as a property declaration (comments, garbage) is dropped.
- 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 asbackground-image:url(...). - Check the property name (lower-cased). It must be in
ALLOWED_CSS_PROPERTIESorALLOWED_SVG_PROPERTIES, or the part before its first-must be one of the shorthand familiesbackground,border,margin,padding. Somargin-left,padding-top, andborder-radiusare allowed even though they are not listed individually. -
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_FUNCTIONSIdentifier (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 unitAnything else (numbers, dimensions, #hex,,,/, ...)Always, as written -
Drop empty declarations. If no tokens survive, the declaration is removed.
- Keep
!importantif it was present. - 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: whateveris kept asdisplay:whatever;. That is harmless for security (the property list is the safety boundary), but it meansscrub_cssis not a CSS validator.Note: If every declaration is rejected,
scrub_cssreturns an empty string, and the sanitizer then removes the now-emptystyleattribute 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