Commit 9db5eb50 authored by James Fargher's avatar James Fargher

Merge branch 'jwanjohi_improve_path_traversal_check' into 'master'

Improve checks for path traversal validation

See merge request gitlab-org/gitlab!33114
parents acb688a9 09dfd28b
---
title: Improve path traversal validation checks
merge_request: 33114
author:
type: security
...@@ -173,7 +173,8 @@ guide on how you can add a new custom validator. ...@@ -173,7 +173,8 @@ guide on how you can add a new custom validator.
validates the parameter value for different cases. Mainly, it checks whether a validates the parameter value for different cases. Mainly, it checks whether a
path is relative and does it contain `../../` relative traversal using path is relative and does it contain `../../` relative traversal using
`File::Separator` or not, and whether the path is absolute, for example `File::Separator` or not, and whether the path is absolute, for example
`/etc/passwd/`. `/etc/passwd/`. By default, absolute paths are not allowed. However, you can optionally pass in an allowlist for allowed absolute paths in the following way:
`requires :file_path, type: String, file_path: { allowlist: ['/foo/bar/', '/home/foo/', '/app/home'] }`
- `Git SHA`: - `Git SHA`:
......
...@@ -5,10 +5,12 @@ module API ...@@ -5,10 +5,12 @@ module API
module Validators module Validators
class FilePath < Grape::Validations::Base class FilePath < Grape::Validations::Base
def validate_param!(attr_name, params) def validate_param!(attr_name, params)
options = @option.is_a?(Hash) ? @option : {}
path_allowlist = options.fetch(:allowlist, [])
path = params[attr_name] path = params[attr_name]
path = Gitlab::Utils.check_path_traversal!(path)
Gitlab::Utils.check_path_traversal!(path) Gitlab::Utils.check_allowed_absolute_path!(path, path_allowlist)
rescue ::Gitlab::Utils::PathTraversalAttackError rescue
raise Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)], raise Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)],
message: "should be a valid file path" message: "should be a valid file path"
end end
......
...@@ -7,23 +7,43 @@ module Gitlab ...@@ -7,23 +7,43 @@ module Gitlab
# Ensure that the relative path will not traverse outside the base directory # Ensure that the relative path will not traverse outside the base directory
# We url decode the path to avoid passing invalid paths forward in url encoded format. # We url decode the path to avoid passing invalid paths forward in url encoded format.
# We are ok to pass some double encoded paths to File.open since they won't resolve.
# Also see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24223#note_284122580 # Also see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24223#note_284122580
# It also checks for ALT_SEPARATOR aka '\' (forward slash) # It also checks for ALT_SEPARATOR aka '\' (forward slash)
def check_path_traversal!(path, allowed_absolute: false) def check_path_traversal!(path)
path = CGI.unescape(path) path = decode_path(path)
path_regex = /(\A(\.{1,2})\z|\A\.\.[\/\\]|[\/\\]\.\.\z|[\/\\]\.\.[\/\\]|\n)/
if path.start_with?("..#{File::SEPARATOR}", "..#{File::ALT_SEPARATOR}") ||
path.include?("#{File::SEPARATOR}..#{File::SEPARATOR}") ||
path.end_with?("#{File::SEPARATOR}..") ||
(!allowed_absolute && Pathname.new(path).absolute?)
if path.match?(path_regex)
raise PathTraversalAttackError.new('Invalid path') raise PathTraversalAttackError.new('Invalid path')
end end
path path
end end
def allowlisted?(absolute_path, allowlist)
path = absolute_path.downcase
allowlist.map(&:downcase).any? do |allowed_path|
path.start_with?(allowed_path)
end
end
def check_allowed_absolute_path!(path, allowlist)
return unless Pathname.new(path).absolute?
return if allowlisted?(path, allowlist)
raise StandardError, "path #{path} is not allowed"
end
def decode_path(encoded_path)
decoded = CGI.unescape(encoded_path)
if decoded != CGI.unescape(decoded)
raise StandardError, "path #{encoded_path} is not allowed"
end
decoded
end
def force_utf8(str) def force_utf8(str)
str.dup.force_encoding(Encoding::UTF_8) str.dup.force_encoding(Encoding::UTF_8)
end end
......
...@@ -6,31 +6,64 @@ RSpec.describe API::Validations::Validators::FilePath do ...@@ -6,31 +6,64 @@ RSpec.describe API::Validations::Validators::FilePath do
include ApiValidatorsHelpers include ApiValidatorsHelpers
subject do subject do
described_class.new(['test'], {}, false, scope.new) described_class.new(['test'], params, false, scope.new)
end end
context 'valid file path' do context 'when allowlist is not set' do
it 'does not raise a validation error' do shared_examples 'file validation' do
expect_no_validation_error('test' => './foo') context 'valid file path' do
expect_no_validation_error('test' => './bar.rb') it 'does not raise a validation error' do
expect_no_validation_error('test' => 'foo%2Fbar%2Fnew%2Ffile.rb') expect_no_validation_error('test' => './foo')
expect_no_validation_error('test' => 'foo%2Fbar%2Fnew') expect_no_validation_error('test' => './bar.rb')
expect_no_validation_error('test' => 'foo%252Fbar%252Fnew%252Ffile.rb') expect_no_validation_error('test' => 'foo%2Fbar%2Fnew%2Ffile.rb')
expect_no_validation_error('test' => 'foo%2Fbar%2Fnew')
expect_no_validation_error('test' => 'foo/bar')
end
end
context 'invalid file path' do
it 'raise a validation error' do
expect_validation_error('test' => '../foo')
expect_validation_error('test' => '../')
expect_validation_error('test' => 'foo/../../bar')
expect_validation_error('test' => 'foo/../')
expect_validation_error('test' => 'foo/..')
expect_validation_error('test' => '../')
expect_validation_error('test' => '..\\')
expect_validation_error('test' => '..\/')
expect_validation_error('test' => '%2e%2e%2f')
expect_validation_error('test' => '/etc/passwd')
expect_validation_error('test' => 'test%0a/etc/passwd')
expect_validation_error('test' => '%2Ffoo%2Fbar%2Fnew%2Ffile.rb')
expect_validation_error('test' => '%252Ffoo%252Fbar%252Fnew%252Ffile.rb')
expect_validation_error('test' => 'foo%252Fbar%252Fnew%252Ffile.rb')
expect_validation_error('test' => 'foo%25252Fbar%25252Fnew%25252Ffile.rb')
end
end
end
it_behaves_like 'file validation' do
let(:params) { {} }
end
it_behaves_like 'file validation' do
let(:params) { true }
end end
end end
context 'invalid file path' do context 'when allowlist is set' do
it 'raise a validation error' do let(:params) { { allowlist: ['/home/bar'] } }
expect_validation_error('test' => '../foo')
expect_validation_error('test' => '../') context 'when file path is included in the allowlist' do
expect_validation_error('test' => 'foo/../../bar') it 'does not raise a validation error' do
expect_validation_error('test' => 'foo/../') expect_no_validation_error('test' => '/home/bar')
expect_validation_error('test' => 'foo/..') end
expect_validation_error('test' => '../') end
expect_validation_error('test' => '..\\')
expect_validation_error('test' => '..\/') context 'when file path is not included in the allowlist' do
expect_validation_error('test' => '%2e%2e%2f') it 'raises a validation error' do
expect_validation_error('test' => '/etc/passwd') expect_validation_error('test' => '/foo/xyz')
end
end end
end end
end end
...@@ -5,39 +5,93 @@ require 'spec_helper' ...@@ -5,39 +5,93 @@ require 'spec_helper'
RSpec.describe Gitlab::Utils do RSpec.describe Gitlab::Utils do
delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, :which, delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, :which,
:ensure_array_from_string, :to_exclusive_sentence, :bytes_to_megabytes, :ensure_array_from_string, :to_exclusive_sentence, :bytes_to_megabytes,
:append_path, :check_path_traversal!, :ms_to_round_sec, to: :described_class :append_path, :check_path_traversal!, :allowlisted?, :check_allowed_absolute_path!, :decode_path, :ms_to_round_sec, to: :described_class
describe '.check_path_traversal!' do describe '.check_path_traversal!' do
it 'detects path traversal in string without any separators' do
expect { check_path_traversal!('.') }.to raise_error(/Invalid path/)
expect { check_path_traversal!('..') }.to raise_error(/Invalid path/)
end
it 'detects path traversal at the start of the string' do it 'detects path traversal at the start of the string' do
expect { check_path_traversal!('../foo') }.to raise_error(/Invalid path/) expect { check_path_traversal!('../foo') }.to raise_error(/Invalid path/)
expect { check_path_traversal!('..\\foo') }.to raise_error(/Invalid path/)
end end
it 'detects path traversal at the start of the string, even to just the subdirectory' do it 'detects path traversal at the start of the string, even to just the subdirectory' do
expect { check_path_traversal!('../') }.to raise_error(/Invalid path/) expect { check_path_traversal!('../') }.to raise_error(/Invalid path/)
expect { check_path_traversal!('..\\') }.to raise_error(/Invalid path/)
expect { check_path_traversal!('/../') }.to raise_error(/Invalid path/)
expect { check_path_traversal!('\\..\\') }.to raise_error(/Invalid path/)
end end
it 'detects path traversal in the middle of the string' do it 'detects path traversal in the middle of the string' do
expect { check_path_traversal!('foo/../../bar') }.to raise_error(/Invalid path/) expect { check_path_traversal!('foo/../../bar') }.to raise_error(/Invalid path/)
expect { check_path_traversal!('foo\\..\\..\\bar') }.to raise_error(/Invalid path/)
expect { check_path_traversal!('foo/..\\bar') }.to raise_error(/Invalid path/)
expect { check_path_traversal!('foo\\../bar') }.to raise_error(/Invalid path/)
expect { check_path_traversal!('foo/..\\..\\..\\..\\../bar') }.to raise_error(/Invalid path/)
end end
it 'detects path traversal at the end of the string when slash-terminates' do it 'detects path traversal at the end of the string when slash-terminates' do
expect { check_path_traversal!('foo/../') }.to raise_error(/Invalid path/) expect { check_path_traversal!('foo/../') }.to raise_error(/Invalid path/)
expect { check_path_traversal!('foo\\..\\') }.to raise_error(/Invalid path/)
end end
it 'detects path traversal at the end of the string' do it 'detects path traversal at the end of the string' do
expect { check_path_traversal!('foo/..') }.to raise_error(/Invalid path/) expect { check_path_traversal!('foo/..') }.to raise_error(/Invalid path/)
expect { check_path_traversal!('foo\\..') }.to raise_error(/Invalid path/)
end end
it 'does nothing for a safe string' do it 'does nothing for a safe string' do
expect(check_path_traversal!('./foo')).to eq('./foo') expect(check_path_traversal!('./foo')).to eq('./foo')
expect(check_path_traversal!('.test/foo')).to eq('.test/foo')
expect(check_path_traversal!('..test/foo')).to eq('..test/foo')
expect(check_path_traversal!('dir/..foo.rb')).to eq('dir/..foo.rb')
expect(check_path_traversal!('dir/.foo.rb')).to eq('dir/.foo.rb')
end
end
describe '.allowlisted?' do
let(:allowed_paths) { ['/home/foo', '/foo/bar', '/etc/passwd']}
it 'returns true if path is allowed' do
expect(allowlisted?('/foo/bar', allowed_paths)).to be(true)
end
it 'returns false if path is not allowed' do
expect(allowlisted?('/test/test', allowed_paths)).to be(false)
end
end
describe '.check_allowed_absolute_path!' do
let(:allowed_paths) { ['/home/foo'] }
it 'raises an exception if an absolute path is not allowed' do
expect { check_allowed_absolute_path!('/etc/passwd', allowed_paths) }.to raise_error(StandardError)
end end
it 'does nothing if an absolute path is allowed' do it 'does nothing for an allowed absolute path' do
expect(check_path_traversal!('/etc/folder/path', allowed_absolute: true)). to eq('/etc/folder/path') expect(check_allowed_absolute_path!('/home/foo', allowed_paths)).to be_nil
end end
end
it 'raises exception if an absolute path is not allowed' do describe '.decode_path' do
expect { check_path_traversal!('/etc/folder/path') }.to raise_error(/Invalid path/) it 'returns path unencoded for singled-encoded paths' do
expect(decode_path('%2Fhome%2Fbar%3Fasd%3Dqwe')).to eq('/home/bar?asd=qwe')
end
it 'returns path when it is unencoded' do
expect(decode_path('/home/bar?asd=qwe')).to eq('/home/bar?asd=qwe')
end
[
'..%252F..%252F..%252Fetc%252Fpasswd',
'%25252Fresult%25252Fchosennickname%25253D%252522jj%252522'
].each do |multiple_encoded_path|
it 'raises an exception when the path is multiple-encoded' do
expect { decode_path(multiple_encoded_path) }.to raise_error(/path #{multiple_encoded_path} is not allowed/)
end
end end
end end
......
...@@ -10,6 +10,7 @@ RSpec.describe API::Files do ...@@ -10,6 +10,7 @@ RSpec.describe API::Files do
let(:guest) { create(:user) { |u| project.add_guest(u) } } let(:guest) { create(:user) { |u| project.add_guest(u) } }
let(:file_path) { "files%2Fruby%2Fpopen%2Erb" } let(:file_path) { "files%2Fruby%2Fpopen%2Erb" }
let(:rouge_file_path) { "%2e%2e%2f" } let(:rouge_file_path) { "%2e%2e%2f" }
let(:absolute_path) { "%2Fetc%2Fpasswd.rb" }
let(:invalid_file_message) { 'file_path should be a valid file path' } let(:invalid_file_message) { 'file_path should be a valid file path' }
let(:params) do let(:params) do
{ {
...@@ -57,6 +58,18 @@ RSpec.describe API::Files do ...@@ -57,6 +58,18 @@ RSpec.describe API::Files do
end end
end end
shared_examples 'when path is absolute' do
it 'returns 400 when file path is absolute' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
if response.body.present?
expect(json_response['error']).to eq(invalid_file_message)
end
end
end
describe "HEAD /projects/:id/repository/files/:file_path" do describe "HEAD /projects/:id/repository/files/:file_path" do
shared_examples_for 'repository files' do shared_examples_for 'repository files' do
it 'returns 400 when file path is invalid' do it 'returns 400 when file path is invalid' do
...@@ -65,6 +78,10 @@ RSpec.describe API::Files do ...@@ -65,6 +78,10 @@ RSpec.describe API::Files do
expect(response).to have_gitlab_http_status(:bad_request) expect(response).to have_gitlab_http_status(:bad_request)
end end
it_behaves_like 'when path is absolute' do
subject { head api(route(absolute_path), current_user), params: params }
end
it 'returns file attributes in headers' do it 'returns file attributes in headers' do
head api(route(file_path), current_user), params: params head api(route(file_path), current_user), params: params
...@@ -165,6 +182,10 @@ RSpec.describe API::Files do ...@@ -165,6 +182,10 @@ RSpec.describe API::Files do
expect(json_response['error']).to eq(invalid_file_message) expect(json_response['error']).to eq(invalid_file_message)
end end
it_behaves_like 'when path is absolute' do
subject { get api(route(absolute_path), api_user), params: params }
end
it 'returns file attributes as json' do it 'returns file attributes as json' do
get api(route(file_path), api_user), params: params get api(route(file_path), api_user), params: params
...@@ -350,6 +371,10 @@ RSpec.describe API::Files do ...@@ -350,6 +371,10 @@ RSpec.describe API::Files do
expect(json_response['error']).to eq(invalid_file_message) expect(json_response['error']).to eq(invalid_file_message)
end end
it_behaves_like 'when path is absolute' do
subject { get api(route(absolute_path) + '/blame', current_user), params: params }
end
it 'returns blame file attributes as json' do it 'returns blame file attributes as json' do
get api(route(file_path) + '/blame', current_user), params: params get api(route(file_path) + '/blame', current_user), params: params
...@@ -473,6 +498,10 @@ RSpec.describe API::Files do ...@@ -473,6 +498,10 @@ RSpec.describe API::Files do
expect(json_response['error']).to eq(invalid_file_message) expect(json_response['error']).to eq(invalid_file_message)
end end
it_behaves_like 'when path is absolute' do
subject { get api(route(absolute_path) + '/raw', current_user), params: params }
end
it 'returns raw file info' do it 'returns raw file info' do
url = route(file_path) + "/raw" url = route(file_path) + "/raw"
expect(Gitlab::Workhorse).to receive(:send_git_blob) expect(Gitlab::Workhorse).to receive(:send_git_blob)
...@@ -597,6 +626,10 @@ RSpec.describe API::Files do ...@@ -597,6 +626,10 @@ RSpec.describe API::Files do
expect(json_response['error']).to eq(invalid_file_message) expect(json_response['error']).to eq(invalid_file_message)
end end
it_behaves_like 'when path is absolute' do
subject { post api(route(absolute_path), user), params: params }
end
it "creates a new file in project repo" do it "creates a new file in project repo" do
post api(route(file_path), user), params: params post api(route(file_path), user), params: params
...@@ -735,6 +768,16 @@ RSpec.describe API::Files do ...@@ -735,6 +768,16 @@ RSpec.describe API::Files do
expect(json_response['error']).to eq(invalid_file_message) expect(json_response['error']).to eq(invalid_file_message)
end end
it_behaves_like 'when path is absolute' do
let(:last_commit) do
Gitlab::Git::Commit
.last_for_path(project.repository, 'master', URI.unescape(file_path))
end
let(:params_with_correct_id) { params.merge(last_commit_id: last_commit.id) }
subject { put api(route(absolute_path), user), params: params_with_correct_id }
end
it "returns a 400 bad request if no params given" do it "returns a 400 bad request if no params given" do
put api(route(file_path), user) put api(route(file_path), user)
...@@ -770,6 +813,10 @@ RSpec.describe API::Files do ...@@ -770,6 +813,10 @@ RSpec.describe API::Files do
expect(json_response['error']).to eq(invalid_file_message) expect(json_response['error']).to eq(invalid_file_message)
end end
it_behaves_like 'when path is absolute' do
subject { delete api(route(absolute_path), user), params: params }
end
it "deletes existing file in project repo" do it "deletes existing file in project repo" do
delete api(route(file_path), user), params: params delete api(route(file_path), user), params: params
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment