CVE-2024-32970
Description
Phlex is a framework for building object-oriented views in Ruby. In affected versions there is a potential cross-site scripting (XSS) vulnerability that can be exploited via maliciously crafted user data. Since the last two vulnerabilities https://github.com/phlex-ruby/phlex/security/advisories/GHSA-242p-4v39-2v8g and https://github.com/phlex-ruby/phlex/security/advisories/GHSA-g7xq-xv8c-h98c, we have invested in extensive browser tests. It was these new tests that helped us uncover these issues. As of now the project exercises every possible attack vector the developers can think of — including enumerating every ASCII character, and we run these tests in Chrome, Firefox and Safari. Additionally, we test against a list of 6613 known XSS payloads (see: payloadbox/xss-payload-list). The reason these issues were not detected before is the escapes were working as designed. However, their design didn't take into account just how recklessly permissive browsers are when it comes to executing unsafe JavaScript via HTML attributes. If you render an <a> tag with an href attribute set to a user-provided link, that link could potentially execute JavaScript when clicked by another user. If you splat user-provided attributes when rendering any HTML or SVG tag, malicious event attributes could be included in the output, executing JavaScript when the events are triggered by another user. Patches are available on RubyGems for all minor versions released in the last year. Users are advised to upgrade. Users unable to upgrade should configure a Content Security Policy that does not allow unsafe-inline which would effectively prevent this vulnerability from being exploited. Users who upgrade are also advised to configure a Content Security Policy header that does not allow unsafe-inline.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
phlexRubyGems | < 1.9.3 | 1.9.3 |
phlexRubyGems | >= 1.10.0, < 1.10.2 | 1.10.2 |
Patches
4da8f94342a842ab3a8f8b4b67052ee8f7dceda8f94342a84Browser tests for XSS
8 files changed · +6913 −9
browser_tests.rb+266 −0 added@@ -0,0 +1,266 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "phlex" +require "selenium-webdriver" + +ASCII_CHARS = (0..127).to_set(&:chr) + +class Layout < Phlex::HTML + def view_template(&block) + doctype + html do + head do + meta(charset: "utf-8") + end + + body(&block) + end + end +end + +class JavaScriptLinks < Phlex::HTML + def initialize(char) + @char = char + end + + def view_template + render Layout do + # Standard JavaScript link + a(href: "javascript:alert(1)") { "x" } + + # With capitalization + a(href: "Javascript:alert(1)") { "x" } + + # With extra "javascript:" prefixes + a(href: "javascript:javascript:alert(1)") { "x" } + a(href: "javascript:javascript:javascript:alert(1)") { "x" } + + # With extra "javascript:" prefixes and capitalization + a(href: "javascript:Javascript:alert(1)") { "x" } + a(href: "Javascript:javascript:alert(1)") { "x" } + + a(href: "#{@char}javascript:alert(1)") { "x" } + a(href: "j#{@char}avascript:alert(1)") { "x" } + a(href: "ja#{@char}vascript:alert(1)") { "x" } + a(href: "jav#{@char}ascript:alert(1)") { "x" } + a(href: "java#{@char}script:alert(1)") { "x" } + a(href: "javas#{@char}cript:alert(1)") { "x" } + a(href: "javasc#{@char}ript:alert(1)") { "x" } + a(href: "javascr#{@char}ipt:alert(1)") { "x" } + a(href: "javascr#{@char}ipt:alert(1)") { "x" } + a(href: "javascri#{@char}pt:alert(1)") { "x" } + a(href: "javascrip#{@char}t:alert(1)") { "x" } + a(href: "javascript#{@char}:alert(1)") { "x" } + a(href: "javascript:#{@char}alert(1)") { "x" } + + a(href: "#{@char}#{@char}javascript:alert(1)") { "x" } + a(href: "j#{@char}#{@char}avascript:alert(1)") { "x" } + a(href: "ja#{@char}#{@char}vascript:alert(1)") { "x" } + a(href: "jav#{@char}#{@char}ascript:alert(1)") { "x" } + a(href: "java#{@char}#{@char}script:alert(1)") { "x" } + a(href: "javas#{@char}#{@char}cript:alert(1)") { "x" } + a(href: "javasc#{@char}#{@char}ript:alert(1)") { "x" } + a(href: "javascr#{@char}#{@char}ipt:alert(1)") { "x" } + a(href: "javascr#{@char}#{@char}ipt:alert(1)") { "x" } + a(href: "javascri#{@char}#{@char}pt:alert(1)") { "x" } + a(href: "javascrip#{@char}#{@char}t:alert(1)") { "x" } + a(href: "javascript#{@char}#{@char}:alert(1)") { "x" } + a(href: "javascript:#{@char}#{@char}alert(1)") { "x" } + + a(href: "#{@char}Javascript:alert(1)") { "x" } + a(href: "J#{@char}avascript:alert(1)") { "x" } + a(href: "Ja#{@char}vascript:alert(1)") { "x" } + a(href: "Jav#{@char}ascript:alert(1)") { "x" } + a(href: "Java#{@char}script:alert(1)") { "x" } + a(href: "Javas#{@char}cript:alert(1)") { "x" } + a(href: "Javasc#{@char}ript:alert(1)") { "x" } + a(href: "Javascr#{@char}ipt:alert(1)") { "x" } + a(href: "Javascr#{@char}ipt:alert(1)") { "x" } + a(href: "Javascri#{@char}pt:alert(1)") { "x" } + a(href: "Javascrip#{@char}t:alert(1)") { "x" } + a(href: "Javascript#{@char}:alert(1)") { "x" } + a(href: "Javascript:#{@char}alert(1)") { "x" } + + a(href: "#{@char}#{@char}Javascript:alert(1)") { "x" } + a(href: "J#{@char}#{@char}avascript:alert(1)") { "x" } + a(href: "Ja#{@char}#{@char}vascript:alert(1)") { "x" } + a(href: "Jav#{@char}#{@char}ascript:alert(1)") { "x" } + a(href: "Java#{@char}#{@char}script:alert(1)") { "x" } + a(href: "Javas#{@char}#{@char}cript:alert(1)") { "x" } + a(href: "Javasc#{@char}#{@char}ript:alert(1)") { "x" } + a(href: "Javascr#{@char}#{@char}ipt:alert(1)") { "x" } + a(href: "Javascr#{@char}#{@char}ipt:alert(1)") { "x" } + a(href: "Javascri#{@char}#{@char}pt:alert(1)") { "x" } + a(href: "Javascrip#{@char}#{@char}t:alert(1)") { "x" } + a(href: "Javascript#{@char}#{@char}:alert(1)") { "x" } + a(href: "Javascript:#{@char}#{@char}alert(1)") { "x" } + end + end +end + +class XSSWithStrings < Phlex::HTML + def view_template + render Layout do + File.open("fixtures/xss.txt") do |file| + file.each_line do |line| + div(class: line) { line } + end + end + end + end +end + +class XSSWithSymbols < Phlex::HTML + def view_template + render Layout do + File.open("fixtures/xss.txt") do |file| + file.each_line do |line| + div(class: line.to_sym) { line.to_sym } + end + end + end + end +end + +class OnClick < Phlex::HTML + def initialize(char) + @char = char + end + + def view_template + render Layout do + ignore_warnings { div("#{@char}onclick" => "alert(1)") { "x" } } + ignore_warnings { div("o#{@char}nclick" => "alert(1)") { "x" } } + ignore_warnings { div("on#{@char}click" => "alert(1)") { "x" } } + ignore_warnings { div("onc#{@char}lick" => "alert(1)") { "x" } } + ignore_warnings { div("oncl#{@char}ick" => "alert(1)") { "x" } } + ignore_warnings { div("oncli#{@char}ck" => "alert(1)") { "x" } } + ignore_warnings { div("onclic#{@char}k" => "alert(1)") { "x" } } + ignore_warnings { div("onclick#{@char}" => "alert(1)") { "x" } } + + ignore_warnings { div("#{@char}#{@char}onclick" => "alert(1)") { "x" } } + ignore_warnings { div("o#{@char}#{@char}nclick" => "alert(1)") { "x" } } + ignore_warnings { div("on#{@char}#{@char}click" => "alert(1)") { "x" } } + ignore_warnings { div("onc#{@char}#{@char}lick" => "alert(1)") { "x" } } + ignore_warnings { div("oncl#{@char}#{@char}ick" => "alert(1)") { "x" } } + ignore_warnings { div("oncli#{@char}#{@char}ck" => "alert(1)") { "x" } } + ignore_warnings { div("onclic#{@char}#{@char}k" => "alert(1)") { "x" } } + ignore_warnings { div("onclick#{@char}#{@char}" => "alert(1)") { "x" } } + + ignore_warnings { div("#{@char}onclick": "alert(1)") { "x" } } + ignore_warnings { div("o#{@char}nclick": "alert(1)") { "x" } } + ignore_warnings { div("on#{@char}click": "alert(1)") { "x" } } + ignore_warnings { div("onc#{@char}lick": "alert(1)") { "x" } } + ignore_warnings { div("oncl#{@char}ick": "alert(1)") { "x" } } + ignore_warnings { div("oncli#{@char}ck": "alert(1)") { "x" } } + ignore_warnings { div("onclic#{@char}k": "alert(1)") { "x" } } + ignore_warnings { div("onclick#{@char}": "alert(1)") { "x" } } + + ignore_warnings { div("#{@char}#{@char}onclick": "alert(1)") { "x" } } + ignore_warnings { div("o#{@char}#{@char}nclick": "alert(1)") { "x" } } + ignore_warnings { div("on#{@char}#{@char}click": "alert(1)") { "x" } } + ignore_warnings { div("onc#{@char}#{@char}lick": "alert(1)") { "x" } } + ignore_warnings { div("oncl#{@char}#{@char}ick": "alert(1)") { "x" } } + ignore_warnings { div("oncli#{@char}#{@char}ck": "alert(1)") { "x" } } + ignore_warnings { div("onclic#{@char}#{@char}k": "alert(1)") { "x" } } + ignore_warnings { div("onclick#{@char}#{@char}": "alert(1)") { "x" } } + end + end + + def ignore_warnings + yield + rescue ArgumentError + # ignore + end +end + +class Browser + MUTEX = { safari: Mutex.new, chrome: Mutex.new, firefox: Mutex.new } + + def self.open(driver) + MUTEX.fetch(driver).synchronize do + browser = new(Selenium::WebDriver.for(driver)) + yield(browser) + browser.quit + end + end + + def self.open_each + [:safari, :chrome, :firefox].map do |driver| + Thread.new do + self.open(driver) do |browser| + yield(browser) + end + end + end.each(&:join) + end + + def initialize(driver) + @driver = driver + end + + attr_reader :driver + + def load_string(string) + navigate_to("data:text/html,#{ERB::Util.url_encode(string)}") + end + + def navigate_to(url) + @driver.navigate.to(url) + end + + def execute_script(script) + @driver.execute_script(script) + end + + def each_alert + while (next_alert = alert) + yield(next_alert) + end + end + + def alert + @driver.switch_to.alert + rescue Selenium::WebDriver::Error::NoSuchAlertError + nil + end + + def quit + @driver.quit + end +end + +Browser.open_each do |browser| + ASCII_CHARS.each do |char| + browser.load_string(JavaScriptLinks.new(char).call) + browser.execute_script("document.querySelectorAll('a').forEach(function(a) { a.click(); });") + browser.each_alert do |alert| + unless alert.text == "Safari cannot open the page because the address is invalid." + raise "Failed with #{char.codepoints}" + end + + alert.accept + end + + browser.load_string(OnClick.new(char).call) + browser.execute_script("document.querySelectorAll('div').forEach(function(div) { div.click(); });") + browser.each_alert do + raise "Failed with #{char.codepoints}" + end + end + + browser.load_string(XSSWithStrings.new.call) + browser.execute_script("document.querySelectorAll('div').forEach(function(div) { div.click(); });") + + if browser.alert + raise "Failed with strings" + end + + browser.load_string(XSSWithSymbols.new.call) + browser.execute_script("document.querySelectorAll('div').forEach(function(div) { div.click(); });") + + if browser.alert + raise "Failed with symbols" + end +end
fixtures/xss/LICENSE.txt+21 −0 added@@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 İsmail Taşdelen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.
fixtures/xss.txt+6613 −0 addedGemfile+1 −0 modified@@ -13,6 +13,7 @@ group :test do gem "async" end gem "concurrent-ruby" + gem "selenium-webdriver" end group :development do
.gitignore+1 −0 modified@@ -14,3 +14,4 @@ Gemfile.lock .rubocop-* .covered.db .DS_Store +.gem
lib/phlex/sgml.rb+2 −2 modified@@ -382,10 +382,10 @@ def __attributes__(attributes, buffer = +"") end lower_name = name.downcase - next if lower_name == "href" && v.to_s.downcase.tr("\t \n", "").start_with?("javascript:") + next if lower_name == "href" && v.to_s.downcase.tr("^a-z:", "").start_with?("javascript:") # Detect unsafe attribute names. Attribute names are considered unsafe if they match an event attribute or include unsafe characters. - if HTML::EVENT_ATTRIBUTES.include?(lower_name) || name.match?(/[<>&"']/) + if HTML::EVENT_ATTRIBUTES.include?(lower_name.tr("^a-z-", "")) || name.match?(/[<>&"']/) raise ArgumentError, "Unsafe attribute name detected: #{k}." end
phlex.gemspec+6 −7 modified@@ -19,13 +19,12 @@ Gem::Specification.new do |spec| spec.metadata["changelog_uri"] = "https://github.com/phlex-ruby/phlex/blob/main/CHANGELOG.md" spec.metadata["funding_uri"] = "https://github.com/sponsors/joeldrapper" - # Specify which files should be added to the gem when it is released. - # The `git ls-files -z` loads the files in the RubyGem that have been added into git. - spec.files = Dir.chdir(File.expand_path(__dir__)) do - `git ls-files -z`.split("\x0").reject do |f| - (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features|quickdraw)/|\.(?:git|travis|circleci)|appveyor)}) - end - end + spec.files = Dir[ + "README.md", + "LICENSE.txt", + "lib/**/*.rb" + ] + spec.require_paths = ["lib"] spec.metadata["rubygems_mfa_required"] = "true"
.rubocop.yml+3 −0 modified@@ -35,3 +35,6 @@ Style/ArgumentsForwarding: # We need to disable this cop because it’s not compatible with TruffleRuby 23.1, which still needs a `require "set"` Lint/RedundantRequireStatement: Enabled: false + +Lint/UnreachableLoop: + Enabled: false
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
10- github.com/advisories/GHSA-9p57-h987-4vgxghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-32970ghsaADVISORY
- developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-PolicynvdWEB
- developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-PolicynvdWEB
- github.com/phlex-ruby/phlex/commit/da8f94342a84cff9d78c98bcc3b3604ee2e577d2nvdWEB
- github.com/phlex-ruby/phlex/security/advisories/GHSA-9p57-h987-4vgxnvdWEB
- github.com/rubysec/ruby-advisory-db/blob/master/gems/phlex/CVE-2024-32970.ymlghsaWEB
- rubygems.org/gems/phlexnvdWEB
- rubygems.org/gems/phlex/versions/1.10.2ghsaWEB
- rubygems.org/gems/phlex/versions/1.9.3ghsaWEB
News mentions
0No linked articles in our index yet.