CVE-2022-24722: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: VIewComponent is a framework for building view components in Ruby on Rails. Versions prior to 2.31.2 and 2.49.1 contain a cross-site scripting vulnerability that has the potential to impact anyone using translations with the view_component gem. Data received via user input and passed as an interpolation argument to the `translate` method is not properly sanitized before display. Versions 2.31.2 and 2.49.1 have been released and fully mitigate the vulnerability. As a workaround, avoid passing user input to the `translate` function, or sanitize the inputs before passing them.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2022-03-02T23:15Z
Repository: https://github.com/github/view_component
Language: Ruby
Files changed: Gemfile.lock, docs/CHANGELOG.md, lib/view_component/translatable.rb, test/sandbox/app/components/translatable_component.yml, test/view_component/translatable_test.rb
Commit date: 2022-03-02 14:11:00 -0800
Commit message: Fix XSS vulnerability when using HTML-safe translations and interpolation arguments (#1295)


Diff: diff --git a/Gemfile.lock b/Gemfile.lock
index dc19791..3610d0c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,76 +1,76 @@
 PATH
   remote: .
   specs:
-    view_component (2.48.0)
+    view_component (2.49.0)
       activesupport (>= 5.0.0, < 8.0)
       method_source (~> 1.0)
 
 GEM
   remote: https://rubygems.org/
   specs:
-    actioncable (7.0.1)
-      actionpack (= 7.0.1)
-      activesupport (= 7.0.1)
+    actioncable (7.0.2)
+      actionpack (= 7.0.2)
+      activesupport (= 7.0.2)
       nio4r (~> 2.0)
       websocket-driver (>= 0.6.1)
-    actionmailbox (7.0.1)
-      actionpack (= 7.0.1)
-      activejob (= 7.0.1)
-      activerecord (= 7.0.1)
-      activestorage (= 7.0.1)
-      activesupport (= 7.0.1)
+    actionmailbox (7.0.2)
+      actionpack (= 7.0.2)
+      activejob (= 7.0.2)
+      activerecord (= 7.0.2)
+      activestorage (= 7.0.2)
+      activesupport (= 7.0.2)
       mail (>= 2.7.1)
       net-imap
       net-pop
       net-smtp
-    actionmailer (7.0.1)
-      actionpack (= 7.0.1)
-      actionview (= 7.0.1)
-      activejob (= 7.0.1)
-      activesupport (= 7.0.1)
+    actionmailer (7.0.2)
+      actionpack (= 7.0.2)
+      actionview (= 7.0.2)
+      activejob (= 7.0.2)
+      activesupport (= 7.0.2)
       mail (~> 2.5, >= 2.5.4)
       net-imap
       net-pop
       net-smtp
       rails-dom-testing (~> 2.0)
-    actionpack (7.0.1)
-      actionview (= 7.0.1)
-      activesupport (= 7.0.1)
+    actionpack (7.0.2)
+      actionview (= 7.0.2)
+      activesupport (= 7.0.2)
       rack (~> 2.0, >= 2.2.0)
       rack-test (>= 0.6.3)
       rails-dom-testing (~> 2.0)
       rails-html-sanitizer (~> 1.0, >= 1.2.0)
-    actiontext (7.0.1)
-      actionpack (= 7.0.1)
-      activerecord (= 7.0.1)
-      activestorage (= 7.0.1)
-      activesupport (= 7.0.1)
+    actiontext (7.0.2)
+      actionpack (= 7.0.2)
+      activerecord (= 7.0.2)
+      activestorage (= 7.0.2)
+      activesupport (= 7.0.2)
       globalid (>= 0.6.0)
       nokogiri (>= 1.8.5)
-    actionview (7.0.1)
-      activesupport (= 7.0.1)
+    actionview (7.0.2)
+      activesupport (= 7.0.2)
       builder (~> 3.1)
       erubi (~> 1.4)
       rails-dom-testing (~> 2.0)
       rails-html-sanitizer (~> 1.1, >= 1.2.0)
-    activejob (7.0.1)
-      activesupport (= 7.0.1)
+    activejob (7.0.2)
+      activesupport (= 7.0.2)
       globalid (>= 0.3.6)
-    activemodel (7.0.1)
-      activesupport (= 7.0.1)
-    activerecord (7.0.1)
-      activemodel (= 7.0.1)
-      activesupport (= 7.0.1)
-    activestorage (7.0.1)
-      actionpack (= 7.0.1)
-      activejob (= 7.0.1)
-      activerecord (= 7.0.1)
-      activesupport (= 7.0.1)
+    activemodel (7.0.2)
+      activesupport (= 7.0.2)
+    activerecord (7.0.2)
+      activemodel (= 7.0.2)
+      activesupport (= 7.0.2)
+    activestorage (7.0.2)
+      actionpack (= 7.0.2)
+      activejob (= 7.0.2)
+      activerecord (= 7.0.2)
+      activesupport (= 7.0.2)
       marcel (~> 1.0)
       mini_mime (>= 1.1.0)
-    activesupport (7.0.1)
+    activesupport (7.0.2)
       concurrent-ruby (~> 1.0, >= 1.0.2)
       i18n (>= 1.6, < 2)
       minitest (>= 5.1)
       tzinfo (~> 2.0)
     addressable (2.8.0)
@@ -118,26 +118,26 @@ GEM
       activesupport (>= 5.0)
     haml (5.2.2)
       temple (>= 0.8.0)
       tilt
     html_tokenizer (0.0.7)
-    i18n (1.8.11)
+    i18n (1.10.0)
       concurrent-ruby (~> 1.0)
     io-wait (0.2.1)
     jbuilder (2.11.5)
       actionview (>= 5.0.0)
       activesupport (>= 5.0.0)
-    loofah (2.13.0)
+    loofah (2.14.0)
       crass (~> 1.0.2)
       nokogiri (>= 1.5.9)
     mail (2.7.1)
       mini_mime (>= 0.1.1)
     marcel (1.0.2)
     matrix (0.4.2)
     method_source (1.0.0)
     mini_mime (1.1.2)
-    mini_portile2 (2.7.1)
+    mini_portile2 (2.8.0)
     minitest (5.6.0)
     net-imap (0.2.3)
       digest
       net-protocol
       strscan
@@ -151,65 +151,65 @@ GEM
     net-smtp (0.3.1)
       digest
       net-protocol
       timeout
     nio4r (2.5.8)
-    nokogiri (1.13.1)
-      mini_portile2 (~> 2.7.0)
+    nokogiri (1.13.3)
+      mini_portile2 (~> 2.8.0)
       racc (~> 1.4)
     parallel (1.21.0)
-    parser (3.1.0.0)
+    parser (3.1.1.0)
       ast (~> 2.4.1)
     pry (0.14.1)
       coderay (~> 1.1)
       method_source (~> 1.0)
     public_suffix (4.0.6)
     racc (1.6.0)
     rack (2.2.3)
     rack-test (1.1.0)
       rack (>= 1.0, < 3)
-    rails (7.0.1)
-      actioncable (= 7.0.1)
-      actionmailbox (= 7.0.1)
-      actionmailer (= 7.0.1)
-      actionpack (= 7.0.1)
-      actiontext (= 7.0.1)
-      actionview (= 7.0.1)
-      activejob (= 7.0.1)
-      activemodel (= 7.0.1)
-      activerecord (= 7.0.1)
-      activestorage (= 7.0.1)
-      activesupport (= 7.0.1)
+    rails (7.0.2)
+      actioncable (= 7.0.2)
+      actionmailbox (= 7.0.2)
+      actionmailer (= 7.0.2)
+      actionpack (= 7.0.2)
+      actiontext (= 7.0.2)
+      actionview (= 7.0.2)
+      activejob (= 7.0.2)
+      activemodel (= 7.0.2)
+      activerecord (= 7.0.2)
+      activestorage (= 7.0.2)
+      activesupport (= 7.0.2)
       bundler (>= 1.15.0)
-      railties (= 7.0.1)
+      railties (= 7.0.2)
     rails-dom-testing (2.0.3)
       activesupport (>= 4.2.0)
       nokogiri (>= 1.6)
     rails-html-sanitizer (1.4.2)
       loofah (~> 2.3)
-    railties (7.0.1)
-      actionpack (= 7.0.1)
-      activesupport (= 7.0.1)
+    railties (7.0.2)
+      actionpack (= 7.0.2)
+      activesupport (= 7.0.2)
       method_source
       rake (>= 12.2)
       thor (~> 1.0)
       zeitwerk (~> 2.5)
     rainbow (3.1.1)
     rake (13.0.6)
-    regexp_parser (2.2.0)
+    regexp_parser (2.2.1)
     rexml (3.2.5)
     rubocop (1.13.0)
       parallel (~> 1.10)
       parser (>= 3.0.0.0)
       rainbow (>= 2.2.2, < 4.0)
       regexp_parser (>= 1.8, < 3.0)
       rexml
       rubocop-ast (>= 1.2.0, < 2.0)
       ruby-progressbar (~> 1.7)
       unicode-display_width (>= 1.4.0, < 3.0)
-    rubocop-ast (1.15.1)
-      parser (>= 3.0.1.1)
+    rubocop-ast (1.16.0)
+      parser (>= 3.1.1.0)
     rubocop-github (0.16.2)
       rubocop (<= 1.13.0)
       rubocop-performance (<= 1.11.0)
       rubocop-rails (<= 2.7.1)
     rubocop-performance (1.11.0)
@@ -230,11 +230,11 @@ GEM
     simplecov-html (0.12.3)
     slim (4.1.0)
       temple (>= 0.7.6, < 0.9)
       tilt (>= 2.0.6, < 2.1)
     smart_properties (1.17.0)
-    sprockets (4.0.2)
+    sprockets (4.0.3)
       concurrent-ruby (~> 1.0)
       rack (> 1, < 3)
     sprockets-rails (3.2.2)
       actionpack (>= 4.0)
       activesupport (>= 4.0)
@@ -257,11 +257,11 @@ GEM
       nokogiri (~> 1.8)
     yard (0.9.27)
       webrick (~> 1.7.0)
     yard-activesupport-concern (0.0.1)
       yard (>= 0.8)
-    zeitwerk (2.5.3)
+    zeitwerk (2.5.4)
 
 PLATFORMS
   ruby
 
 DEPENDENCIES
@@ -272,15 +272,12 @@ DEPENDENCIES
   capybara (~> 3)
   erb_lint (~> 0.0.37)
   haml (~> 5)
   jbuilder (~> 2)
   minitest (= 5.6.0)
-  net-imap
-  net-pop
-  net-smtp
   pry (~> 0.13)
-  rails (~> 7.0.0)
+  rails (= 7.0.2)
   rake (~> 13.0)
   rubocop-github (~> 0.16.1)
   simplecov (~> 0.18.0)
   simplecov-console (~> 0.7.2)
   slim (~> 4.0)
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index a6919b8..d02350f 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -51,10 +51,16 @@ title: Changelog
 
 * Add Shogun to users list.
 
     *Bernie Chiu*
 
+## 2.49.1
+
+* Patch XSS vulnerability in `ViewComponent::Translatable` module caused by improperly escaped interpolation arguments.
+
+    *Cameron Dutro*
+
 ## 2.49.0
 
 * Fix path handling for evaluated view components that broke in Ruby 3.1.
 
     *Adam Hess*
@@ -657,10 +663,16 @@ title: Changelog
 
 * Are you interested in building the future of ViewComponent? GitHub is looking to hire a Senior Engineer to work on Primer ViewComponents and ViewComponent. Apply here: [US/Canada](https://github.com/careers) / [Europe](https://boards.greenhouse.io/github/jobs/3132294). Feel free to reach out to joelhawksley@github.com with any questions.
 
     *Joel Hawksley*
 
+## 2.31.2
+
+* Patch XSS vulnerability in `ViewComponent::Translatable` module caused by improperly escaped interpolation arguments.
+
+    *Cameron Dutro*
+
 ## 2.31.1
 
 * Fix `DEPRECATION WARNING: before_render_check` when compiling `ViewComponent::Base`
 
     *Dave Kroondyk*
diff --git a/lib/view_component/translatable.rb b/lib/view_component/translatable.rb
index 0e19dac..8e11f00 100644
--- a/lib/view_component/translatable.rb
+++ b/lib/view_component/translatable.rb
@@ -1,7 +1,8 @@
 # frozen_string_literal: true
 
+require "erb"
 require "set"
 require "i18n"
 require "action_view/helpers/translation_helper"
 require "active_support/concern"
 
@@ -68,10 +69,14 @@ module ViewComponent
 
       locale = options.delete(:locale) || ::I18n.locale
       key = key&.to_s unless key.is_a?(String)
       key = "#{i18n_scope}#{key}" if key.start_with?(".")
 
+      if HTML_SAFE_TRANSLATION_KEY.match?(key)
+        html_escape_translation_options!(options)
+      end
+
       if key.start_with?(i18n_scope + ".")
         translated =
           catch(:exception) do
             i18n_backend.translate(locale, key, options)
           end
@@ -105,7 +110,21 @@ module ViewComponent
         # It's reasonable that if we're in Rails, `active_support/core_ext/string/output_safety.rb`
         # will provide this to `Object`.
         translation.html_safe # rubocop:disable Rails/OutputSafety
       end
     end
+
+    private
+
+    def html_escape_translation_options!(options)
+      options.each do |name, value|
+        unless i18n_option?(name) || (name == :count && value.is_a?(Numeric))
+          options[name] = ERB::Util.html_escape(value.to_s)
+        end
+      end
+    end
+
+    def i18n_option?(name)
+      (@i18n_option_names ||= I18n::RESERVED_KEYS.to_set).include?(name)
+    end
   end
 end
diff --git a/test/sandbox/app/components/translatable_component.yml b/test/sandbox/app/components/translatable_component.yml
index eb73de6..cc741b5 100644
--- a/test/sandbox/app/components/translatable_component.yml
+++ b/test/sandbox/app/components/translatable_component.yml
@@ -1,10 +1,12 @@
 en:
   hello: "Hello from sidecar translations!"
 
   hello_html: "Hello from <strong>sidecar translations</strong>!"
 
+  interpolated_html: "There are %{horse_count} horses in the <strong>barn</strong>!"
+
   html: "hello <em>world</em>!"
 
   from:
     sidecar: This is coming from the sidecar
 
diff --git a/test/view_component/translatable_test.rb b/test/view_component/translatable_test.rb
index 6b4ba1c..b601b3b 100644
--- a/test/view_component/translatable_test.rb
+++ b/test/view_component/translatable_test.rb
@@ -56,10 +56,27 @@ class TranslatableTest < ViewComponent::TestCase
   def test_translate_marks_translations_with_a_html_suffix_as_safe_html
     assert_equal "Hello from <strong>sidecar translations</strong>!", translate(".hello_html")
     assert_predicate translate(".hello_html"), :html_safe?
   end
 
+  def test_translate_with_html_suffix_escapes_interpolated_arguments
+    translation = translate(".interpolated_html", horse_count: "<script type='text/javascript'>alert('foo');</script>")
+    assert_equal(
+      "There are &lt;script type=&#39;text/javascript&#39;&gt;alert(&#39;foo&#39;);&lt;/script&gt; horses in the "\
+        "<strong>barn</strong>!",
+      translation
+    )
+  end
+
+  def test_translate_with_html_suffix_does_not_double_escape
+    translation = translate(".interpolated_html", horse_count: "> 4")
+    assert_equal(
+      "There are &gt; 4 horses in the <strong>barn</strong>!",
+      translation
+    )
+  end
+
   def test_translate_uses_the_helper_when_no_sidecar_file_is_provided
     # The cache needs to be kept clean for TranslatableComponent, otherwise it will rely on the
     # already created i18n_backend.
     ViewComponent::CompileCache.cache.delete(TranslatableComponent)
 

Vulnerable code:     view_component (2.48.0)
    actioncable (7.0.1)
      actionpack (= 7.0.1)
      activesupport (= 7.0.1)
    actionmailbox (7.0.1)
      actionpack (= 7.0.1)
      activejob (= 7.0.1)
      activerecord (= 7.0.1)
      activestorage (= 7.0.1)
      activesupport (= 7.0.1)
    actionmailer (7.0.1)
      actionpack (= 7.0.1)
      actionview (= 7.0.1)
      activejob (= 7.0.1)
      activesupport (= 7.0.1)
    actionpack (7.0.1)
      actionview (= 7.0.1)
      activesupport (= 7.0.1)
    actiontext (7.0.1)
      actionpack (= 7.0.1)
      activerecord (= 7.0.1)
      activestorage (= 7.0.1)
      activesupport (= 7.0.1)
    actionview (7.0.1)
      activesupport (= 7.0.1)
    activejob (7.0.1)
      activesupport (= 7.0.1)
    activemodel (7.0.1)
      activesupport (= 7.0.1)
    activerecord (7.0.1)
      activemodel (= 7.0.1)
      activesupport (= 7.0.1)
    activestorage (7.0.1)
      actionpack (= 7.0.1)
      activejob (= 7.0.1)
      activerecord (= 7.0.1)
      activesupport (= 7.0.1)
    activesupport (7.0.1)
    i18n (1.8.11)
    loofah (2.13.0)
    mini_portile2 (2.7.1)
    nokogiri (1.13.1)
      mini_portile2 (~> 2.7.0)
    parser (3.1.0.0)
    rails (7.0.1)
      actioncable (= 7.0.1)
      actionmailbox (= 7.0.1)
      actionmailer (= 7.0.1)
      actionpack (= 7.0.1)
      actiontext (= 7.0.1)
      actionview (= 7.0.1)
      activejob (= 7.0.1)
      activemodel (= 7.0.1)
      activerecord (= 7.0.1)
      activestorage (= 7.0.1)
      activesupport (= 7.0.1)
      railties (= 7.0.1)
    railties (7.0.1)
      actionpack (= 7.0.1)
      activesupport (= 7.0.1)
    regexp_parser (2.2.0)
    rubocop-ast (1.15.1)
      parser (>= 3.0.1.1)
    sprockets (4.0.2)
    zeitwerk (2.5.3)
  net-imap
  net-pop
  net-smtp
  rails (~> 7.0.0)
Fixed code:     view_component (2.49.0)
    actioncable (7.0.2)
      actionpack (= 7.0.2)
      activesupport (= 7.0.2)
    actionmailbox (7.0.2)
      actionpack (= 7.0.2)
      activejob (= 7.0.2)
      activerecord (= 7.0.2)
      activestorage (= 7.0.2)
      activesupport (= 7.0.2)
    actionmailer (7.0.2)
      actionpack (= 7.0.2)
      actionview (= 7.0.2)
      activejob (= 7.0.2)
      activesupport (= 7.0.2)
    actionpack (7.0.2)
      actionview (= 7.0.2)
      activesupport (= 7.0.2)
    actiontext (7.0.2)
      actionpack (= 7.0.2)
      activerecord (= 7.0.2)
      activestorage (= 7.0.2)
      activesupport (= 7.0.2)
    actionview (7.0.2)
      activesupport (= 7.0.2)
    activejob (7.0.2)
      activesupport (= 7.0.2)
    activemodel (7.0.2)
      activesupport (= 7.0.2)
    activerecord (7.0.2)
      activemodel (= 7.0.2)
      activesupport (= 7.0.2)
    activestorage (7.0.2)
      actionpack (= 7.0.2)
      activejob (= 7.0.2)
      activerecord (= 7.0.2)
      activesupport (= 7.0.2)
    activesupport (7.0.2)
    i18n (1.10.0)
    loofah (2.14.0)
    mini_portile2 (2.8.0)
    nokogiri (1.13.3)
      mini_portile2 (~> 2.8.0)
    parser (3.1.1.0)
    rails (7.0.2)
      actioncable (= 7.0.2)
      actionmailbox (= 7.0.2)
      actionmailer (= 7.0.2)
      actionpack (= 7.0.2)
      actiontext (= 7.0.2)
      actionview (= 7.0.2)
      activejob (= 7.0.2)
      activemodel (= 7.0.2)
      activerecord (= 7.0.2)
      activestorage (= 7.0.2)
      activesupport (= 7.0.2)
      railties (= 7.0.2)
    railties (7.0.2)
      actionpack (= 7.0.2)
      activesupport (= 7.0.2)
    regexp_parser (2.2.1)
    rubocop-ast (1.16.0)
      parser (>= 3.1.1.0)
    sprockets (4.0.3)
    zeitwerk (2.5.4)
  rails (= 7.0.2)
## 2.49.1

* Patch XSS vulnerability in `ViewComponent::Translatable` module caused by improperly escaped interpolation arguments.

    *Cameron Dutro*

## 2.31.2

* Patch XSS vulnerability in `ViewComponent::Translatable` module caused by improperly escaped interpolation arguments.

    *Cameron Dutro*

require "erb"
      if HTML_SAFE_TRANSLATION_KEY.match?(key)
        html_escape_translation_options!(options)
      end


    private

    def html_escape_translation_options!(options)
      options.each do |name, value|
        unless i18n_option?(name) || (name == :count && value.is_a?(Numeric))
          options[name] = ERB::Util.html_escape(value.to_s)
        end
      end
    end

    def i18n_option?(name)
      (@i18n_option_names ||= I18n::RESERVED_KEYS.to_set).include?(name)
    end
  interpolated_html: "There are %{horse_count} horses in the <strong>barn</strong>!"

  def test_translate_with_html_suffix_escapes_interpolated_arguments
    translation = translate(".interpolated_html", horse_count: "<script type='text/javascript'>alert('foo');</script>")
    assert_equal(
      "There are &lt;script type=&#39;text/javascript&#39;&gt;alert(&#39;foo&#39;);&lt;/script&gt; horses in the "\
        "<strong>barn</strong>!",
      translation
    )
  end

  def test_translate_with_html_suffix_does_not_double_escape
    translation = translate(".interpolated_html", horse_count: "> 4")
    assert_equal(
      "There are &gt; 4 horses in the <strong>barn</strong>!",
      translation
    )
  end