CVE-2019-18978: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: An issue was discovered in the rack-cors (aka Rack CORS Middleware) gem before 1.0.4 for Ruby. It allows ../ directory traversal to access private resources because resource matching does not ensure that pathnames are in a canonical format.
CWE description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
CVSS v3 base score: 5.3
Published: 2019-11-14T21:15Z
Repository: https://github.com/cyu/rack-cors
Language: Ruby
Files changed: CHANGELOG.md, lib/rack/cors.rb, lib/rack/cors/version.rb, test/unit/cors_test.rb, test/unit/test.ru
Commit date: 2019-11-13 22:56:55 -0500
Commit message: Unescape and resolve paths before resource checks

Fix scenario where someone could use '../' to access private resources

Diff: diff --git a/CHANGELOG.md b/CHANGELOG.md
index 04c5bf7..d0c5e9b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,12 @@
 # Change Log
 All notable changes to this project will be documented in this file.
 
+## 1.0.4 - 2019-11-13
+### Security
+- Escape and resolve path before evaluating resource rules (thanks to Colby Morgan)
+
 ## 1.0.3 - 2019-03-24
 ### Changed
 - Don't send 'Content-Type' header with pre-flight requests
 - Allow ruby array for  vary header config
 
diff --git a/lib/rack/cors.rb b/lib/rack/cors.rb
index 999b1c3..24bc2b3 100644
--- a/lib/rack/cors.rb
+++ b/lib/rack/cors.rb
@@ -62,37 +62,40 @@ module Rack
     end
 
     def call(env)
       env[HTTP_ORIGIN] ||= env[HTTP_X_ORIGIN] if env[HTTP_X_ORIGIN]
 
+      path = evaluate_path(env)
+
       add_headers = nil
       if env[HTTP_ORIGIN]
         debug(env) do
           [ 'Incoming Headers:',
             "  Origin: #{env[HTTP_ORIGIN]}",
+            "  Path-Info: #{path}",
             "  Access-Control-Request-Method: #{env[HTTP_ACCESS_CONTROL_REQUEST_METHOD]}",
             "  Access-Control-Request-Headers: #{env[HTTP_ACCESS_CONTROL_REQUEST_HEADERS]}"
             ].join("\n")
         end
         if env[REQUEST_METHOD] == OPTIONS and env[HTTP_ACCESS_CONTROL_REQUEST_METHOD]
-          headers = process_preflight(env)
+          headers = process_preflight(env, path)
           debug(env) do
             "Preflight Headers:\n" +
                 headers.collect{|kv| "  #{kv.join(': ')}"}.join("\n")
           end
           return [200, headers, []]
         else
-          add_headers = process_cors(env)
+          add_headers = process_cors(env, path)
         end
       else
         Result.miss(env, Result::MISS_NO_ORIGIN)
       end
 
       # This call must be done BEFORE calling the app because for some reason
       # env[PATH_INFO] gets changed after that and it won't match. (At least
       # in rails 4.1.6)
-      vary_resource = resource_for_path(env[PATH_INFO])
+      vary_resource = resource_for_path(path)
 
       status, headers, body = @app.call env
 
       if add_headers
         headers = add_headers.merge(headers)
@@ -145,28 +148,34 @@ module Rack
         else
           ::Logger.new(STDOUT).tap { |logger| logger.level = ::Logger::Severity::DEBUG }
         end
       end
 
+      def evaluate_path(env)
+        path = env[PATH_INFO]
+        path = Rack::Utils.clean_path_info(Rack::Utils.unescape_path(path)) if path
+        path
+      end
+
       def all_resources
         @all_resources ||= []
       end
 
-      def process_preflight(env)
+      def process_preflight(env, path)
         result = Result.preflight(env)
 
-        resource, error = match_resource(env)
+        resource, error = match_resource(path, env)
         unless resource
           result.miss(error)
           return {}
         end
 
         return resource.process_preflight(env, result)
       end
 
-      def process_cors(env)
-        resource, error = match_resource(env)
+      def process_cors(env, path)
+        resource, error = match_resource(path, env)
         if resource
           Result.hit(env)
           cors = resource.to_headers(env)
           cors
 
@@ -183,12 +192,11 @@ module Rack
           end
         end
         nil
       end
 
-      def match_resource(env)
-        path   = env[PATH_INFO]
+      def match_resource(path, env)
         origin = env[HTTP_ORIGIN]
 
         origin_matched = false
         all_resources.each do |r|
           if r.allow_origin?(origin, env)
diff --git a/lib/rack/cors/version.rb b/lib/rack/cors/version.rb
index 5eef48d..513b551 100644
--- a/lib/rack/cors/version.rb
+++ b/lib/rack/cors/version.rb
@@ -1,5 +1,5 @@
 module Rack
   class Cors
-    VERSION = "1.0.3"
+    VERSION = "1.0.4"
   end
 end
diff --git a/test/unit/cors_test.rb b/test/unit/cors_test.rb
index 10957bb..182a340 100644
--- a/test/unit/cors_test.rb
+++ b/test/unit/cors_test.rb
@@ -144,10 +144,16 @@ describe Rack::Cors do
   it "should add Vary header based on :vary option" do
     successful_cors_request '/vary_test'
     last_response.headers['Vary'].must_equal 'Origin, Host'
   end
 
+  it "decode URL and resolve paths before resource matching" do
+    header 'Origin', 'http://localhost:3000'
+    get '/public/a/..%2F..%2Fprivate/stuff'
+    last_response.wont_render_cors_success
+  end
+
   describe 'with array of upstream Vary headers' do
     let(:app) { load_app('test', { proxy: true }) }
 
     it 'should add to them' do
       successful_cors_request '/vary_test'
diff --git a/test/unit/test.ru b/test/unit/test.ru
index dda69e9..cf25c50 100644
--- a/test/unit/test.ru
+++ b/test/unit/test.ru
@@ -39,10 +39,11 @@ use Rack::Cors do
   end
 
   allow do
     origins '*'
     resource '/public'
+    resource '/public/*'
     resource '/public_without_credentials', :credentials => false
   end
 
   allow do
     origins 'mucho-grande.com'

Vulnerable code:           headers = process_preflight(env)
          add_headers = process_cors(env)
      vary_resource = resource_for_path(env[PATH_INFO])
      def process_preflight(env)
        resource, error = match_resource(env)
      def process_cors(env)
        resource, error = match_resource(env)
      def match_resource(env)
        path   = env[PATH_INFO]
    VERSION = "1.0.3"
Fixed code: ## 1.0.4 - 2019-11-13
### Security
- Escape and resolve path before evaluating resource rules (thanks to Colby Morgan)

      path = evaluate_path(env)

            "  Path-Info: #{path}",
          headers = process_preflight(env, path)
          add_headers = process_cors(env, path)
      vary_resource = resource_for_path(path)
      def evaluate_path(env)
        path = env[PATH_INFO]
        path = Rack::Utils.clean_path_info(Rack::Utils.unescape_path(path)) if path
        path
      end

      def process_preflight(env, path)
        resource, error = match_resource(path, env)
      def process_cors(env, path)
        resource, error = match_resource(path, env)
      def match_resource(path, env)
    VERSION = "1.0.4"
  it "decode URL and resolve paths before resource matching" do
    header 'Origin', 'http://localhost:3000'
    get '/public/a/..%2F..%2Fprivate/stuff'
    last_response.wont_render_cors_success
  end

    resource '/public/*'