CVE-2013-4492: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Cross-site scripting (XSS) vulnerability in exceptions.rb in the i18n gem before 0.6.6 for Ruby allows remote attackers to inject arbitrary web script or HTML via a crafted I18n::MissingTranslationData.new call.
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.
Published: 2013-12-07T00:55Z
Repository: https://github.com/svenfuchs/i18n
Language: Ruby
Files changed: lib/i18n/exceptions.rb, test/i18n/exceptions_test.rb
Commit date: 2013-12-03 17:24:04 +0100
Commit message: The I18n::MissingTranslation exception escapes key names for its html_message

Also added deprecation message for the :rescue_format option

Diff: diff --git a/lib/i18n/exceptions.rb b/lib/i18n/exceptions.rb
index c0cf438..669d4cd 100644
--- a/lib/i18n/exceptions.rb
+++ b/lib/i18n/exceptions.rb
@@ -1,15 +1,29 @@
+require 'cgi'
+
 module I18n
   # Handles exceptions raised in the backend. All exceptions except for
   # MissingTranslationData exceptions are re-thrown. When a MissingTranslationData
   # was caught the handler returns an error message string containing the key/scope.
   # Note that the exception handler is not called when the option :throw was given.
   class ExceptionHandler
     include Module.new {
       def call(exception, locale, key, options)
         if exception.is_a?(MissingTranslation)
-          options[:rescue_format] == :html ? exception.html_message : exception.message
+          #
+          # TODO: this block is to be replaced by `exception.message` when
+          # rescue_format is removed
+          if options[:rescue_format] == :html
+            if @rescue_format_deprecation
+              $stderr.puts "[DEPRECATED] I18n's :recue_format option will be removed from a future release. All exception messages will be plain text. If you need the exception handler to return an html format please set or pass a custom exception handler."
+              @rescue_format_deprecation = true
+            end
+            exception.html_message
+          else
+            exception.message
+          end
+
         elsif exception.is_a?(Exception)
           raise exception
         else
           throw :exception, exception
         end
@@ -43,12 +57,13 @@ module I18n
         @key, @locale, @options = key, locale, options.dup || {}
         options.each { |k, v| self.options[k] = v.inspect if v.is_a?(Proc) }
       end
 
       def html_message
-        key = keys.last.to_s.gsub('_', ' ').gsub(/\b('?[a-z])/) { $1.capitalize }
-        %(<span class="translation_missing" title="translation missing: #{keys.join('.')}">#{key}</span>)
+        key  = CGI.escape_html titleize(keys.last)
+        path = CGI.escape_html keys.join('.')
+        %(<span class="translation_missing" title="translation missing: #{path}">#{key}</span>)
       end
 
       def keys
         @keys ||= I18n.normalize_keys(locale, key, options[:scope]).tap do |keys|
           keys << 'no key' if keys.size < 2
@@ -61,10 +76,17 @@ module I18n
       alias :to_s :message
 
       def to_exception
         MissingTranslationData.new(locale, key, options)
       end
+
+      protected
+
+      # TODO : remove when #html_message is removed
+      def titleize(key)
+        key.to_s.gsub('_', ' ').gsub(/\b('?[a-z])/) { $1.capitalize }
+      end
     end
 
     include Base
   end
 
diff --git a/test/i18n/exceptions_test.rb b/test/i18n/exceptions_test.rb
index 3c2e1cb..098eefe 100644
--- a/test/i18n/exceptions_test.rb
+++ b/test/i18n/exceptions_test.rb
@@ -26,13 +26,17 @@ class I18nExceptionsTest < Test::Unit::TestCase
       assert_equal 'translation missing: de.bar.foo', exception.message
     end
   end
 
   test "MissingTranslationData html_message is a span with the titlelized last key token" do
-    force_missing_translation_data do |exception|
-      assert_equal '<span class="translation_missing" title="translation missing: de.bar.foo">Foo</span>', exception.html_message
-    end
+    exception = I18n::MissingTranslationData.new(:de, :foo, :scope => :bar)
+    assert_equal '<span class="translation_missing" title="translation missing: de.bar.foo">Foo</span>', exception.html_message
+  end
+
+  test "MissingTranslationData html_message html escapes key names" do
+    exception = I18n::MissingTranslationData.new(:de, '<script>Evil</script>', :scope => '<iframe src="example.com" />')
+    assert_equal '<span class="translation_missing" title="translation missing: de.&lt;iframe src=&quot;example.com&quot; /&gt;.&lt;script&gt;Evil&lt;/script&gt;">&lt;Script&gt;Evil&lt;/Script&gt;</span>', exception.html_message
   end
 
   test "ExceptionHandler returns the html_message if :rescue_format => :html was given" do
     message = force_missing_translation_data(:rescue_format => :html)
     assert_equal '<span class="translation_missing" title="translation missing: de.bar.foo">Foo</span>', message

Vulnerable code:           options[:rescue_format] == :html ? exception.html_message : exception.message
        key = keys.last.to_s.gsub('_', ' ').gsub(/\b('?[a-z])/) { $1.capitalize }
        %(<span class="translation_missing" title="translation missing: #{keys.join('.')}">#{key}</span>)
    force_missing_translation_data do |exception|
      assert_equal '<span class="translation_missing" title="translation missing: de.bar.foo">Foo</span>', exception.html_message
    end
Fixed code: require 'cgi'

          #
          # TODO: this block is to be replaced by `exception.message` when
          # rescue_format is removed
          if options[:rescue_format] == :html
            if @rescue_format_deprecation
              $stderr.puts "[DEPRECATED] I18n's :recue_format option will be removed from a future release. All exception messages will be plain text. If you need the exception handler to return an html format please set or pass a custom exception handler."
              @rescue_format_deprecation = true
            end
            exception.html_message
          else
            exception.message
          end

        key  = CGI.escape_html titleize(keys.last)
        path = CGI.escape_html keys.join('.')
        %(<span class="translation_missing" title="translation missing: #{path}">#{key}</span>)

      protected

      # TODO : remove when #html_message is removed
      def titleize(key)
        key.to_s.gsub('_', ' ').gsub(/\b('?[a-z])/) { $1.capitalize }
      end
    exception = I18n::MissingTranslationData.new(:de, :foo, :scope => :bar)
    assert_equal '<span class="translation_missing" title="translation missing: de.bar.foo">Foo</span>', exception.html_message
  end

  test "MissingTranslationData html_message html escapes key names" do
    exception = I18n::MissingTranslationData.new(:de, '<script>Evil</script>', :scope => '<iframe src="example.com" />')
    assert_equal '<span class="translation_missing" title="translation missing: de.&lt;iframe src=&quot;example.com&quot; /&gt;.&lt;script&gt;Evil&lt;/script&gt;">&lt;Script&gt;Evil&lt;/Script&gt;</span>', exception.html_message