diff options
Diffstat (limited to 'lib/gitlab_shell.rb')
-rw-r--r-- | lib/gitlab_shell.rb | 309 |
1 files changed, 245 insertions, 64 deletions
diff --git a/lib/gitlab_shell.rb b/lib/gitlab_shell.rb index bd7b783..2057ea9 100644 --- a/lib/gitlab_shell.rb +++ b/lib/gitlab_shell.rb @@ -3,120 +3,301 @@ require 'pathname' require_relative 'gitlab_net' require_relative 'gitlab_metrics' -require_relative 'actor' -class GitlabShell - API_2FA_RECOVERY_CODES_COMMAND = '2fa_recovery_codes'.freeze +class GitlabShell # rubocop:disable Metrics/ClassLength + class AccessDeniedError < StandardError; end + class DisallowedCommandError < StandardError; end + class InvalidRepositoryPathError < StandardError; end - GIT_UPLOAD_PACK_COMMAND = 'git-upload-pack'.freeze - GIT_RECEIVE_PACK_COMMAND = 'git-receive-pack'.freeze - GIT_UPLOAD_ARCHIVE_COMMAND = 'git-upload-archive'.freeze - GIT_LFS_AUTHENTICATE_COMMAND = 'git-lfs-authenticate'.freeze + GIT_COMMANDS = %w(git-upload-pack git-receive-pack git-upload-archive git-lfs-authenticate).freeze + GITALY_MIGRATED_COMMANDS = { + 'git-upload-pack' => File.join(ROOT_PATH, 'bin', 'gitaly-upload-pack'), + 'git-upload-archive' => File.join(ROOT_PATH, 'bin', 'gitaly-upload-archive'), + 'git-receive-pack' => File.join(ROOT_PATH, 'bin', 'gitaly-receive-pack') + }.freeze + API_COMMANDS = %w(2fa_recovery_codes).freeze + GL_PROTOCOL = 'ssh'.freeze - GIT_COMMANDS = [GIT_UPLOAD_PACK_COMMAND, GIT_RECEIVE_PACK_COMMAND, - GIT_UPLOAD_ARCHIVE_COMMAND, GIT_LFS_AUTHENTICATE_COMMAND].freeze - - Struct.new('ParsedCommand', :command, :git_access_command, :repo_name, :args) + attr_accessor :gl_id, :gl_repository, :repo_name, :command, :git_access, :git_protocol + attr_reader :repo_path def initialize(who) + who_sym, = GitlabNet.parse_who(who) + if who_sym == :username + @who = who + else + @gl_id = who + end @config = GitlabConfig.new - @actor = Actor.new_from(who, audit_usernames: @config.audit_usernames) 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) - if !origin_cmd || origin_cmd.empty? - puts "Welcome to GitLab, #{actor.username}!" + unless origin_cmd + puts "Welcome to GitLab, #{username}!" return true end - parsed_command = parse_cmd(origin_cmd) - action = determine_action(parsed_command) - action.execute(parsed_command.command, parsed_command.args) + args = Shellwords.shellwords(origin_cmd) + args = parse_cmd(args) + + if GIT_COMMANDS.include?(args.first) + GitlabMetrics.measure('verify-access') { verify_access } + 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 + + process_cmd(args) + + true rescue GitlabNet::ApiUnreachableError $stderr.puts "GitLab: Failed to authorize your Git request: internal API unreachable" false - rescue AccessDeniedError, UnknownError => ex - $logger.warn('Access denied', command: origin_cmd, user: actor.log_username) + rescue AccessDeniedError => ex + $logger.warn('Access denied', command: origin_cmd, user: log_username) + $stderr.puts "GitLab: #{ex.message}" false rescue DisallowedCommandError - $logger.warn('Denied disallowed command', command: origin_cmd, user: actor.log_username) - $stderr.puts 'GitLab: Disallowed command' + $logger.warn('Denied disallowed command', command: origin_cmd, user: log_username) + + $stderr.puts "GitLab: Disallowed command" false rescue InvalidRepositoryPathError - $stderr.puts 'GitLab: Invalid repository path' + $stderr.puts "GitLab: Invalid repository path" false end - private - - attr_reader :config, :actor - - def parse_cmd(cmd) - args = Shellwords.shellwords(cmd) + 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] + @command = "git-#{args[1]}" + args = [@command, args.last] else - command = args.first + @command = args.first end - git_access_command = command + @git_access = @command - if command == API_2FA_RECOVERY_CODES_COMMAND - return Struct::ParsedCommand.new(command, git_access_command, nil, args) - end + return args if API_COMMANDS.include?(@command) - raise DisallowedCommandError unless GIT_COMMANDS.include?(command) + raise DisallowedCommandError unless GIT_COMMANDS.include?(@command) - case command + case @command when 'git-lfs-authenticate' raise DisallowedCommandError unless args.count >= 2 - repo_name = args[1] - git_access_command = case args[2] - when 'download' - GIT_UPLOAD_PACK_COMMAND - when 'upload' - GIT_RECEIVE_PACK_COMMAND - else - raise DisallowedCommandError - end + @repo_name = args[1] + case args[2] + when 'download' + @git_access = 'git-upload-pack' + when 'upload' + @git_access = 'git-receive-pack' + else + raise DisallowedCommandError + end else raise DisallowedCommandError unless args.count == 2 - repo_name = args.last + @repo_name = args.last end - Struct::ParsedCommand.new(command, git_access_command, repo_name, args) + args end - def determine_action(parsed_command) - return Action::API2FARecovery.new(actor) if parsed_command.command == API_2FA_RECOVERY_CODES_COMMAND + 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? + + self.repo_path = status.repository_path + @gl_repository = status.gl_repository + @git_protocol = ENV['GIT_PROTOCOL'] + @gitaly = status.gitaly + @username = status.gl_username + @git_config_options = status.git_config_options + if defined?(@who) + @gl_id = status.gl_id + end + end - GitlabMetrics.measure('verify-access') do - # GitlabNet#check_access will raise exception in the event of a problem - initial_action = api.check_access( - parsed_command.git_access_command, - nil, - parsed_command.repo_name, - actor, - '_any' + def process_cmd(args) + return send("api_#{@command}") if API_COMMANDS.include?(@command) + + if @command == 'git-lfs-authenticate' + GitlabMetrics.measure('lfs-authenticate') do + $logger.info('Processing LFS authentication', user: log_username) + lfs_authenticate + end + return + end + + executable = @command + args = [repo_path] + + if GITALY_MIGRATED_COMMANDS.key?(executable) && @gitaly + executable = GITALY_MIGRATED_COMMANDS[executable] + + gitaly_address = @gitaly['address'] + + # The entire gitaly_request hash should be built in gitlab-ce and passed + # on as-is. For now we build a fake one on the spot. + gitaly_request = { + 'repository' => @gitaly['repository'], + 'gl_repository' => @gl_repository, + 'gl_id' => @gl_id, + 'gl_username' => @username, + 'git_config_options' => @git_config_options, + 'git_protocol' => @git_protocol + } + + args = [gitaly_address, JSON.dump(gitaly_request)] + end + + args_string = [File.basename(executable), *args].join(' ') + $logger.info('executing git command', command: args_string, user: log_username) + exec_cmd(executable, *args) + end + + # This method is not covered by Rspec because it ends the current Ruby process. + def exec_cmd(*args) + # If you want to call a command without arguments, use + # exec_cmd(['my_command', 'my_command']) . Otherwise use + # exec_cmd('my_command', 'my_argument', ...). + if args.count == 1 && !args.first.is_a?(Array) + raise DisallowedCommandError + end + + env = { + 'HOME' => ENV['HOME'], + 'PATH' => ENV['PATH'], + 'LD_LIBRARY_PATH' => ENV['LD_LIBRARY_PATH'], + 'LANG' => ENV['LANG'], + 'GL_ID' => @gl_id, + 'GL_PROTOCOL' => GL_PROTOCOL, + 'GL_REPOSITORY' => @gl_repository, + 'GL_USERNAME' => @username + } + if @gitaly && @gitaly.include?('token') + env['GITALY_TOKEN'] = @gitaly['token'] + end + + if git_trace_available? + env.merge!( + 'GIT_TRACE' => @config.git_trace_log_file, + 'GIT_TRACE_PACKET' => @config.git_trace_log_file, + 'GIT_TRACE_PERFORMANCE' => @config.git_trace_log_file ) + end - case parsed_command.command - when GIT_LFS_AUTHENTICATE_COMMAND - Action::GitLFSAuthenticate.new(actor, parsed_command.repo_name) + # 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']}" else - initial_action + @user = api.discover(@gl_id) end + rescue GitlabNet::ApiUnreachableError + @user = nil end end - def api - @api ||= GitlabNet.new + 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 + lfs_access = api.lfs_authenticate(@gl_id, @repo_name) + + 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 + + def git_trace_available? + return false unless @config.git_trace_log_file + + if Pathname(@config.git_trace_log_file).relative? + $logger.warn('git trace log path must be absolute, ignoring', git_trace_log_file: @config.git_trace_log_file) + return false + end + + begin + File.open(@config.git_trace_log_file, 'a') { nil } + return true + rescue => ex + $logger.warn('Failed to open git trace log file', git_trace_log_file: @config.git_trace_log_file, error: ex.to_s) + return false + end + end + + def repo_path=(repo_path) + raise ArgumentError, "Repository path not provided. Please make sure you're using GitLab v8.10 or later." unless repo_path + raise InvalidRepositoryPathError if File.absolute_path(repo_path) != repo_path + + @repo_path = repo_path end end |