Jul 22, 2026·9 min read·3 visits
Loofah fails to sanitize 'javascript:' URIs when colons and whitespace are represented by numeric character references without trailing semicolons, enabling Stored XSS.
A critical HTML sanitization bypass vulnerability in the Ruby library Loofah allows unauthenticated remote attackers to execute Stored Cross-Site Scripting (XSS) attacks. By using semicolon-less numeric character references (NCRs) to encode protocol characters, attackers can evade backend URI verification filters while ensuring the malicious protocol is successfully decoded and executed by client-side web browsers.
The Ruby library Loofah is a critical component of the Rails security architecture, designed to orchestrate the parsing, manipulation, and sanitization of HTML and XML documents. It is built as an abstraction layer over Nokogiri, which provides fast XML parsing capabilities by wrapping Libxml2. In many Rails installations, Loofah is called transitively through the rails-html-sanitizer gem to protect applications from user-supplied payloads containing malicious scripts. The primary attack surface includes rich-text inputs, comments, profile bio fields, and any user-managed markdown or HTML blocks.
The vulnerability designated as GHSA-5QHF-9PHG-95M2 identifies a severe sanitization bypass within the allowed_uri? method of the Loofah::HTML5::Scrub module. This helper method is responsible for validating HTML attributes that store URIs, such as the href attribute of an anchor tag or the src attribute of an iframe. The function assesses whether the URI belongs to an allowed set of protocols, rejecting unsafe active scripting protocols such as javascript: or vbscript:.
By exploiting a parser differential, an attacker can manipulate numeric character references (NCRs) to bypass Loofah's scheme detection entirely. Specifically, when an attacker supplies a URI with a scheme separator or control characters represented as NCRs without trailing semicolons, the backend engine fails to decode them. Consequently, the backend treats the URI as a harmless relative link and allows it to pass. When modern, specification-compliant client browsers render the output, they aggressively decode the semicolon-less entities, reconstruct the malicious URI, and execute the active script context. This leads to Stored Cross-Site Scripting (XSS) within the application's domain.
The underlying security flaw resides in a structural parser differential between the backend Ruby standard library and the parsing engines built into modern web browsers. When evaluating attribute values, Loofah attempts to decode any encoded HTML entities so that it can evaluate the canonical string representation against its protocol blacklist. To perform this decoding, Loofah historically invoked CGI.unescapeHTML on the target attribute string prior to checking it against regex filters.
However, the Ruby standard library decoder CGI.unescapeHTML strictly adheres to classical XML/HTML specifications regarding the structure of Numeric Character References. Under these strict guidelines, any decimal or hexadecimal character reference must terminate with a trailing semicolon. For instance, a colon is written as : (decimal) or : (hexadecimal). If an entity is constructed without this terminating character, such as : or :, CGI.unescapeHTML leaves the reference completely intact, failing to decode it.
Modern web browsers, by contrast, implement the error-tolerant HTML5 tokenization and parsing specifications. Under these specifications, the state machine for consuming numeric character references is highly permissive, resolving references that omit the trailing semicolon to maintain backwards compatibility with legacy web content. When a browser parses the attribute string javascript:alert(1), it consumes the characters :, recognizes it as a decimal representation of the colon character, decodes it, and executes the reconstructed javascript:alert(1) scheme.
This divergence in parsing logic also affects how control and whitespace characters are sanitized. If an attacker embeds a semicolon-less horizontal tab (	), line feed (
), or carriage return (
) within the protocol name (e.g., java	script:), the backend library fails to normalize or remove it. Since the backend regex looks for the literal string javascript:, the tab prevents a match. When the browser receives the markup, it resolves the tab, strips it from the protocol name as dictated by browser-side scheme validation rules, and executes the active payload.
To understand the precise vulnerability mechanics, we must review the execution flow of allowed_uri? in the affected versions. The vulnerable method is structured as follows:
# Affected implementation in Loofah < 2.25.2
def allowed_uri?(uri_string)
# CGI.unescapeHTML fails to decode semicolon-less NCRs, leaving them unmodified
uri_string = CGI.unescapeHTML(uri_string.gsub(CONTROL_CHARACTERS, ""))
uri_string.gsub!(CONTROL_CHARACTERS, "")
uri_string.gsub!(WHITESPACE_CHARACTER_REFERENCES, "")
uri_string.gsub!(":", ":")
# The regex matches against the unescaped string.
# If the colon was encoded as : (no semicolon), this match fails.
return false if uri_string =~ URI_PROTOCOL_REGEX
true
endThe validation fails because CGI.unescapeHTML processes javascript:alert(1) and returns it unchanged. The subsequent regular expression, URI_PROTOCOL_REGEX (defined as /Z[a-z][a-z0-9+\-.]*:/), expects a literal colon separator. Because no colon is present in the analyzed backend string, Loofah determines the URI is safe and allows it.
The official patch resolved this issue by implementing a custom decoding pipeline that intercepts and normalizes semicolon-less character references before standard checks are performed. The updated implementation introduces a dedicated helper method, decode_numeric_character_references, and modifies the validation pipeline:
# Patched implementation in Loofah v2.25.2
# The regex matches decimal or hex NCRs with an optional trailing semicolon
NUMERIC_CHARACTER_REFERENCE = /&#(x[0-9a-f]+|[0-9]+);?/i
# The URI protocol regex is updated to utilize the SafeList's defined protocol separator
URI_PROTOCOL_REGEX = /\A[a-z][a-z0-9+\-.]*#{SafeList::PROTOCOL_SEPARATOR}/
def allowed_uri?(uri_string)
# The input is normalized through the custom decoder after CGI.unescapeHTML
uri_string = decode_numeric_character_references(CGI.unescapeHTML(uri_string.gsub(CONTROL_CHARACTERS, "")))
uri_string.gsub!(CONTROL_CHARACTERS, "")
uri_string.gsub!(WHITESPACE_CHARACTER_REFERENCES, "")
uri_string.gsub!(":", ":")
# Normalized strings containing a decoded colon are now caught and blocked
return false if uri_string =~ URI_PROTOCOL_REGEX
true
endTo prevent secondary security issues, the maintainers engineered the decode_numeric_character_references helper with a critical defense-in-depth guard against Denial of Service (DoS) attacks. Because conversion of a string to an integer in Ruby via .to_i can be CPU-intensive if the string is arbitrarily long, an attacker could attempt to exhaust CPU cycles by supplying an entity with thousands of leading zeros (e.g., �...0058). The patch mitigates this risk by filtering out leading zeros and verifying that the length of the significant digits corresponds to valid Unicode codepoint limits:
def decode_numeric_character_references(string)
string.gsub(NUMERIC_CHARACTER_REFERENCE) do |reference|
digits = ::Regexp.last_match(1)
hexadecimal = digits.start_with?("x", "X")
digits = digits[1..-1] if hexadecimal
significant_digits = digits.sub(/\A0+/, "")
# The maximum Unicode code point is U+10FFFF (6 hex or 7 decimal digits)
# Any larger value is skipped to prevent unnecessary integer conversion
next reference if significant_digits.length > (hexadecimal ? 6 : 7)
codepoint = significant_digits.to_i(hexadecimal ? 16 : 10)
begin
codepoint.chr(Encoding::UTF_8)
rescue RangeError
# If the integer is out of bounds for UTF-8, return the original reference
reference
end
end
endExploitation of GHSA-5QHF-9PHG-95M2 requires the target application to accept user-supplied HTML and sanitize it using a vulnerable version of Loofah prior to rendering it. No authentication or elevated privileges are required beyond the permission to submit input that is processed by the sanitizer.
An attacker can exploit the vulnerability using several payload configurations designed to leverage the browser's tolerant parsing rules. The most direct approach is replacing the protocol separator colon with a semicolon-less NCR:
<!-- Decimal representation of colon (:) without semicolon -->
<a href="javascript:alert(document.cookie)">Click to View Profile</a>
<!-- Hexadecimal representation of colon (:) without semicolon -->
<a href="javascriptΪlert(1)">Click to View Profile</a>When Loofah processes these tags, the backend parser evaluates the attribute value as safe. Once written to the database and rendered on the victim's page, the browser decodes the entity, reconstructs the active URI scheme, and executes the JavaScript payload when the victim interacts with the link.
Beyond the protocol separator, attackers can also obfuscate the protocol name itself by inserting whitespace characters that are represented by semicolon-less NCRs. Because modern browsers strip control characters and whitespace from the scheme of a URI prior to evaluating its protocol, the following payload executes successfully:
<!-- Tab character (	) splits the protocol word -->
<a href="java	script:alert(1)">Verify Account Details</a>
<!-- Line Feed (
) splits the protocol word -->
<a href="java
script:alert(1)">Verify Account Details</a>The backend parser evaluates the scheme as java	script, which does not match the blocklist. Upon rendering, the browser decodes 	 to a raw tab, strips it from the protocol string, and treats the remaining URI as javascript:alert(1).
The security impact of a successful sanitization bypass leading to Stored Cross-Site Scripting (XSS) is severe. Because the malicious payload is stored permanently on the application server, every user who retrieves and renders the compromised resource becomes a victim of the attack. No further active exploitation steps are required from the attacker once the payload is injected.
An attacker exploiting this vulnerability can execute arbitrary JavaScript in the context of the victim's browser session. This enables several high-impact attack scenarios:
localStorage keys, allowing the attacker to steal active session tokens and masquerade as the victim.The severity is amplified in multi-tenant or collaborative platforms where user profiles, comments, or shared workspaces are accessed by multiple corporate accounts, potentially leading to widespread session compromise and lateral movement within the target enterprise.
The primary remediation for this vulnerability is upgrading the loofah dependency to version 2.25.2 or higher. This update introduces the robust decode_numeric_character_references helper, neutralizing the parser differential between the sanitizer and modern browsers. Applications using Bundler should execute bundle update loofah and verify the change in Gemfile.lock.
If upgrading immediately is not feasible, several defensive controls can be implemented to mitigate the risk of exploitation:
Implement a Strict Content Security Policy (CSP): A robust CSP is the most effective secondary defense against XSS. Enforcing policies that restrict script execution to trusted domains and ban inline scripts (unsafe-inline) prevents injected protocols from executing, even if they bypass backend filters:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none';Web Application Firewall (WAF) Rules: Configure edge-protection systems to detect and block incoming request parameters containing semicolon-less character references that decode to control characters or colons. Regular expressions can target structures like �*58 or �*3[aA] when positioned inside potential URL attributes.
Sanitization Auditing: Conduct regular security reviews of all entry points accepting user-supplied markup. Ensure that sanitization is consistently applied on the backend before data storage and that downstream templates do not inadvertently disable escaping.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
Loofah flavorjones | < 2.25.2 | 2.25.2 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-79 / CWE-436 |
| Attack Vector | Network (AV:N) |
| CVSS v3.1 Score | 8.8 (High) |
| EPSS Score | N/A |
| Primary Impact | Stored Cross-Site Scripting (XSS) |
| Exploit Status | Proof-of-Concept Available |
| CISA KEV Status | Not Listed |
The library fails to correctly normalize or escape semicolon-less numeric structures that downstream web browsers parse as active protocol schemes.
A critical second-order code injection vulnerability exists in the migration generation engine of TypeORM. When TypeORM introspects a database schema to automatically generate migration files, it writes schema metadata directly into JavaScript/TypeScript migration files inside ES2015 template literals. Because the generator failed to sanitize template literal string interpolation markers and backslashes, attackers with control over database metadata can execute arbitrary code on the developer environment or within a CI/CD pipeline.
A security logic flaw in Loofah, a Ruby HTML/XML sanitization library, allowed unauthenticated attackers to bypass local-reference restrictions on SVG elements. Prior to version 2.25.2, Loofah only sanitized the legacy namespaced "xlink:href" attribute when enforcing local URI fragments on SVG elements like "<use>". It did not validate or sanitize the plain, non-namespaced "href" attribute introduced in the SVG 2 standard. This structural omission allowed attackers to construct SVG elements referencing external domains, paving the way for arbitrary resource loading and cross-site scripting (XSS).
GHSA-HRXH-6V49-42GF is a critical security advisory addressing two distinct vulnerabilities within gRPC-Go: an HTTP/2 Control Buffer Flooding weakness that allows unauthenticated denial of service, and xDS Role-Based Access Control parser weaknesses that lead to authorization bypasses and application panics.
An unauthenticated memory-leak Denial of Service (DoS) vulnerability exists in the Node.js Adapter for Hono (@hono/node-server) during the WebSocket handshake upgrade process. If a client initiates a WebSocket upgrade but the process is aborted or fails, resources are permanently retained in memory, leading to heap exhaustion.
An issue in rails-html-sanitizer allowed attackers to bypass restrictions on SVG reference elements (such as <use> or <feImage>) when specific custom configurations allowed these tags. Because the sanitizer only restricted the legacy 'xlink:href' attribute to local references, modern browsers supporting plain 'href' attributes according to the SVG 2 specification would load external resources. This could lead to Cross-Site Scripting (XSS) or user tracking.
An input validation flaw in GitPython allows remote attackers to exfiltrate system environment variables and bypass safe-protocol restrictions via crafted repository URLs passed to the clone API.