diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml index 72f553e69f..a2388cd503 100644 --- a/.cirrus.tasks.yml +++ b/.cirrus.tasks.yml @@ -20,7 +20,7 @@ env: MTEST_ARGS: --print-errorlogs --no-rebuild -C build PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf - PG_TEST_EXTRA: kerberos ldap ssl load_balance + PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance # What files to preserve in case tests fail diff --git a/src/test/libpq_encryption/Makefile b/src/test/libpq_encryption/Makefile new file mode 100644 index 0000000000..3ad3da7031 --- /dev/null +++ b/src/test/libpq_encryption/Makefile @@ -0,0 +1,25 @@ +#------------------------------------------------------------------------- +# +# Makefile for src/test/libpq_encryption +# +# Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group +# Portions Copyright (c) 1994, Regents of the University of California +# +# src/test/libpq_encryption/Makefile +# +#------------------------------------------------------------------------- + +subdir = src/test/libpq_encryption +top_builddir = ../../.. +include $(top_builddir)/src/Makefile.global + +export with_ssl with_gssapi with_krb_srvnam + +check: + $(prove_check) + +installcheck: + $(prove_installcheck) + +clean distclean: + rm -rf tmp_check diff --git a/src/test/libpq_encryption/README b/src/test/libpq_encryption/README new file mode 100644 index 0000000000..8ceb194527 --- /dev/null +++ b/src/test/libpq_encryption/README @@ -0,0 +1,31 @@ +src/test/libpq_encryption/README + +Tests for negotiating network encryption method +=============================================== + +This directory contains a test suite for the libpq options to +negotiate encryption with the server. This requires reconfiguring a +test server, enabling/disabling SSL and GSSAPI, and is therefore kept +separate and not run by default. + +CAUTION: The test server run by this test is configured to listen for TCP +connections on localhost. Any user on the same host is able to log in to the +test server while the tests are running. Do not run this suite on a multi-user +system where you don't trust all local users! Also, this test suite creates a +KDC server that listens for TCP/IP connections on localhost without any real +access control. + +Running the tests +================= + +NOTE: You must have given the --enable-tap-tests argument to configure. + +Run + make check PG_TEST_EXTRA=libpq_encryption + +You can use "make installcheck" if you previously did "make install". +In that case, the code in the installation tree is tested. With +"make check", a temporary installation tree is built from the current +sources and then tested. + +See src/test/perl/README for more info about running these tests. diff --git a/src/test/libpq_encryption/meson.build b/src/test/libpq_encryption/meson.build new file mode 100644 index 0000000000..ac1db10d74 --- /dev/null +++ b/src/test/libpq_encryption/meson.build @@ -0,0 +1,18 @@ +# Copyright (c) 2022-2024, PostgreSQL Global Development Group + +tests += { + 'name': 'libpq_encryption', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'tap': { + 'tests': [ + 't/001_negotiate_encryption.pl', + ], + 'env': { + 'with_ssl': ssl_library, + 'OPENSSL': openssl.found() ? openssl.path() : '', + 'with_gssapi': gssapi.found() ? 'yes' : 'no', + 'with_krb_srvnam': 'postgres', + }, + }, +} diff --git a/src/test/libpq_encryption/t/001_negotiate_encryption.pl b/src/test/libpq_encryption/t/001_negotiate_encryption.pl new file mode 100644 index 0000000000..ed33988aa7 --- /dev/null +++ b/src/test/libpq_encryption/t/001_negotiate_encryption.pl @@ -0,0 +1,548 @@ + +# Copyright (c) 2021-2024, PostgreSQL Global Development Group + +# OVERVIEW +# -------- +# +# Test negotiation of SSL and GSSAPI encryption +# +# We test all combinations of: +# +# - all the libpq client options that affect the protocol negotiations +# (gssencmode, sslmode) +# - server accepting or rejecting the authentication due to +# pg_hba.conf entries +# - SSL and GSS enabled/disabled in the server +# +# That's a lot of combinations, so we use a table-driven approach. +# Each combination is represented by a line in a table. The line lists +# the options specifying the test case, and an expected outcome. The +# expected outcome includes whether the connection succeeds or fails, +# and whether it uses SSL, GSS or no encryption. +# +# TEST TABLE FORMAT +# ----------------- +# +# Example of the test table format: +# +# # USER GSSENCMODE SSLMODE OUTCOME +# testuser disable allow plain +# . . prefer ssl +# testuser require * fail +# +# USER, GSSENCMODE and SSLMODE fields are the libpq 'user', +# 'gssencmode' and 'sslmode' options used in the test. As a shorthand, +# a single dot ('.') can be used in the USER, GSSENCMODE, and SSLMODE +# fields, to indicate "same as on previous line". A '*' can be used +# as a wildcard; it is expanded to mean all possible values of that +# field. +# +# The OUTCOME field indicates the expected result of the test: +# +# plain: an unencrypted connection was established +# ssl: SSL connection was established +# gss: GSSAPI encrypted connection was established +# fail: the connection attempt failed +# +# Empty lines are ignored. '#' can be used to mark the rest of the +# line as a comment. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Utils; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Kerberos; +use File::Basename; +use File::Copy; +use Test::More; + +if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\blibpq_encryption\b/) +{ + plan skip_all => + 'Potentially unsafe test libpq_encryption not enabled in PG_TEST_EXTRA'; +} + +my $ssl_supported = $ENV{with_ssl} eq 'openssl'; +my $gss_supported = $ENV{with_gssapi} eq 'yes'; + +### +### Prepare test server for GSSAPI and SSL authentication, with a few +### different test users and helper functions. We don't actually +### enable SSL and kerberos in the server yet, we will do that later. +### + +my $host = 'enc-test-localhost.postgresql.example.com'; +my $hostaddr = '127.0.0.1'; +my $servercidr = '127.0.0.1/32'; + +my $node = PostgreSQL::Test::Cluster->new('node'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq{ +listen_addresses = '$hostaddr' +log_connections = on +lc_messages = 'C' +}); +my $pgdata = $node->data_dir; + +my $dbname = 'postgres'; +my $username = 'enctest'; +my $application = '001_negotiate_encryption.pl'; + +my $gssuser_password = 'secret1'; + +my $krb; + +if ($gss_supported != 0) +{ + note "setting up Kerberos"; + + my $realm = 'EXAMPLE.COM'; + $krb = PostgreSQL::Test::Kerberos->new($host, $hostaddr, $realm); + $node->append_conf('postgresql.conf', "krb_server_keyfile = '$krb->{keytab}'\n"); +} + +if ($ssl_supported != 0) +{ + my $certdir = dirname(__FILE__) . "/../../ssl/ssl"; + + copy "$certdir/server-cn-only.crt", "$pgdata/server.crt" + || die "copying server.crt: $!"; + copy "$certdir/server-cn-only.key", "$pgdata/server.key" + || die "copying server.key: $!"; + chmod(0600, "$pgdata/server.key"); + + # Start with SSL disabled. + $node->append_conf('postgresql.conf', "ssl = off\n"); +} + +$node->start; + +$node->safe_psql('postgres', 'CREATE USER localuser;'); +$node->safe_psql('postgres', 'CREATE USER testuser;'); +$node->safe_psql('postgres', 'CREATE USER ssluser;'); +$node->safe_psql('postgres', 'CREATE USER nossluser;'); +$node->safe_psql('postgres', 'CREATE USER gssuser;'); +$node->safe_psql('postgres', 'CREATE USER nogssuser;'); + +my $unixdir = $node->safe_psql('postgres', 'SHOW unix_socket_directories;'); +chomp($unixdir); + +# Helper function that returns the encryption method in use in the +# connection. +$node->safe_psql('postgres', q{ +CREATE FUNCTION current_enc() RETURNS text LANGUAGE plpgsql AS $$ +DECLARE + ssl_in_use bool; + gss_in_use bool; +BEGIN + ssl_in_use = (SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()); + gss_in_use = (SELECT encrypted FROM pg_stat_gssapi WHERE pid = pg_backend_pid()); + + raise log 'ssl % gss %', ssl_in_use, gss_in_use; + + IF ssl_in_use AND gss_in_use THEN + RETURN 'ssl+gss'; -- shouldn't happen + ELSIF ssl_in_use THEN + RETURN 'ssl'; + ELSIF gss_in_use THEN + RETURN 'gss'; + ELSE + RETURN 'plain'; + END IF; +END; +$$; +}); + +# Only accept SSL connections from $servercidr. Our tests don't depend on this +# but seems best to keep it as narrow as possible for security reasons. +open my $hba, '>', "$pgdata/pg_hba.conf"; +print $hba qq{ +# TYPE DATABASE USER ADDRESS METHOD OPTIONS +local postgres localuser trust +host postgres testuser $servercidr trust +hostnossl postgres nossluser $servercidr trust +hostnogssenc postgres nogssuser $servercidr trust +}; + +print $hba qq{ +hostssl postgres ssluser $servercidr trust +} if ($ssl_supported != 0); + +print $hba qq{ +hostgssenc postgres gssuser $servercidr trust +} if ($gss_supported != 0); +close $hba; +$node->reload; + +# Ok, all prepared. Run the tests. + +my @all_test_users = ('testuser', 'ssluser', 'nossluser', 'gssuser', 'nogssuser'); +my @all_gssencmodes = ('disable', 'prefer', 'require'); +my @all_sslmodes = ('disable', 'allow', 'prefer', 'require'); + +my $server_config = { + server_ssl => 0, + server_gss => 0, +}; + +### +### Run tests with GSS and SSL disabled in the server +### +my $test_table = q{ +# USER GSSENCMODE SSLMODE OUTCOME +testuser disable disable plain +. . allow plain +. . prefer plain +. . require fail +. prefer disable plain +. . allow plain +. . prefer plain +. . require fail + +# All attempts with gssencmode=require fail because no credential +# cache has been configured in the client (and the server isn't +# configured for GSS either) +. require * fail +}; +note("Running tests with SSL and GSS disabled in the server"); +test_matrix($node, $server_config, + ['testuser'], + \@all_sslmodes, \@all_gssencmodes, parse_table($test_table)); + +### +### Run tests with GSS disabled and SSL enabled in the server +### +SKIP: +{ + skip "SSL not supported by this build" if $ssl_supported == 0; + + $test_table = q{ +# USER GSSENCMODE SSLMODE OUTCOME +testuser disable disable plain +. . allow plain +. . prefer ssl +. . require ssl +ssluser . disable fail +. . allow ssl +. . prefer ssl +. . require ssl +nossluser . disable plain +. . allow plain +. . prefer plain +. . require fail +}; + + # Enable SSL in the server + $node->adjust_conf('postgresql.conf', 'ssl', 'on'); + $node->reload; + $server_config->{server_ssl} = 1; + + note("Running tests with SSL enabled in server"); + test_matrix($node, $server_config, + ['testuser', 'ssluser', 'nossluser'], + \@all_sslmodes, ['disable'], parse_table($test_table)); + + # Disable SSL again + $node->adjust_conf('postgresql.conf', 'ssl', 'off'); + $node->reload; + $server_config->{server_ssl} = 0; +} + +### +### Run tests with GSS enabled, SSL disabled in the server +### +SKIP: +{ + skip "GSSAPI/Kerberos not supported by this build" if $gss_supported == 0; + $test_table = q{ +# USER GSSENCMODE SSLMODE OUTCOME +testuser disable disable plain +. . allow plain +. . prefer plain +. . require fail +. require * gss +. prefer * gss + +gssuser disable disable fail +. . allow fail +. . prefer fail +. . require fail +. prefer * gss +. require * gss + +nogssuser disable disable plain +. . allow plain +. . prefer plain +. . require fail +. prefer disable plain +. . allow plain +. . prefer plain +. . require fail +. require * fail +}; + + # Sanity check that the connection fails when no kerberos ticket + # is present in the client + connect_test($node, 'user=testuser gssencmode=require sslmode=disable', 'fail'); + + $krb->create_principal('gssuser', $gssuser_password); + $krb->create_ticket('gssuser', $gssuser_password); + $server_config->{server_gss} = 1; + + note("Running tests with GSS enabled in server"); + test_matrix($node, $server_config, + ['testuser', 'gssuser', 'nogssuser'], + \@all_sslmodes, \@all_gssencmodes, parse_table($test_table)); + + # Check that logs match the expected 'no pg_hba.conf entry' line, too, as + # that is not tested by test_matrix. + connect_test($node, 'user=nogssuser gssencmode=require sslmode=prefer', 'fail', + 'no pg_hba.conf entry for host "127.0.0.1", user "nogssuser", database "postgres", GSS encryption'); + + # With 'gssencmode=prefer', libpq will first negotiate GSSAPI + # encryption, but the connection will fail because pg_hba.conf + # forbids GSSAPI encryption for this user. It will then reconnect + # with SSL, but the server doesn't support it, so it will continue + # with no encryption. + connect_test($node, 'user=nogssuser gssencmode=prefer sslmode=prefer', 'plain', + 'no pg_hba.conf entry for host "127.0.0.1", user "nogssuser", database "postgres", GSS encryption'); +} + +### +### Tests with both GSS and SSL enabled in the server +### +SKIP: +{ + skip "GSSAPI/Kerberos or SSL not supported by this build" unless ($ssl_supported && $gss_supported); + + $test_table = q{ +# USER GSSENCMODE SSLMODE OUTCOME +testuser disable disable plain +. . allow plain +. . prefer ssl +. . require ssl +. prefer disable gss +. . allow gss +. . prefer gss +. . require gss # If both GSS and SSL is possible, GSS is chosen over SSL, even if sslmode=require +. require disable gss +. . allow gss +. . prefer gss +. . require gss # If both GSS and SSL is possible, GSS is chosen over SSL, even if sslmode=require + +gssuser disable * fail +. prefer * gss +. require * gss + +ssluser disable disable fail +. . allow ssl +. . prefer ssl +. . require ssl +. prefer disable fail +. . allow ssl +. . prefer ssl +. . require ssl +. require disable fail +. . allow fail +. . prefer fail +. . require fail # If both GSS and SSL are required, the sslmode=require is effectively ignored and GSS is required + +nogssuser disable disable plain +. . allow plain +. . prefer ssl +. . require ssl +. prefer disable plain +. . allow plain +. . prefer ssl +. . require ssl +. require * fail + +nossluser disable disable plain +. . allow plain +. . prefer plain +. . require fail +. prefer * gss +. require * gss +}; + + # Sanity check that GSSAPI is still enabled from previous test. + connect_test($node, 'user=testuser gssencmode=prefer sslmode=prefer', 'gss'); + + # Enable SSL + $node->adjust_conf('postgresql.conf', 'ssl', 'on'); + $node->reload; + $server_config->{server_ssl} = 1; + + note("Running tests with both GSS and SSL enabled in server"); + test_matrix($node, $server_config, + ['testuser', 'gssuser', 'ssluser', 'nogssuser', 'nossluser'], + \@all_sslmodes, \@all_gssencmodes, parse_table($test_table)); + + # Test case that server supports GSSAPI, but it's not allowed for + # this user. Special cased because we check output + connect_test($node, 'user=nogssuser gssencmode=require sslmode=prefer', 'fail', + 'no pg_hba.conf entry for host "127.0.0.1", user "nogssuser", database "postgres", GSS encryption'); + + # with 'gssencmode=prefer', libpq will first negotiate GSSAPI + # encryption, but the connection will fail because pg_hba.conf + # forbids GSSAPI encryption for this user. It will then reconnect + # with SSL. + connect_test($node, 'user=nogssuser gssencmode=prefer sslmode=prefer', 'ssl', + 'no pg_hba.conf entry for host "127.0.0.1", user "nogssuser", database "postgres", GSS encryption'); + + # Setting both gssencmode=require and sslmode=require fails if + # GSSAPI is not available. + connect_test($node, 'user=nogssuser gssencmode=require sslmode=require ', 'fail'); +} + +### +### Test negotiation over unix domain sockets. +### +SKIP: +{ + skip "Unix domain sockets not supported" unless ($unixdir ne ""); + + connect_test($node, "user=localuser gssencmode=prefer sslmode=require host=$unixdir", 'plain'); + connect_test($node, "user=localuser gssencmode=require sslmode=prefer host=$unixdir", 'fail'); +} + +done_testing(); + + +### Helper functions + +# Test the cube of parameters: user, sslmode, and gssencmode +sub test_matrix +{ + local $Test::Builder::Level = $Test::Builder::Level + 1; + + my ($pg_node, $node_conf, + $test_users, $ssl_modes, $gss_modes, %expected) = @_; + + foreach my $test_user (@{$test_users}) + { + foreach my $gssencmode (@{$gss_modes}) + { + foreach my $client_mode (@{$ssl_modes}) + { + my %params = ( + server_ssl=>$node_conf->{server_ssl}, + server_gss=>$node_conf->{server_gss}, + user=>$test_user, + gssencmode=>$gssencmode, + sslmode=>$client_mode, + ); + my $key = "$test_user $gssencmode $client_mode"; + my $res = $expected{$key}; + if (!defined $res) { + $res = ""; + } + connect_test($pg_node, "user=$test_user gssencmode=$gssencmode sslmode=$client_mode", $res); + } + } + } +} + +sub connect_test +{ + local $Test::Builder::Level = $Test::Builder::Level + 1; + + my ($node, $connstr, $expected_outcome, @expect_log_msgs) + = @_; + + my $test_name = " '$connstr' -> $expected_outcome"; + + my $connstr_full = ""; + $connstr_full .= "dbname=postgres " unless $connstr =~ m/dbname=/; + $connstr_full .= "host=$host hostaddr=$hostaddr " unless $connstr =~ m/host=/; + $connstr_full .= $connstr; + + my $log_location = -s $node->logfile; + + # XXX: Pass command with -c, because I saw intermittent test + # failures like this: + # + # ack Broken pipe: write( 13, 'SELECT current_enc()' ) at /usr/local/lib/perl5/site_perl/IPC/Run/IO.pm line 550. + # + # I think that happens if the connection fails before we write the + # query to its stdin. This test gets a lot of connection failures + # on purpose. + my ($ret, $stdout, $stderr) = $node->psql( + 'postgres', + '', + extra_params => ['-w', '-c', 'SELECT current_enc()'], + connstr => "$connstr_full", + on_error_stop => 0); + + my $outcome = $ret == 0 ? $stdout : 'fail'; + + is($outcome, $expected_outcome, $test_name) or diag("$stderr"); + + if (@expect_log_msgs) + { + # Match every message literally. + my @regexes = map { qr/\Q$_\E/ } @expect_log_msgs; + my %params = (); + $params{log_like} = \@regexes; + $node->log_check($test_name, $log_location, %params); + } +} + +sub parse_table +{ + my ($table) = @_; + my @lines = split /\n/, $table; + + my %expected; + + my ($user, $gssencmode, $sslmode); + foreach my $line (@lines) { + + # Trim comments + $line =~ s/#.*$//; + + # Trim whitespace at beginning and end + $line =~ s/^\s+//; + $line =~ s/\s+$//; + + # Ignore empty lines (includes comment-only lines) + next if $line eq ''; + + my @cols = split /\s+/, $line; + die "test table line \"$line\" has incorrect number of columns\n" if scalar(@cols) != 4 ; + + $user = $cols[0] unless $cols[0] eq "."; + $gssencmode = $cols[1] unless $cols[1] eq "."; + $sslmode = $cols[2] unless $cols[2] eq "."; + my $outcome = $cols[3]; + + my %expanded = expand_expected_line($user, $gssencmode, $sslmode, $outcome); + %expected = (%expected, %expanded); + } + return %expected; +} + +# Expand wildcards on a test table line +sub expand_expected_line +{ + my ($user, $gssencmode, $sslmode, $expected) = @_; + + my %result; + if ($user eq '*') { + foreach my $x (@all_test_users) { + %result = (%result, expand_expected_line($x, $gssencmode, $sslmode, $expected)); + } + } elsif ($gssencmode eq '*') { + foreach my $x (@all_gssencmodes) { + %result = (%result, expand_expected_line($user, $x, $sslmode, $expected)); + } + } elsif ($sslmode eq '*') { + foreach my $x (@all_sslmodes) { + %result = (%result, expand_expected_line($user, $gssencmode, $x, $expected)); + } + } else { + $result{"$user $gssencmode $sslmode"} = $expected; + } + return %result; +} diff --git a/src/test/meson.build b/src/test/meson.build index c3d0dfedf1..702213bc6f 100644 --- a/src/test/meson.build +++ b/src/test/meson.build @@ -4,6 +4,7 @@ subdir('regress') subdir('isolation') subdir('authentication') +subdir('libpq_encryption') subdir('recovery') subdir('subscription') subdir('modules')