diff --git a/Gemfile b/Gemfile index 4bc89628756a4f3523ae3467542d24e7b3a409ef..fd90f2ba6fa018c848bf72a6ff7b1abd83513dfd 100644 --- a/Gemfile +++ b/Gemfile @@ -172,6 +172,9 @@ gem 'rainbow', '~> 2.2' # GitLab settings gem 'settingslogic', '~> 2.0.9' +# Linear-time regex library for untrusted regular expressions +gem 're2', '~> 1.0.0' + # Misc gem 'version_sorter', '~> 2.1.0' diff --git a/Gemfile.lock b/Gemfile.lock index 9f8962b87afd4fdac6bfe342ec4fdac6d9d54491..e12014cbceba9a2df7bfd486d715eb25873c1f74 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -689,6 +689,7 @@ GEM debugger-ruby_core_source (~> 1.3) rdoc (4.2.2) json (~> 1.4) + re2 (1.0.0) recaptcha (3.0.0) json recursive-open-struct (1.0.0) @@ -1097,6 +1098,7 @@ DEPENDENCIES raindrops (~> 0.18) rblineprof (~> 0.3.6) rdoc (~> 4.2) + re2 (~> 1.0.0) recaptcha (~> 3.0) redcarpet (~> 3.4) redis (~> 3.2) diff --git a/lib/gitlab/ci/trace/stream.rb b/lib/gitlab/ci/trace/stream.rb index c4c0623df6c559df9e761da009dee6dd77662a5d..5d6977106d6ff5553cb9fbfbcd1397854540903f 100644 --- a/lib/gitlab/ci/trace/stream.rb +++ b/lib/gitlab/ci/trace/stream.rb @@ -69,12 +69,12 @@ def extract_coverage(regex) return unless valid? return unless regex - regex = Regexp.new(regex) + regex = Gitlab::UntrustedRegexp.new(regex) match = "" reverse_line do |line| - matches = line.scan(regex) + matches = regex.scan(line) next unless matches.is_a?(Array) next if matches.empty? diff --git a/lib/gitlab/route_map.rb b/lib/gitlab/route_map.rb index 877aa6e6a280a24b52439aab8a338e9ae8877730..f395265798306d86a36fcda738ad495b2da697df 100644 --- a/lib/gitlab/route_map.rb +++ b/lib/gitlab/route_map.rb @@ -18,7 +18,11 @@ def public_path_for_source_path(path) mapping = @map.find { |mapping| mapping[:source] === path } return unless mapping - path.sub(mapping[:source], mapping[:public]) + if mapping[:source].is_a?(String) + path.sub(mapping[:source], mapping[:public]) + else + mapping[:source].replace(path, mapping[:public]) + end end private @@ -35,7 +39,7 @@ def parse_entry(entry) source_pattern = source_pattern[1...-1].gsub('\/', '/') begin - source_pattern = /\A#{source_pattern}\z/ + source_pattern = Gitlab::UntrustedRegexp.new('\A' + source_pattern + '\z') rescue RegexpError => e raise FormatError, "Route map entry source is not a valid regular expression: #{e}" end diff --git a/lib/gitlab/untrusted_regexp.rb b/lib/gitlab/untrusted_regexp.rb new file mode 100644 index 0000000000000000000000000000000000000000..8b43f0053d67e7873f2b537146142d212eb0dcd5 --- /dev/null +++ b/lib/gitlab/untrusted_regexp.rb @@ -0,0 +1,53 @@ +module Gitlab + # An untrusted regular expression is any regexp containing patterns sourced + # from user input. + # + # Ruby's built-in regular expression library allows patterns which complete in + # exponential time, permitting denial-of-service attacks. + # + # Not all regular expression features are available in untrusted regexes, and + # there is a strict limit on total execution time. See the RE2 documentation + # at https://github.com/google/re2/wiki/Syntax for more details. + class UntrustedRegexp + delegate :===, to: :regexp + + def initialize(pattern) + @regexp = RE2::Regexp.new(pattern, log_errors: false) + + raise RegexpError.new(regexp.error) unless regexp.ok? + end + + def replace_all(text, rewrite) + RE2.GlobalReplace(text, regexp, rewrite) + end + + def scan(text) + scan_regexp.scan(text).map do |match| + if regexp.number_of_capturing_groups == 0 + match.first + else + match + end + end + end + + def replace(text, rewrite) + RE2.Replace(text, regexp, rewrite) + end + + private + + attr_reader :regexp + + # RE2 scan operates differently to Ruby scan when there are no capture + # groups, so work around it + def scan_regexp + @scan_regexp ||= + if regexp.number_of_capturing_groups == 0 + RE2::Regexp.new('(' + regexp.source + ')') + else + regexp + end + end + end +end diff --git a/spec/lib/gitlab/ci/trace/stream_spec.rb b/spec/lib/gitlab/ci/trace/stream_spec.rb index bbb3f9912a3e92401c0e86ba2af992fa7b194c1b..13f0338b6aadce7ebcb49a0aee23468507e5825b 100644 --- a/spec/lib/gitlab/ci/trace/stream_spec.rb +++ b/spec/lib/gitlab/ci/trace/stream_spec.rb @@ -293,5 +293,12 @@ it { is_expected.to eq("65") } end + + context 'malicious regexp' do + let(:data) { malicious_text } + let(:regex) { malicious_regexp } + + include_examples 'malicious regexp' + end end end diff --git a/spec/lib/gitlab/route_map_spec.rb b/spec/lib/gitlab/route_map_spec.rb index 21c00c6e5b80a3c511bb74e5364c54cbc4499ff2..e8feb21e4d7e7f2898122a4b67c0a2f7966349c5 100644 --- a/spec/lib/gitlab/route_map_spec.rb +++ b/spec/lib/gitlab/route_map_spec.rb @@ -55,6 +55,19 @@ end describe '#public_path_for_source_path' do + context 'malicious regexp' do + include_examples 'malicious regexp' + + subject do + map = described_class.new(<<-"MAP".strip_heredoc) + - source: '#{malicious_regexp}' + public: '/' + MAP + + map.public_path_for_source_path(malicious_text) + end + end + subject do described_class.new(<<-'MAP'.strip_heredoc) # Team data diff --git a/spec/lib/gitlab/untrusted_regexp_spec.rb b/spec/lib/gitlab/untrusted_regexp_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..66045917cb3d3435dfdbd6d84e388c8ce326435c --- /dev/null +++ b/spec/lib/gitlab/untrusted_regexp_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper' + +describe Gitlab::UntrustedRegexp do + describe '#initialize' do + subject { described_class.new(pattern) } + + context 'invalid regexp' do + let(:pattern) { '[' } + + it { expect { subject }.to raise_error(RegexpError) } + end + end + + describe '#replace_all' do + it 'replaces all instances of the match in a string' do + result = described_class.new('foo').replace_all('foo bar foo', 'oof') + + expect(result).to eq('oof bar oof') + end + end + + describe '#replace' do + it 'replaces the first instance of the match in a string' do + result = described_class.new('foo').replace('foo bar foo', 'oof') + + expect(result).to eq('oof bar foo') + end + end + + describe '#===' do + it 'returns true for a match' do + result = described_class.new('foo') === 'a foo here' + + expect(result).to be_truthy + end + + it 'returns false for no match' do + result = described_class.new('foo') === 'a bar here' + + expect(result).to be_falsy + end + end + + describe '#scan' do + subject { described_class.new(regexp).scan(text) } + context 'malicious regexp' do + let(:text) { malicious_text } + let(:regexp) { malicious_regexp } + + include_examples 'malicious regexp' + end + + context 'no capture group' do + let(:regexp) { '.+' } + let(:text) { 'foo' } + + it 'returns the whole match' do + is_expected.to eq(['foo']) + end + end + + context 'one capture group' do + let(:regexp) { '(f).+' } + let(:text) { 'foo' } + + it 'returns the captured part' do + is_expected.to eq([%w[f]]) + end + end + + context 'two capture groups' do + let(:regexp) { '(f).(o)' } + let(:text) { 'foo' } + + it 'returns the captured parts' do + is_expected.to eq([%w[f o]]) + end + end + end +end diff --git a/spec/support/malicious_regexp_shared_examples.rb b/spec/support/malicious_regexp_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..ac5d22298bbf7e3230c559a63faf18cfeb39d772 --- /dev/null +++ b/spec/support/malicious_regexp_shared_examples.rb @@ -0,0 +1,8 @@ +shared_examples 'malicious regexp' do + let(:malicious_text) { 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!' } + let(:malicious_regexp) { '(?i)^(([a-z])+.)+[A-Z]([a-z])+$' } + + it 'takes under a second' do + expect { Timeout.timeout(1) { subject } }.not_to raise_error + end +end