XSS (mXSS) via malformed HTML attribute name in Action View tag helpers

HIGH
rails/rails
Commit: 12db70157c45
Affected: 8.1.0 - 8.1.2 (pre-fix); fixed in 8.1.3
2026-04-05 12:38 UTC

Description

The commit adds guards in Action View's tag_options to skip blank HTML attribute names across all three iteration paths (top-level options and inner data/aria hashes). This prevents rendering of attributes with empty names, which could lead to malformed HTML and potentially enable mixed or reflected XSS (mXSS). The change is accompanied by tests that reject blank keys in top-level options, data, and aria hashes, and a test ensuring blank attribute names do not appear in the output. The CVE reference CVE-2026-33168 and GHSA are cited in the commit. In short: this is a genuine security vulnerability fix for XSS via malformed HTML from blank attribute names in Rails' tag helpers; it prevents generation of attributes with empty names that could be exploited by attackers to inject code or bypass sanitizers.

Proof of Concept

Proof-of-concept (PoC) exploit path before the fix (illustrative, not executable against your environment): Assume an attacker provides a set of HTML attributes to a Rails tag helper, including a blank key intended to inject an attacker-controlled payload via an attribute-like string: Ruby (illustrative): # Before the fix, a blank-key attribute could be emitted by tag helpers html_before = ActionView::Helpers::TagHelper.tag("img", { "src" => "/safe.png", "" => "onerror=alert(1)" }) # Potential (malformed) output guess (depends on implementation details): # <img src="/safe.png" ""="onerror=alert(1)" /> In a browser, this could yield a malformed attribute structure that an attacker might craft around, potentially tainting HTML parsing or triggering unintended behaviors in downstream sanitizers or renderers (a form of mXSS). The exact exploit surface depends on how the surrounding HTML/JS sanitization handles empty attribute names, but the risk is that an empty attribute name could bypass checks or alter parsing in surprising ways. After the fix (what you should test in your environment): Ruby (post-fix expectation): html_after = ActionView::Helpers::TagHelper.tag("img", { "src" => "/safe.png", "" => "onerror=alert(1)" }) # Expected sanitized output (blank key skipped): # <img src="/safe.png" /> Demonstration steps you can run locally (conceptual): 1) Build an HTML fragment with a tag helper including a blank key along with a safe attribute, e.g. a data or aria hash as in the tests. 2) Render it via the Rails tag helper. 3) Inspect the resulting HTML to verify the blank-key attribute is not present. 4) Use a DOM parser (like Nokogiri) to assert that no attribute with an empty name exists. 5) Optionally inject a payload in a non-blank attribute (e.g., data-foo) to confirm normal attribute rendering remains unaffected. Ruby snippet you can adapt for testing (conceptual): require 'action_view' include ActionView::Helpers::TagHelper html_before = tag("img", { "src" => "/safe.png", "" => "onerror=alert(1)" }) # Inspect html_before to see if an empty-name attribute appears puts html_before # After fix, the same call should omit the blank key html_after = tag("img", { "src" => "/safe.png", "" => "onerror=alert(1)" }) puts html_after # Parsing with Nokogiri to confirm no empty-name attribute require 'nokogiri' frag = Nokogiri::HTML5::DocumentFragment.parse(html_after) attrs = frag.at_css('img')&.attributes&.keys || [] puts attrs.inspect

Commit Details

Author: Mike Dalessio

Date: 2026-03-16 16:06 UTC

Message:

Skip blank attribute names in Action View tag helpers When a blank string is used as an HTML attribute name in tag helpers, `xml_name_escape` returns an empty string, producing malformed HTML that may be susceptible to mXSS attacks. `tag_options` now skips blank keys in all three iteration paths: the top-level options loop, and (for consistency) the inner data/aria hash loops. [CVE-2026-33168] [GHSA-v55j-83pf-r9cq]

Triage Assessment

Vulnerability Type: XSS

Confidence: HIGH

Reasoning:

The commit adds guards to skip blank HTML attribute names in Action View tag helpers, preventing generation of malformed HTML that could be exploited for mixed or reflected XSS (mXSS). It references CVE-2026-33168 and includes tests for rejecting blank keys in top-level options and in data/aria hashes, indicating a security-focused fix.

Verification Assessment

Vulnerability Type: XSS (mXSS) via malformed HTML attribute name in Action View tag helpers

Confidence: HIGH

Affected Versions: 8.1.0 - 8.1.2 (pre-fix); fixed in 8.1.3

Code Diff

diff --git a/actionview/lib/action_view/helpers/tag_helper.rb b/actionview/lib/action_view/helpers/tag_helper.rb index 5d57eee6aedd6..bbbe4dfb03230 100644 --- a/actionview/lib/action_view/helpers/tag_helper.rb +++ b/actionview/lib/action_view/helpers/tag_helper.rb @@ -237,16 +237,19 @@ def tag_options(options, escape = true) # :nodoc: output = +"" sep = " " options.each_pair do |key, value| + next if key.blank? + type = TAG_TYPES[key] if type == :data && value.is_a?(Hash) value.each_pair do |k, v| - next if v.nil? + next if k.blank? || v.nil? + output << sep output << prefix_tag_option(key, k, v, escape) end elsif type == :aria && value.is_a?(Hash) value.each_pair do |k, v| - next if v.nil? + next if k.blank? || v.nil? case v when Array, Hash diff --git a/actionview/test/template/tag_helper_test.rb b/actionview/test/template/tag_helper_test.rb index 2c13fe101fe3e..1b735620d33e1 100644 --- a/actionview/test/template/tag_helper_test.rb +++ b/actionview/test/template/tag_helper_test.rb @@ -107,6 +107,27 @@ def test_tag_options_accepts_blank_option assert_equal "<p included=\"\" />", tag("p", included: "") end + def test_tag_options_rejects_blank_key + assert_equal "<p />", tag("p", "" => "value") + assert_equal "<p />", tag("p", nil => "value") + assert_equal '<p class="a" />', tag("p", "" => "value", "class" => "a") + assert_equal '<p class="a" />', tag("p", nil => "value", "class" => "a") + end + + def test_tag_options_rejects_blank_data_key + assert_equal "<p />", tag("p", data: { "" => "value" }) + assert_equal "<p />", tag("p", data: { nil => "value" }) + assert_equal '<p data-x="y" />', tag("p", data: { "" => "value", "x" => "y" }) + assert_equal '<p data-x="y" />', tag("p", data: { nil => "value", "x" => "y" }) + end + + def test_tag_options_rejects_blank_aria_key + assert_equal "<p />", tag("p", aria: { "" => "value" }) + assert_equal "<p />", tag("p", aria: { nil => "value" }) + assert_equal '<p aria-x="y" />', tag("p", aria: { "" => "value", "x" => "y" }) + assert_equal '<p aria-x="y" />', tag("p", aria: { nil => "value", "x" => "y" }) + end + def test_tag_builder_options_accepts_blank_option assert_equal "<p included=\"\"></p>", tag.p(included: "") end @@ -205,6 +226,14 @@ def test_tag_with_dangerous_unknown_attribute_name tag("the-name", { COMMON_DANGEROUS_CHARS => "the value" }, false, false) end + def test_tag_with_blank_attribute_name_generates_valid_markup + # https://hackerone.com/reports/3078929 + html = tag("img", "src" => "/nonexistent.png", "" => "/onerror=alert(1)") + fragment = Nokogiri::HTML5::DocumentFragment.parse(html) + attrs = fragment.at_css("img").attribute_nodes.map(&:name) + assert_equal [ "src" ], attrs + end + def test_tag_builder_with_dangerous_unknown_attribute_name escaped_dangerous_chars = "_" * COMMON_DANGEROUS_CHARS.size assert_equal "<the-name #{escaped_dangerous_chars}=\"the value\"></the-name>",
← Back to Alerts View on GitHub →