summaryrefslogtreecommitdiff
path: root/lib/gitlab/middleware/read_only/controller.rb
blob: cdab86540f8357e2d5464c15368b6b566a8ae362 (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
# frozen_string_literal: true

module Gitlab
  module Middleware
    class ReadOnly
      class Controller
        prepend_if_ee('EE::Gitlab::Middleware::ReadOnly::Controller') # rubocop: disable Cop/InjectEnterpriseEditionModule

        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'

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

        WHITELISTED_GIT_LFS_ROUTES = {
          'repositories/lfs_api' => %w{batch},
          'repositories/lfs_locks_api' => %w{verify create unlock}
        }.freeze

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

        WHITELISTED_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? && Gitlab::Database.read_only?
            Rails.logger.debug('GitLab ReadOnly: preventing possible non read-only operation') # rubocop:disable Gitlab/RailsLogger

            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']) &&
            !whitelisted_routes
        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 whitelisted_routes
          workhorse_passthrough_route? || internal_route? || lfs_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', '.git/git-receive-pack')

          WHITELISTED_GIT_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')

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

        def lfs_route?
          # Calling route_hash may be expensive. Only do it if we think there's a possible match
          unless request.path.end_with?('/info/lfs/objects/batch',
            '/info/lfs/locks', '/info/lfs/locks/verify') ||
              %r{/info/lfs/locks/\d+/unlock\z}.match?(request.path)
            return false
          end

          WHITELISTED_GIT_LFS_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')

          WHITELISTED_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?(GRAPHQL_URL)
        end
      end
    end
  end
end