VYPR
High severity7.1NVD Advisory· Published Apr 30, 2024· Updated Apr 15, 2026

CVE-2024-32970

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.

PackageAffected versionsPatched versions
phlexRubyGems
< 1.9.31.9.3
phlexRubyGems
>= 1.10.0, < 1.10.21.10.2

Patches

4
da8f94342a84

Browser tests for XSS

https://github.com/phlex-ruby/phlexJoel DrapperApr 29, 2024via ghsa
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 added
  • Gemfile+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

News mentions

0

No linked articles in our index yet.