summaryrefslogtreecommitdiff
path: root/lib/gitlab/middleware/read_only/controller.rb
blob: b11ee0afc102f0e69f0d12a7a499e35a52bfe19d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# frozen_string_literal: true

module Gitlab
  module Middleware
    class ReadOnly
      class Controller
        DISALLOWED_METHODS = %w(POST PATCH PUT DELETE).freeze
        APPLICATION_JSON = 'application/json'
        APPLICATION_JSON_TYPES = %W{#{APPLICATION_JSON} application/vnd.git-lfs+json}.freeze
        ERROR_MESSAGE = 'You cannot perform write operations on a read-only instance'

        ALLOWLISTED_GIT_READ_ONLY_ROUTES = {
          'repositories/git_http' => %w{git_upload_pack}
        }.freeze

        ALLOWLISTED_GIT_LFS_BATCH_ROUTES = {
          'repositories/lfs_api' => %w{batch}
        }.freeze

        ALLOWLISTED_GIT_REVISION_ROUTES = {
          'projects/compare' => %w{create}
        }.freeze

        ALLOWLISTED_SESSION_ROUTES = {
          'sessions' => %w{destroy},
          'admin/sessions' => %w{create destroy}
        }.freeze

        GRAPHQL_URL = '/api/graphql'

        def initialize(app, env)
          @app = app
          @env = env
        end

        def call
          if disallowed_request? && read_only?
            Gitlab::AppLogger.debug('GitLab ReadOnly: preventing possible non read-only operation')

            if json_request?
              return [403, { 'Content-Type' => APPLICATION_JSON }, [{ 'message' => ERROR_MESSAGE }.to_json]]
            else
              rack_flash.alert = ERROR_MESSAGE
              rack_session['flash'] = rack_flash.to_session_value

              return [301, { 'Location' => last_visited_url }, []]
            end
          end

          @app.call(@env)
        end

        private

        def disallowed_request?
          DISALLOWED_METHODS.include?(@env['REQUEST_METHOD']) &&
            !allowlisted_routes
        end

        # Overridden in EE module
        def read_only?
          Gitlab::Database.read_only?
        end

        def json_request?
          APPLICATION_JSON_TYPES.include?(request.media_type)
        end

        def rack_flash
          @rack_flash ||= ActionDispatch::Flash::FlashHash.from_session_value(rack_session)
        end

        def rack_session
          @env['rack.session']
        end

        def request
          @env['actionpack.request'] ||= ActionDispatch::Request.new(@env)
        end

        def last_visited_url
          @env['HTTP_REFERER'] || rack_session['user_return_to'] || Gitlab::Routing.url_helpers.root_url
        end

        def route_hash
          @route_hash ||= Rails.application.routes.recognize_path(request.url, { method: request.request_method }) rescue {}
        end

        def relative_url
          File.join('', Gitlab.config.gitlab.relative_url_root).chomp('/')
        end

        # Overridden in EE module
        def allowlisted_routes
          workhorse_passthrough_route? || internal_route? || lfs_batch_route? || compare_git_revisions_route? || sidekiq_route? || session_route? || graphql_query?
        end

        # URL for requests passed through gitlab-workhorse to rails-web
        # https://gitlab.com/gitlab-org/gitlab-workhorse/-/merge_requests/12
        def workhorse_passthrough_route?
          # Calling route_hash may be expensive. Only do it if we think there's a possible match
          return false unless request.post? &&
            request.path.end_with?('.git/git-upload-pack')

          ALLOWLISTED_GIT_READ_ONLY_ROUTES[route_hash[:controller]]&.include?(route_hash[:action])
        end

        def internal_route?
          ReadOnly.internal_routes.any? { |path| request.path.include?(path) }
        end

        def compare_git_revisions_route?
          # Calling route_hash may be expensive. Only do it if we think there's a possible match
          return false unless request.post? && request.path.end_with?('compare')

          ALLOWLISTED_GIT_REVISION_ROUTES[route_hash[:controller]]&.include?(route_hash[:action])
        end

        # Batch upload requests are blocked in:
        # https://gitlab.com/gitlab-org/gitlab/blob/master/app/controllers/repositories/lfs_api_controller.rb#L106
        def lfs_batch_route?
          # Calling route_hash may be expensive. Only do it if we think there's a possible match
          return unless request.path.end_with?('/info/lfs/objects/batch')

          ALLOWLISTED_GIT_LFS_BATCH_ROUTES[route_hash[:controller]]&.include?(route_hash[:action])
        end

        def session_route?
          # Calling route_hash may be expensive. Only do it if we think there's a possible match
          return false unless request.post? && request.path.end_with?('/users/sign_out',
            '/admin/session', '/admin/session/destroy')

          ALLOWLISTED_SESSION_ROUTES[route_hash[:controller]]&.include?(route_hash[:action])
        end

        def sidekiq_route?
          request.path.start_with?("#{relative_url}/admin/sidekiq")
        end

        def graphql_query?
          request.post? && request.path.start_with?(File.join(relative_url, GRAPHQL_URL))
        end
      end
    end
  end
end

Gitlab::Middleware::ReadOnly::Controller.prepend_if_ee('EE::Gitlab::Middleware::ReadOnly::Controller')