From 1169920ff77025550718b90a5cafc6849875f43f Mon Sep 17 00:00:00 2001 From: Heikki Linnakangas Date: Mon, 8 Apr 2024 02:49:32 +0300 Subject: [PATCH] Add tests for libpq gssencmode and sslmode options Test all combinations of gssencmode, sslmode, whether the server supports SSL and/or GSSAPI encryption, and whether they are accepted by pg_hba.conf. This is in preparation for refactoring that code in libpq, and for adding a new option for "direct SSL" connections, which adds another dimension to the logic. If we add even more options in the future, testing all combinations will become unwieldy and we'll need to rethink this, but for now an exhaustive test is nice. Author: Heikki Linnakangas, Matthias van de Meent Reviewed-by: Jacob Champion Discussion: https://www.postgresql.org/message-id/a3af4070-3556-461d-aec8-a8d794f94894@iki.fi --- .cirrus.tasks.yml | 2 +- src/test/libpq_encryption/Makefile | 25 + src/test/libpq_encryption/README | 31 + src/test/libpq_encryption/meson.build | 18 + .../t/001_negotiate_encryption.pl | 548 ++++++++++++++++++ src/test/meson.build | 1 + 6 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 src/test/libpq_encryption/Makefile create mode 100644 src/test/libpq_encryption/README create mode 100644 src/test/libpq_encryption/meson.build create mode 100644 src/test/libpq_encryption/t/001_negotiate_encryption.pl 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')