#! /usr/bin/env perl # vim: set filetype=perl ts=4 sw=4 sts=4 et: use common::sense; use MIME::Base64; use IO::Compress::Gzip qw{ gzip }; use Sys::Hostname (); use File::Basename; use lib dirname(__FILE__) . '/../../../lib/perl'; use OVH::Result; use OVH::Bastion; use OVH::Bastion::Plugin qw( :DEFAULT ); # stdout is used by scp, so ensure we output everything through stderr local $ENV{'FORCE_STDERR'} = 1; # don't output fancy stuff, this can get digested by scp and we get garbage output local $ENV{'PLUGIN_QUIET'} = 1; my ($scpCmd); my $remainingOptions = OVH::Bastion::Plugin::begin( argv => \@ARGV, header => undef, options => {'scp-cmd=s' => \$scpCmd}, help => \&help, ); sub help { delete $ENV{'FORCE_STDERR'}; delete $ENV{'PLUGIN_QUIET'}; osh_header("scp"); my $bastionCommand = OVH::Bastion::config('bastionCommand')->value; my $bastionName = OVH::Bastion::config('bastionName')->value; $bastionCommand =~ s/USER|ACCOUNT/$self/g; $bastionCommand =~ s/CACHENAME|BASTIONNAME/$bastionName/g; my $hostname = Sys::Hostname::hostname(); $bastionCommand =~ s/HOSTNAME/$hostname/g; # for scp, if the bastionCommand contains -t, we need to get rid of it $bastionCommand =~ s/ -t( |$)/$1/; # same thing for -- $bastionCommand =~ s/ --/ /; my $script = <<'END_OF_SCRIPT'; #! /usr/bin/env bash set -u # SELF="%SELF%" BASTION_CMD="%BASTION_CMD%" VERSION="%VERSION%" # : "${BASTION_SCP_DEBUG:=}" [ "$BASTION_SCP_DEBUG" = 1 ] && echo "scpwrapper: args: $*" >&2 BASTION_SSH_EXTRA_ARGS="" BASTION_SCP_EXTRA_ARGS="" LOCAL_PATH="" REMOTE_HOST="" REMOTE_PORT=22 REMOTE_USER="$SELF" REMOTE_PATH="" usage() { cat >&2 <&2 exit 1 fi BASTION_SSH_EXTRA_ARGS="$BASTION_SSH_EXTRA_ARGS -i $2" BASTION_SCP_EXTRA_ARGS="$BASTION_SCP_EXTRA_ARGS -i $2" shift 2;; "-o") if [ -z "${2:-}" ]; then echo "scpwrapper: missing argument after '-o'" >&2 exit 1 fi BASTION_SSH_EXTRA_ARGS="$BASTION_SSH_EXTRA_ARGS -o $2" BASTION_SCP_EXTRA_ARGS="$BASTION_SCP_EXTRA_ARGS -o $2" shift 2;; "-P") if [ -z "${2:-}" ]; then echo "scpwrapper: missing argument after '-P'" >&2 exit 1 fi REMOTE_PORT="$2" shift 2;; "-r") BASTION_SCP_EXTRA_ARGS="$BASTION_SCP_EXTRA_ARGS -r" shift;; "-*") echo "scpwrapper: unsupported option '$1'" >&2 exit 1;; *) break;; esac done # here, we should have SRC in $1, and DST in $2 [ $# -ne 2 ] && usage src="${1:-}" dst="${2:-}" way="" if [[ $src =~ : ]]; then # it's a download if [[ $src =~ ^(([^@:]*)@)?([^@:]+):(.*)?$ ]]; then REMOTE_USER="${BASH_REMATCH[2]:-$SELF}" REMOTE_HOST="${BASH_REMATCH[3]}" REMOTE_PATH="${BASH_REMATCH[4]}" else echo "scpwrapper: couldn't parse source '$src'" >&2 exit 1 fi LOCAL_PATH="$dst" way=download [ "$BASTION_SCP_DEBUG" = 1 ] && echo "scpwrapper: will download '$REMOTE_PATH' from $REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT to '$LOCAL_PATH'" >&2 elif [[ $dst =~ : ]]; then # it's an upload if [[ $dst =~ ^(([^@:]*)@)?([^@:]+):(.*)?$ ]]; then REMOTE_USER="${BASH_REMATCH[2]:-$SELF}" REMOTE_HOST="${BASH_REMATCH[3]}" REMOTE_PATH="${BASH_REMATCH[4]}" else echo "scpwrapper: couldn't parse destination '$dst'" >&2 exit 1 fi LOCAL_PATH="$src" way=upload [ "$BASTION_SCP_DEBUG" = 1 ] && echo "scpwrapper: will upload '$LOCAL_PATH' to $REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT as '$REMOTE_PATH'" >&2 else echo "scpwrapper: no remote host found in your command '$src $dst'" >&2 exit 1 fi t=$(mktemp) # shellcheck disable=SC2064 trap "rm -f $t" EXIT # shellcheck disable=SC2086 [ "$BASTION_SCP_DEBUG" = 1 ] && set -x $BASTION_CMD -t $BASTION_SSH_EXTRA_ARGS -- --osh scp --host "$REMOTE_HOST" --port "$REMOTE_PORT" --user "$REMOTE_USER" --generate-mfa-token | tee "$t" [ "$BASTION_SCP_DEBUG" = 1 ] && set +x token=$(grep -Eo '^MFA_TOKEN=[a-zA-Z0-9,]+' "$t" | tail -n 1 | cut -d= -f2) if [ -z "$token" ]; then echo "scpwrapper: Couldn't get an MFA token, aborting." >&2 exit 1 fi # detect whether we need '-O' for this scp or not t2=$(mktemp) if scp -O "$t2" "$t" >/dev/null 2>&1; then BASTION_SCP_EXTRA_ARGS="$BASTION_SCP_EXTRA_ARGS -O" fi rm -f "$t2" # now craft the wrapper to be used by scp through -S cat >"$t" <<'EOF' #! /usr/bin/env bash REMOTE_USER=${USER:-} REMOTE_PORT=22 sshcmdline="" [ "$BASTION_SCP_DEBUG" = 1 ] && echo "scphelper: args: $*" >&2 while ! [ "${1:-}" = "--" ] ; do # handle several ways of specifying remote user if [ "${1:-}" = "-l" ] ; then REMOTE_USER="${2:-}" shift 2 elif [ "${1:-}" = "-p" ] ; then REMOTE_PORT="${2:-}" shift 2 elif [ "${1:-}" = "-s" ]; then # caller is a newer scp that tries to use the sftp subsystem # instead of plain old scp, warn because it won't work echo "scphelper: WARNING: your scp version is recent, you need to add '-O' to your scp command-line, exiting." >&2 exit 1 else sshcmdline="$sshcmdline $1" shift fi done [ "$BASTION_SCP_DEBUG" = 1 ] && echo "scphelper: remaining args: $*" >&2 # sane default : "${REMOTE_USER:-$USER}" [ -z "$REMOTE_USER" ] && REMOTE_USER="$(whoami)" REMOTE_HOST="$2" scpcmd=$(echo "$3" | sed -e 's/#/##/g;s/ /#/g') # and go [ "$BASTION_SCP_DEBUG" = 1 ] && set -x EOF echo "exec $BASTION_CMD -T \$sshcmdline $BASTION_SSH_EXTRA_ARGS -- --user \"\$REMOTE_USER\" --port \"\$REMOTE_PORT\" --host \"\$REMOTE_HOST\" --osh scp --scp-cmd \"\$scpcmd\" --mfa-token $token" >> "$t" chmod +x "$t" # don't use exec below, because we need the trap to be executed on exit export BASTION_SCP_DEBUG [ "$BASTION_SCP_DEBUG" = 1 ] && set -x case "$way" in upload) scp $BASTION_SCP_EXTRA_ARGS -S "$t" -P "$REMOTE_PORT" "$LOCAL_PATH" "$REMOTE_USER"@"$REMOTE_HOST":"$REMOTE_PATH";; download) scp $BASTION_SCP_EXTRA_ARGS -S "$t" -P "$REMOTE_PORT" "$REMOTE_USER"@"$REMOTE_HOST":"$REMOTE_PATH" "$LOCAL_PATH";; esac END_OF_SCRIPT $script =~ s{%BASTION_CMD%}{$bastionCommand}g; $script =~ s{%SELF%}{$self}g; $script =~ s{%VERSION%}{$OVH::Bastion::VERSION}g; my $compressed = ''; gzip \$script => \$compressed; my $base64 = encode_base64($compressed); chomp $base64; osh_info <<"EOF"; Description: Transfers files to/from a host through the bastion Usage: To use scp through the bastion, you need a wrapper script to use instead of calling your scp client directly. It'll be specific to your account and this bastion, don't share it with others! To download your customized script, copy/paste this command: EOF print "\necho \"$base64\"|base64 -d|gunzip -c > ~/scp-via-$bastionName && chmod +x ~/scp-via-$bastionName\n\n"; osh_info <<"EOF"; To use scp through this bastion, use this script instead of your regular scp command. For example, to upload a file to a remote server with ssh listening on port 222 there: \$ ~/scp-via-$bastionName -i ~/.ssh/mysshkey -P 222 localfile login\@server:/dest/folder/ Or to recursively download a folder contents: \$ ~/scp-via-$bastionName -i ~/.ssh/mysshkey -r login\@server:/src/folder/ /tmp/ The following environment variables modify the behavior of the script: - `BASTION_SCP_DEBUG`: if set to 1, debug info is printed on the console - `BASTION_SCP_EXTRA_ARGS`: if set, the contents of this variable is added to the resulting scp command called by the script - `BASTION_SSH_EXTRA_ARGS`: if set, the contents of this variable is added to the resulting ssh commands used in the script For example: \$ BASTION_SCP_DEBUG=1 BASTION_SSH_EXTRA_ARGS="-v" ~/scp-via-$bastionName file1 login\@srv:/tmp Please note that you need to be granted for uploading or downloading files with scp to/from the remote host, in addition to having the right to SSH to it. For a group, the right should be added with --scpup/--scpdown of the groupAddServer command. For a personal access, the right should be added with --scpup/--scpdown of the selfAddPersonalAccess command. EOF osh_ok({script => $base64, "content-encoding" => 'base64-gzip'}); return 0; } # # code # my $fnret; if (not $host) { help(); osh_exit; } if (not $ip) { # note that the calling-side scp will not passthrough this exit code, but most probably "1" instead. osh_exit 'ERR_HOST_NOT_FOUND', "Sorry, couldn't resolve the host you specified ('$host'), aborting."; } my $machine = $ip; $machine = "$user\@$ip" if $user; $port ||= 22; # scp uses 22 if not specified, so we need to test access to that port and not any port (aka undef) $user ||= $self; # same for user $machine .= ":$port"; # decode the passed scp command my $decoded = $scpCmd; $decoded =~ s/(? $self, user => $user, ipfrom => $ENV{'OSH_IP_FROM'}, ip => $ip, port => $port, details => 1 ); if (not $fnret) { osh_exit 'ERR_ACCESS_DENIED', "Sorry, but you don't seem to have access to $machine"; } # get the keys we would try foreach my $access (@{$fnret->value || []}) { foreach my $key (@{$access->{'sortedKeys'} || []}) { my $keyfile = $access->{'keys'}{$key}{'fullpath'}; $keys{$keyfile}++ if -r $keyfile; osh_debug("Checking access 1/2 keyfile: $keyfile"); } } osh_debug("Checking access 2/2 of $self to $userToCheck of $machine..."); $fnret = OVH::Bastion::is_access_granted( account => $self, user => $userToCheck, ipfrom => $ENV{'OSH_IP_FROM'}, ip => $ip, port => $port, exactUserMatch => 1, details => 1 ); if (not $fnret) { osh_exit 'ERR_ACCESS_DENIED', "Sorry, but even if you have ssh access to $machine, you still need to be granted specifically for scp"; } # get the keys we would try too foreach my $access (@{$fnret->value || []}) { foreach my $key (@{$access->{'sortedKeys'} || []}) { my $keyfile = $access->{'keys'}{$key}{'fullpath'}; $keys{$keyfile}++ if -r $keyfile; osh_debug("Checking access 2/2 keyfile: $keyfile"); } } # now build the command my @cmd = qw{ ssh -x -oForwardAgent=no -oPermitLocalCommand=no -oClearAllForwardings=yes }; push @cmd, ('-p', $port) if $port; push @cmd, ('-l', $user) if $user; my $atleastonekey = 0; foreach my $keyfile (keys %keys) { # only use the key if it has been seen in both allow_deny() calls, this is to avoid # a security bypass where a user would have group access to a server, but not to the # !scpupload special user, and we would add himself this access through selfAddPrivateAccess. # in that case both allow_deny would return OK, but with different keys. # we'll only use the keys that matched BOTH calls. next unless $keys{$keyfile} == 2; push @cmd, ('-i', $keyfile); $atleastonekey = 1; } if (not $atleastonekey) { osh_exit('KO_ACCESS_DENIED', "Sorry, you seem to have access through ssh and through scp but by different and distinct means (distinct keys)." . " The intersection between your rights for ssh and for scp needs to be at least one."); } # basic mitigation for CVE-2020-15778 if ($decoded =~ m{[\`\$\;><\|\&]}) { osh_exit('ERR_SECURITY_VIOLATION', "Invalid characters detected, bailing out"); } push @cmd, "--", $ip, $decoded; print STDERR ">>> Hello $self, transferring your file through the bastion " . ($userToCheck eq '!scpupload' ? 'to' : 'from') . " $machine...\n"; #print STDERR join('^', @cmd)."\n"; $fnret = OVH::Bastion::execute(cmd => \@cmd, expects_stdin => 1, is_binary => 1); if ($fnret->err ne 'OK') { osh_exit 'ERR_TRANSFER_FAILED', "Error launching transfer: $fnret"; } print STDERR sprintf( ">>> Done, %d bytes uploaded, %d bytes downloaded\n", $fnret->value->{'bytesnb'}{'stdin'} + 0, $fnret->value->{'bytesnb'}{'stdout'} + 0 ); if ($fnret->value->{'sysret'} != 0) { print STDERR ">>> On bastion side, scp exited with return code " . $fnret->value->{'sysret'} . ".\n"; } # don't use osh_exit() to avoid getting a footer exit OVH::Bastion::EXIT_OK;