Thomas Lamprecht a1298cc2a5 cert verification: trust openssl result if hostnames are verified
If we verified that the hostname matches the cert we can also trust
the openssl verification result.

We get the openssl result as first parameter[0].

[0]: https://metacpan.org/pod/IO::Socket::SSL#SSL_verify_callback

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2020-12-03 16:06:33 +01:00

385 lines
9.6 KiB
Perl
Executable File

package PVE::APIClient::LWP;
use strict;
use warnings;
use Carp;
use HTTP::Request::Common;
use IO::Socket::SSL; # important for SSL_verify_callback
use JSON;
use LWP::UserAgent;
use Net::SSLeay;
use URI::Escape;
use URI;
use PVE::APIClient::Exception qw(raise);
my $extract_data = sub {
my ($res) = @_;
croak "undefined result" if !defined($res);
croak "undefined result data" if !exists($res->{data});
return $res->{data};
};
sub get_raw {
my ($self, $path, $param) = @_;
return $self->call('GET', $path, $param);
}
sub get {
my ($self, $path, $param) = @_;
return $extract_data->($self->call('GET', $path, $param));
}
sub post_raw {
my ($self, $path, $param) = @_;
return $self->call('POST', $path, $param);
}
sub post {
my ($self, $path, $param) = @_;
return $extract_data->($self->call('POST', $path, $param));
}
sub put_raw {
my ($self, $path, $param) = @_;
return $self->call('PUT', $path, $param);
}
sub put {
my ($self, $path, $param) = @_;
return $extract_data->($self->call('PUT', $path, $param));
}
sub delete_raw {
my ($self, $path, $param) = @_;
return $self->call('DELETE', $path, $param);
}
sub delete {
my ($self, $path, $param) = @_;
return $extract_data->($self->call('DELETE', $path, $param));
}
sub update_csrftoken {
my ($self, $csrftoken) = @_;
$self->{csrftoken} = $csrftoken;
my $agent = $self->{useragent};
$agent->default_header('CSRFPreventionToken', $self->{csrftoken});
}
sub update_ticket {
my ($self, $ticket) = @_;
my $agent = $self->{useragent};
$self->{ticket} = $ticket;
my $encticket = uri_escape($ticket);
my $cookie = "$self->{cookie_name}=$encticket; path=/; secure;";
$agent->default_header('Cookie', $cookie);
}
sub two_factor_auth_login {
my ($self, $type, $challenge) = @_;
if ($type eq 'PVE:tfa') {
raise("TFA-enabled login currently works only with a TTY.") if !-t STDIN;
print "\nEnter OTP code for user $self->{username}: ";
my $tfa_response = <STDIN>;
chomp $tfa_response;
return $self->post('/api2/json/access/tfa', {response => $tfa_response});
} elsif ($type eq 'PVE:u2f') {
# TODO: implement u2f-enabled join
raise("U2F-enabled login is currently not implemented.");
} else {
raise("Authentication type '$type' not recognized, aborting!");
}
}
sub login {
my ($self) = @_;
my $uri = URI->new();
$uri->scheme($self->{protocol});
$uri->host($self->{host});
$uri->port($self->{port});
$uri->path('/api2/json/access/ticket');
my $ua = $self->{useragent};
my $username = $self->{username} // 'unknown',
delete $self->{fingerprint}->{last_unknown};
my $exec_login = sub {
return $ua->post($uri, {
username => $username,
password => $self->{password} || ''
});
};
my $response = $exec_login->();
if (!$response->is_success) {
if (my $fp = delete($self->{fingerprint}->{last_unknown})) {
if ($self->manual_verify_fingerprint($fp)) {
$response = $exec_login->(); # try again
}
}
}
if (!$response->is_success) {
raise($response->status_line ."\n", code => $response->code)
}
my $res = from_json($response->decoded_content, {utf8 => 1, allow_nonref => 1});
my $data = $extract_data->($res);
$self->update_ticket($data->{ticket});
$self->update_csrftoken($data->{CSRFPreventionToken});
# handle two-factor login
my $tfa_ticket_re = qr/^([^\s!]+)![^!]*(!([0-9a-zA-Z\/.=_\-+]+))?$/;
if ($data->{ticket} =~ m/$tfa_ticket_re/) {
my ($type, $challenge) = ($1, $2);
$data = $self->two_factor_auth_login($type, $challenge);
$self->update_ticket($data->{ticket});
}
return $data;
}
sub manual_verify_fingerprint {
my ($self, $fingerprint) = @_;
if (!$self->{manual_verification}) {
raise("fingerprint '$fingerprint' not verified, abort!\n");
}
print "The authenticity of host '$self->{host}' can't be established.\n" .
"X509 SHA256 key fingerprint is $fingerprint.\n" .
"Are you sure you want to continue connecting (yes/no)? ";
my $answer = <STDIN>;
my $valid = ($answer =~ m/^\s*yes\s*$/i) ? 1 : 0;
$self->{fingerprint}->{cache}->{$fingerprint} = $valid;
raise("Fingerprint not verified, abort!\n") if !$valid;
if (my $cb = $self->{register_fingerprint_cb}) {
$cb->($fingerprint) if $valid;
}
return $valid;
}
sub call {
my ($self, $method, $path, $param) = @_;
delete $self->{fingerprint}->{last_unknown};
my $ticket = $self->{ticket};
my $apitoken = $self->{apitoken};
my $ua = $self->{useragent};
# fixme: check ticket lifetime?
if (!$ticket && !$apitoken && $self->{username} && $self->{password}) {
$self->login();
}
my $uri = URI->new();
$uri->scheme($self->{protocol});
$uri->host($self->{host});
$uri->port($self->{port});
$path =~ s!^/+!!;
if ($path !~ m!^api2/!) {
$uri->path("api2/json/$path");
} else {
$uri->path($path);
}
#print "CALL $method : " . $uri->as_string() . "\n";
my $exec_method = sub {
my $response;
if ($method eq 'GET') {
$uri->query_form($param);
$response = $ua->request(HTTP::Request::Common::GET($uri));
} elsif ($method eq 'POST') {
$response = $ua->request(HTTP::Request::Common::POST($uri, Content => $param));
} elsif ($method eq 'PUT') {
# We use another temporary URI object to format
# the application/x-www-form-urlencoded content.
my $tmpurl = URI->new('http:');
$tmpurl->query_form(%$param);
my $content = $tmpurl->query;
$response = $ua->request(HTTP::Request::Common::PUT($uri, 'Content-Type' => 'application/x-www-form-urlencoded', Content => $content));
} elsif ($method eq 'DELETE') {
$response = $ua->request(HTTP::Request::Common::DELETE($uri));
} else {
raise("method $method not implemented\n");
}
return $response;
};
my $response = $exec_method->();
if (my $fp = delete($self->{fingerprint}->{last_unknown})) {
if ($self->manual_verify_fingerprint($fp)) {
$response = $exec_method->(); # try again
}
}
my $ct = $response->header('Content-Type') || '';
if ($response->is_success) {
raise("got unexpected content type", code => $response->code)
if $ct !~ m|application/json|;
return from_json($response->decoded_content, {utf8 => 1, allow_nonref => 1});
} else {
my $msg = $response->message;
my $errors = eval {
return if $ct !~ m|application/json|;
my $res = from_json($response->decoded_content, {utf8 => 1, allow_nonref => 1});
return $res->{errors};
};
raise("$msg\n", code => $response->code, errors => $errors);
}
}
my sub verify_cert_callback {
my ($fingerprint, $cert, $verify_cb) = @_;
# check server certificate against cache of pinned FPs
# get fingerprint of server certificate
my $fp = Net::SSLeay::X509_get_fingerprint($cert, 'sha256');
return 0 if !defined($fp) || $fp eq ''; # error
my $valid = $fingerprint->{cache}->{$fp};
return $valid if defined($valid); # return cached result
if ($verify_cb) {
$valid = $verify_cb->($cert);
$fingerprint->{cache}->{$fp} = $valid;
return $valid;
}
$fingerprint->{last_unknown} = $fp;
return 0;
};
sub new {
my ($class, %param) = @_;
my $ssl_default_opts = { verify_hostname => 0 };
my $ssl_opts = $param{ssl_opts} || $ssl_default_opts;
# we can only really trust openssl result if it also verifies the hostname,
# else it's easy to intercept (MITM using valid Lets Encrypt)
my $trust_openssl = $ssl_opts->{verify_hostname} ? 1 : 0;
my $self = {
username => $param{username},
password => $param{password},
host => $param{host} || 'localhost',
port => $param{port},
protocol => $param{protocol},
cookie_name => $param{cookie_name} // 'PVEAuthCookie',
manual_verification => $param{manual_verification},
fingerprint => {
cache => $param{cached_fingerprints} || {},
last_unknown => undef,
},
register_fingerprint_cb => $param{register_fingerprint_cb},
timeout => $param{timeout} || 60,
};
bless $self, $class;
if (!$ssl_opts->{SSL_verify_callback}) {
$ssl_opts->{'SSL_verify_mode'} = SSL_VERIFY_PEER;
my $fingerprints = $self->{fingerprint}; # avoid passing $self, that's a RC cycle!
my $verify_fingerprint_cb = $param{verify_fingerprint_cb};
$ssl_opts->{'SSL_verify_callback'} = sub {
my ($openssl_valid, undef, undef, undef, $cert, $depth) = @_;
# we don't care about intermediate or root certificates
return 1 if $depth != 0;
return 1 if $trust_openssl && $openssl_valid;
return verify_cert_callback($fingerprints, $cert, $verify_fingerprint_cb);
}
}
if (!$self->{port}) {
$self->{port} = $self->{host} eq 'localhost' ? 85 : 8006;
}
if (!$self->{protocol}) {
$self->{protocol} = $self->{host} eq 'localhost' ? 'http' : 'https';
}
$self->{useragent} = LWP::UserAgent->new(
protocols_allowed => [ 'http', 'https'],
ssl_opts => $ssl_opts,
timeout => $self->{timeout},
keep_alive => $param{keep_alive} // 50,
);
$self->{useragent}->default_header('Accept-Encoding' => 'gzip'); # allow gzip
if ($param{apitoken} && $param{password}) {
warn "password will be ignored in favor of API token\n";
delete $self->{password};
}
if ($param{ticket}) {
if ($param{apitoken}) {
warn "ticket will be ignored in favor of API token\n";
} else {
$self->update_ticket($param{ticket});
}
}
$self->update_csrftoken($param{csrftoken}) if $param{csrftoken};
if ($param{apitoken}) {
my $agent = $self->{useragent};
$self->{apitoken} = $param{apitoken};
$agent->default_header('Authorization', $param{apitoken});
}
return $self;
}
1;