Rex-LibSSH

 view release on metacpan or  search on metacpan

t/05-hostkey.t  view on Meta::CPAN

use strict;
use warnings;
use lib 't/lib';
use Test::More;
use File::Temp qw(tempdir);
use TestSSHD;

# CWE-322 (CPANSec report, karr k8): Rex::Interface::Connection::LibSSH::connect
# hardcodes strict_hostkeycheck => 0 and never looks at $opt{strict_hostkeycheck}
# or $opt{knownhosts} -- so the opt-in documented in lib/Rex/LibSSH.pm POD
# ("To enable it, pass the option when connecting") is a no-op: every host key,
# MITM included, is accepted silently regardless of what a caller asks for.
#
# This file proves: the strict default actually refuses an unverified key (and
# never authenticates against it), that an explicit knownhosts file is what
# makes a real connect succeed, that a changed key is refused as a possible
# MITM without rewriting the known_hosts file, that the documented
# strict_hostkeycheck => 0 opt-out still does real work end-to-end (the
# Rex::GPU / Rex::Rancher path on fresh Hetzner installs), that the
# openssh_opt-based knobs Rex tasks use instead of calling Rex::connect
# directly are consulted, and that a per-connect option wins over them.

my $srv = TestSSHD->start;
plan skip_all => 'sshd or ssh-keygen not available' unless $srv;

# Net::LibSSH >= 0.004 is where connect() started calling
# ssh_session_is_known_server() at all -- on an older version every
# assertion below about refusal is unreachable, not skippable. Fail loud.
use Net::LibSSH;
if ( $Net::LibSSH::VERSION < 0.004 ) {
    die "t/05-hostkey.t requires Net::LibSSH >= 0.004 for host key "
      . "verification (ssh_session_is_known_server); found "
      . "$Net::LibSSH::VERSION -- karr k8 cannot be exercised on this version.\n";
}

use Rex -feature => ['1.4'];
use Rex::Group::Entry::Server;
use Rex::Commands::Run;
use Rex::Config;
use Rex::Interface::Connection;
use Rex::TaskList;

set connection => 'LibSSH';

Rex::Config->set_user( scalar getpwuid($<) );
Rex::Config->set_private_key( $srv->client_key );
Rex::Config->set_public_key( $srv->client_key . '.pub' );

sub with_timeout {
    my ( $seconds, $code ) = @_;
    my $result;
    local $@;
    eval {
        local $SIG{ALRM} = sub { die "TIMEOUT\n" };
        alarm($seconds);
        $result = $code->();
        alarm(0);
    };
    my $err = $@;
    alarm(0);
    die $err if $err;
    return $result;
}

# Count the lines in a known_hosts-shaped file. Used to prove a refused
# connect never appends or rewrites the file it was pointed at.
sub line_count {
    my ($path) = @_;
    CORE::open my $fh, '<', $path or return undef;
    my $n = 0;
    $n++ while <$fh>;
    CORE::close $fh;
    return $n;
}

sub pub_key_line {
    my ($path) = @_;
    CORE::open my $fh, '<', $path or die "open $path: $!";
    my $line = <$fh>;
    CORE::close $fh;
    chomp $line;
    return $line;
}

# A known_hosts file, in the same [host]:port bracketed form TestSSHD uses,
# built from an arbitrary public key -- so a test can hand connect() a key
# that is unknown or has changed from the one the server actually presents.
sub known_hosts_for {
    my ($pub_path) = @_;
    my $dir  = tempdir( CLEANUP => 1 );
    my $path = "$dir/known_hosts";
    CORE::open my $fh, '>', $path or die "open $path: $!";
    print $fh '[' . $srv->host . ']:' . $srv->port . ' ' . pub_key_line($pub_path) . "\n";
    CORE::close $fh;
    return $path;
}

# A fresh ed25519 keypair in its own tempdir -- ssh-keygen is already a hard
# requirement of TestSSHD, so generating a second one here adds no new skip
# condition.
sub gen_key {
    my $dir = tempdir( CLEANUP => 1 );
    system( 'ssh-keygen', '-t', 'ed25519', '-N', '', '-f', "$dir/key", '-q' ) == 0
        or die "ssh-keygen failed";
    return "$dir/key.pub";
}

sub base_opts {
    return (
        server      => $srv->host,
        port        => $srv->port,
        user        => scalar getpwuid($<),
        private_key => $srv->client_key,
        public_key  => $srv->client_key . '.pub',
        auth_type   => 'key',
    );
}

# Rex::connect dies before push_connection when is_connected is false, so
# there is nothing to pop in the normal "refused" case below. This is a
# defensive net around that, not the expected path: pop only if a
# connection was actually pushed, i.e. either Rex::connect unexpectedly
# succeeded (which would mean a refusal test regressed) or it died after
# already pushing (the "Wrong username" auth-failure path, which pushes
# before its own die). Keeps every refusal block safe to run unattended
# regardless of which of those it hits.
sub pop_if_pushed {
    my ($err) = @_;
    Rex::pop_connection() if !$err || $err =~ /Wrong username/;
}

my $harness_kh_lines_before = line_count( $srv->known_hosts );
is $harness_kh_lines_before, 1,
    'sanity: the harness known_hosts starts with exactly the one line it wrote';

# --- 1. Default is strict: no knownhosts/strict_hostkeycheck option
#        anywhere -- Rex::connect must refuse an unverified host key --------
{
    eval { Rex::connect( base_opts() ) };
    my $err = $@;
    pop_if_pushed($err);
    like $err, qr/Connection error or refused\./,
        'Rex::connect dies against an unknown host key with no options given (default strict)';
}

# Same scenario, driving the connection interface object directly -- when
# Rex::connect dies, its own $conn is a lexical that is lost, so this is the
# only way to inspect is_connected / is_authenticated / error after a
# refusal.
{
    my $conn = Rex::Interface::Connection->create('LibSSH');
    with_timeout( 10, sub { $conn->connect( base_opts() ) } );

    is $conn->is_connected, 0,
        'connection object: is_connected is 0 after a default-strict refusal';
    is $conn->is_authenticated, 0,
        'connection object: is_authenticated is 0 -- authentication must not have happened';
    like $conn->error // '', qr/not in known_hosts/,
        q{connection object: error() carries libssh's refusal message};
}

# --- 2. knownhosts => file naming exactly this server -- connect + auth
#        succeed, and a real command actually runs ---------------------------
{
    Rex::connect( base_opts(), knownhosts => $srv->known_hosts );

    my $conn = Rex::get_current_connection()->{conn};
    ok $conn->is_connected,     'knownhosts => harness file: is_connected true';
    ok $conn->is_authenticated, 'knownhosts => harness file: is_authenticated true';

    my $out = run 'echo hello';
    chomp $out;
    is $out, 'hello', 'knownhosts => harness file: run works after connect';

t/05-hostkey.t  view on Meta::CPAN

# --- 5b. Rex::Config->set_openssh_opt( UserKnownHostsFile => ... ) with
#         strict default (no per-connect knownhosts at all) -- connects ----
{
    Rex::Config->set_openssh_opt( UserKnownHostsFile => $srv->known_hosts );

    Rex::connect( base_opts() );

    my $conn = Rex::get_current_connection()->{conn};
    ok $conn->is_connected,
        'openssh_opt UserKnownHostsFile: is_connected true against the trusted key, strict default';
    ok $conn->is_authenticated,
        'openssh_opt UserKnownHostsFile: is_authenticated true';

    my $out = run 'echo hello';
    chomp $out;
    is $out, 'hello', 'openssh_opt UserKnownHostsFile: run works';

    Rex::pop_connection();

    Rex::Config->set_openssh_opt( UserKnownHostsFile => undef );
}

# --- 5c. a per-connect option wins over the openssh_opt fallback ----------
{
    # openssh_opt says "don't check"; the per-connect option says "check,
    # strictly, against a file where this key is unknown" -- per-connect
    # must win, so this must still be refused.
    Rex::Config->set_openssh_opt( StrictHostKeyChecking => 'no' );

    eval {
        Rex::connect(
            base_opts(),
            knownhosts          => '/dev/null',
            strict_hostkeycheck => 1,
        );
    };
    my $err = $@;
    pop_if_pushed($err);
    like $err, qr/Connection error or refused\./,
        'a per-connect strict_hostkeycheck => 1 overrides an openssh_opt StrictHostKeyChecking => no';

    Rex::Config->set_openssh_opt( StrictHostKeyChecking => undef );
}

# --- 6. the real task path: Rex::connect is never called, only
#        Rex::Task->run($server) -- proves the openssh_opt fallback is read
#        by the connection class itself, not bolted onto the Rex::connect
#        wrapper alone. --------------------------------------------------
{
    Rex::Config->set_openssh_opt( StrictHostKeyChecking => 'no' );

    my $task_output;
    task 'hostkey_task_path_test', sub {
        $task_output = run 'echo task-path-ok';
    };

    my $server = Rex::Group::Entry::Server->new(
        name        => $srv->host,
        port        => $srv->port,
        user        => scalar getpwuid($<),
        private_key => $srv->client_key,
        public_key  => $srv->client_key . '.pub',
        auth_type   => 'key',
    );

    my $task = Rex::TaskList->create()->get_task('hostkey_task_path_test');
    eval { $task->run($server) };
    my $err = $@;
    ok !$err,
        'task path: Rex::Task->run() against an unknown host key succeeds via the openssh_opt opt-out'
        or diag "task run died: $err";

    if ( !$err ) {
        my $got = $task_output // '';
        chomp $got;
        is $got, 'task-path-ok',
            'task path: the task body actually executed on the remote host';
    }
    else {
        fail 'task path: the task body actually executed on the remote host (task run died)';
    }

    Rex::Config->set_openssh_opt( StrictHostKeyChecking => undef );
}

# --- 7. nothing above ever wrote back into the harness known_hosts --------
is line_count( $srv->known_hosts ), $harness_kh_lines_before,
    'the harness known_hosts still has exactly the line it started with';

done_testing;



( run in 0.375 second using v1.01-cache-2.11-cpan-036bef1c656 )