# frozen_string_literal: true require 'shellwords' require 'pathname' require_relative 'gitlab_net' require_relative 'gitlab_metrics' require_relative 'action' require_relative 'console_helper' class GitlabShell # rubocop:disable Metrics/ClassLength include ConsoleHelper class AccessDeniedError < StandardError; end class DisallowedCommandError < StandardError; end class InvalidRepositoryPathError < StandardError; end GIT_UPLOAD_PACK_COMMAND = 'git-upload-pack' GIT_RECEIVE_PACK_COMMAND = 'git-receive-pack' GIT_UPLOAD_ARCHIVE_COMMAND = 'git-upload-archive' GIT_LFS_AUTHENTICATE_COMMAND = 'git-lfs-authenticate' GITALY_COMMANDS = { GIT_UPLOAD_PACK_COMMAND => File.join(ROOT_PATH, 'bin', 'gitaly-upload-pack'), GIT_UPLOAD_ARCHIVE_COMMAND => File.join(ROOT_PATH, 'bin', 'gitaly-upload-archive'), GIT_RECEIVE_PACK_COMMAND => File.join(ROOT_PATH, 'bin', 'gitaly-receive-pack') }.freeze GIT_COMMANDS = (GITALY_COMMANDS.keys + [GIT_LFS_AUTHENTICATE_COMMAND]).freeze TWO_FACTOR_RECOVERY_COMMAND = '2fa_recovery_codes' GL_PROTOCOL = 'ssh' attr_accessor :gl_id, :gl_repository, :gl_project_path, :repo_name, :command, :git_access, :git_protocol def initialize(who) who_sym, = GitlabNet.parse_who(who) if who_sym == :username @who = who else @gl_id = who end @config = GitlabConfig.new end # The origin_cmd variable contains UNTRUSTED input. If the user ran # ssh git@gitlab.example.com 'evil command', then origin_cmd contains # 'evil command'. def exec(origin_cmd) unless origin_cmd puts "Welcome to GitLab, #{username}!" return true end args = Shellwords.shellwords(origin_cmd) args = parse_cmd(args) access_status = nil if GIT_COMMANDS.include?(args.first) access_status = GitlabMetrics.measure('verify-access') { verify_access } @gl_repository = access_status.gl_repository @git_protocol = ENV['GIT_PROTOCOL'] @gl_project_path = access_status.gl_project_path @gitaly = access_status.gitaly @username = access_status.gl_username @git_config_options = access_status.git_config_options @gl_id = access_status.gl_id if defined?(@who) elsif !defined?(@gl_id) # We're processing an API command like 2fa_recovery_codes, but # don't have a @gl_id yet, that means we're in the "username" # mode and need to materialize it, calling the "user" method # will do that and call the /discover method. user end if @command == GIT_RECEIVE_PACK_COMMAND && access_status.custom_action? # If the response from /api/v4/allowed is a HTTP 300, we need to perform # a Custom Action and therefore should return and not call process_cmd() # return process_custom_action(access_status) end process_cmd(args) true rescue GitlabNet::ApiUnreachableError write_stderr('Failed to authorize your Git request: internal API unreachable') false rescue AccessDeniedError => ex $logger.warn('Access denied', command: origin_cmd, user: log_username) write_stderr(ex.message) false rescue DisallowedCommandError $logger.warn('Denied disallowed command', command: origin_cmd, user: log_username) write_stderr('Disallowed command') false rescue InvalidRepositoryPathError write_stderr('Invalid repository path') false rescue Action::Custom::BaseError => ex $logger.warn('Custom action error', exception: ex.class, message: ex.message, command: origin_cmd, user: log_username) $stderr.puts ex.message false end protected def parse_cmd(args) # Handle Git for Windows 2.14 using "git upload-pack" instead of git-upload-pack if args.length == 3 && args.first == 'git' @command = "git-#{args[1]}" args = [@command, args.last] else @command = args.first end @git_access = @command return args if TWO_FACTOR_RECOVERY_COMMAND == @command raise DisallowedCommandError unless GIT_COMMANDS.include?(@command) case @command when GIT_LFS_AUTHENTICATE_COMMAND raise DisallowedCommandError unless args.count >= 2 @repo_name = args[1] case args[2] when 'download' @git_access = GIT_UPLOAD_PACK_COMMAND when 'upload' @git_access = GIT_RECEIVE_PACK_COMMAND else raise DisallowedCommandError end else raise DisallowedCommandError unless args.count == 2 @repo_name = args.last end args end def verify_access status = api.check_access(@git_access, nil, @repo_name, @who || @gl_id, '_any', GL_PROTOCOL) raise AccessDeniedError, status.message unless status.allowed? status end def process_custom_action(access_status) Action::Custom.new(@gl_id, access_status.payload).execute end def process_cmd(args) return api_2fa_recovery_codes if TWO_FACTOR_RECOVERY_COMMAND == @command if @command == GIT_LFS_AUTHENTICATE_COMMAND GitlabMetrics.measure('lfs-authenticate') do operation = args[2] $logger.info('Processing LFS authentication', operation: operation, user: log_username) lfs_authenticate(operation) end return end # TODO: instead of building from pieces here in gitlab-shell, build the # entire gitaly_request in gitlab-ce and pass on as-is here. args = JSON.dump( 'repository' => @gitaly['repository'], 'gl_repository' => @gl_repository, 'gl_project_path' => @gl_project_path, 'gl_id' => @gl_id, 'gl_username' => @username, 'git_config_options' => @git_config_options, 'git_protocol' => @git_protocol ) gitaly_address = @gitaly['address'] executable = GITALY_COMMANDS.fetch(@command) gitaly_bin = File.basename(executable) args_string = [gitaly_bin, gitaly_address, args].join(' ') $logger.info('executing git command', command: args_string, user: log_username) exec_cmd(executable, gitaly_address: gitaly_address, token: @gitaly['token'], json_args: args) end # This method is not covered by Rspec because it ends the current Ruby process. def exec_cmd(executable, gitaly_address:, token:, json_args:) env = { 'GITALY_TOKEN' => token } args = [executable, gitaly_address, json_args] # We use 'chdir: ROOT_PATH' to let the next executable know where config.yml is. Kernel.exec(env, *args, unsetenv_others: true, chdir: ROOT_PATH) end def api GitlabNet.new end def user return @user if defined?(@user) begin if defined?(@who) @user = api.discover(@who) @gl_id = "user-#{@user['id']}" if @user && @user.key?('id') else @user = api.discover(@gl_id) end rescue GitlabNet::ApiUnreachableError @user = nil end end def username_from_discover return nil unless user && user['username'] "@#{user['username']}" end def username @username ||= username_from_discover || 'Anonymous' end # User identifier to be used in log messages. def log_username @config.audit_usernames ? username : "user with id #{@gl_id}" end def lfs_authenticate(operation) lfs_access = api.lfs_authenticate(@gl_id, @repo_name, operation) return unless lfs_access puts lfs_access.authentication_payload end private def continue?(question) puts "#{question} (yes/no)" STDOUT.flush # Make sure the question gets output before we wait for input continue = STDIN.gets.chomp puts '' # Add a buffer in the output continue == 'yes' end def api_2fa_recovery_codes continue = continue?( "Are you sure you want to generate new two-factor recovery codes?\n" \ "Any existing recovery codes you saved will be invalidated." ) unless continue puts 'New recovery codes have *not* been generated. Existing codes will remain valid.' return end resp = api.two_factor_recovery_codes(@gl_id) if resp['success'] codes = resp['recovery_codes'].join("\n") puts "Your two-factor authentication recovery codes are:\n\n" \ "#{codes}\n\n" \ "During sign in, use one of the codes above when prompted for\n" \ "your two-factor code. Then, visit your Profile Settings and add\n" \ "a new device so you do not lose access to your account again." else puts "An error occurred while trying to generate new recovery codes.\n" \ "#{resp['message']}" end end end