CVE-2022-45442: Download of Code Without Integrity Check (CWE-494)
value: Sinatra is a domain-specific language for creating web applications in Ruby. An issue was discovered in Sinatra 2.0 before 2.2.3 and 3.0 before 3.0.4. An application is vulnerable to a reflected file download (RFD) attack that sets the Content-Disposition header of a response when the filename is derived from user-supplied input. Version 2.2.3 and 3.0.4 contain patches for this issue.
CWE description: The product downloads source code or an executable from a remote location and executes the code without sufficiently verifying the origin and integrity of the code.
CVSS v3 base score: 8.8
Published: 2022-11-28T21:15Z
Repository: https://github.com/sinatra/sinatra
Language: Ruby
Files changed: lib/sinatra/base.rb, test/helpers_test.rb
Commit date: 2022-11-23 22:24:02 +0900
Commit message: escape filename in the Content-Disposition header

According the multipart form data spec in WHATWG living standard.
Ref: https://html.spec.whatwg.org/#multipart-form-data

Diff: diff --git a/lib/sinatra/base.rb b/lib/sinatra/base.rb
index d7e2d65a..cbb3f2fd 100644
--- a/lib/sinatra/base.rb
+++ b/lib/sinatra/base.rb
@@ -394,17 +394,24 @@ module Sinatra
         end.join(', ')
       end
       response['Content-Type'] = mime_type
     end
 
+    # https://html.spec.whatwg.org/#multipart-form-data
+    MULTIPART_FORM_DATA_REPLACEMENT_TABLE = {
+      '"'  => '%22',
+      "\r" => '%0D',
+      "\n" => '%0A'
+    }.freeze
+
     # Set the Content-Disposition to "attachment" with the specified filename,
     # instructing the user agents to prompt to save.
     def attachment(filename = nil, disposition = :attachment)
       response['Content-Disposition'] = disposition.to_s.dup
       return unless filename
 
-      params = format('; filename="%s"', File.basename(filename))
+      params = format('; filename="%s"', File.basename(filename).gsub(/["\r\n]/, MULTIPART_FORM_DATA_REPLACEMENT_TABLE))
       response['Content-Disposition'] << params
       ext = File.extname(filename)
       content_type(ext) unless response['Content-Type'] || ext.empty?
     end
 
diff --git a/test/helpers_test.rb b/test/helpers_test.rb
index 67dffce1..71b42a8f 100644
--- a/test/helpers_test.rb
+++ b/test/helpers_test.rb
@@ -779,10 +779,22 @@ class HelpersTest < Minitest::Test
       get '/attachment'
       assert_equal 'application/atom+xml', response['Content-Type']
       assert_equal '<sinatra></sinatra>', body
     end
 
+    it 'escapes filename in the Content-Disposition header according to the multipart form data spec in WHATWG living standard' do
+      mock_app do
+        get('/attachment') do
+          attachment "test.xml\";\r\next=.txt"
+          response.write("<sinatra></sinatra>")
+        end
+      end
+
+      get '/attachment'
+      assert_equal 'attachment; filename="test.xml%22;%0D%0Aext=.txt"', response['Content-Disposition']
+      assert_equal '<sinatra></sinatra>', body
+    end
   end
 
   describe 'send_file' do
     setup do
       @file = __dir__ + '/file.txt'

Vulnerable code:       params = format('; filename="%s"', File.basename(filename))
Fixed code:     # https://html.spec.whatwg.org/#multipart-form-data
    MULTIPART_FORM_DATA_REPLACEMENT_TABLE = {
      '"'  => '%22',
      "\r" => '%0D',
      "\n" => '%0A'
    }.freeze

      params = format('; filename="%s"', File.basename(filename).gsub(/["\r\n]/, MULTIPART_FORM_DATA_REPLACEMENT_TABLE))
    it 'escapes filename in the Content-Disposition header according to the multipart form data spec in WHATWG living standard' do
      mock_app do
        get('/attachment') do
          attachment "test.xml\";\r\next=.txt"
          response.write("<sinatra></sinatra>")
        end
      end

      get '/attachment'
      assert_equal 'attachment; filename="test.xml%22;%0D%0Aext=.txt"', response['Content-Disposition']
      assert_equal '<sinatra></sinatra>', body
    end