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