diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml index c6f1b70fd3..28343445be 100644 --- a/doc/src/sgml/client-auth.sgml +++ b/doc/src/sgml/client-auth.sgml @@ -235,8 +235,9 @@ hostnogssenc database userPostgreSQL database. - Multiple database names can be supplied by separating them with - commas. A separate file containing database names can be specified by + Multiple database names and/or regular expressions preceded by / + can be supplied by separating them with commas. + A separate file containing database names can be specified by preceding the file name with @. @@ -249,7 +250,8 @@ hostnogssenc database userall specifies that it matches all users. Otherwise, this is either the name of a specific - database user, or a group name preceded by +. + database user, a regular expression preceded by / + or a group name preceded by +. (Recall that there is no real distinction between users and groups in PostgreSQL; a + mark really means match any of the roles that are directly or indirectly members @@ -258,7 +260,8 @@ hostnogssenc database user/ + can be supplied by separating them with commas. A separate file containing user names can be specified by preceding the file name with @. @@ -270,8 +273,9 @@ hostnogssenc database user Specifies the client machine address(es) that this record - matches. This field can contain either a host name, an IP - address range, or one of the special key words mentioned below. + matches. This field can contain either a host name, a regular expression + preceded by / representing host names, an IP address range, + or one of the special key words mentioned below. @@ -785,16 +789,18 @@ host all all 192.168.12.10/32 gss # TYPE DATABASE USER ADDRESS METHOD host all all 192.168.0.0/16 ident map=omicron -# If these are the only three lines for local connections, they will +# If these are the only four lines for local connections, they will # allow local users to connect only to their own databases (databases -# with the same name as their database user name) except for administrators -# and members of role "support", who can connect to all databases. The file -# $PGDATA/admins contains a list of names of administrators. Passwords +# with the same name as their database user name) except for administrators, +# users finishing with "helpdesk" and members of role "support", +# who can connect to all databases. +# The file$PGDATA/admins contains a list of names of administrators. Passwords # are required in all cases. # # TYPE DATABASE USER ADDRESS METHOD local sameuser all md5 local all @admins md5 +local all /^.*helpdesk$ md5 local all +support md5 # The last two lines above can be combined into a single line: diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c index 4637426d62..89a4fa8b98 100644 --- a/src/backend/libpq/hba.c +++ b/src/backend/libpq/hba.c @@ -117,6 +117,9 @@ static List *tokenize_inc_file(List *tokens, const char *outer_filename, const char *inc_filename, int elevel, char **err_msg); static bool parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline, int elevel, char **err_msg); +static bool token_regcomp(regex_t *re, char *string, char *filename, + int line_num, char **err_msg, int elevel); +static bool token_regexec(const char *match, regex_t *re); /* @@ -574,13 +577,15 @@ is_member(Oid userid, const char *role) } /* - * Check AuthToken list for a match to role, allowing group names. + * Check AuthToken list for a match to role. + * We are allowing group names and regular expressions. */ static bool -check_role(const char *role, Oid roleid, List *tokens) +check_role(const char *role, Oid roleid, List *tokens, List *tokens_re) { ListCell *cell; AuthToken *tok; + int re_num = 0; foreach(cell, tokens) { @@ -590,6 +595,23 @@ check_role(const char *role, Oid roleid, List *tokens) if (is_member(roleid, tok->string + 1)) return true; } + else if (!tok->quoted && tok->string[0] == '/') + { + /* + * When tok->string starts with a slash, treat it as a regular + * expression. + */ + ListCell *cell_re; + regex_t *re; + + cell_re = list_nth_cell(tokens_re, re_num); + re = lfirst(cell_re); + + if (token_regexec(role, re)) + return true; + + re_num++; + } else if (token_matches(tok, role) || token_is_keyword(tok, "all")) return true; @@ -601,10 +623,11 @@ check_role(const char *role, Oid roleid, List *tokens) * Check to see if db/role combination matches AuthToken list. */ static bool -check_db(const char *dbname, const char *role, Oid roleid, List *tokens) +check_db(const char *dbname, const char *role, Oid roleid, List *tokens, List *tokens_re) { ListCell *cell; AuthToken *tok; + int re_num = 0; foreach(cell, tokens) { @@ -633,6 +656,23 @@ check_db(const char *dbname, const char *role, Oid roleid, List *tokens) } else if (token_is_keyword(tok, "replication")) continue; /* never match this if not walsender */ + else if (!tok->quoted && tok->string[0] == '/') + { + /* + * When tok->string starts with a slash, treat it as a regular + * expression. + */ + ListCell *cell_re; + regex_t *re; + + cell_re = list_nth_cell(tokens_re, re_num); + re = lfirst(cell_re); + + if (token_regexec(dbname, re)) + return true; + + re_num++; + } else if (token_matches(tok, dbname)) return true; } @@ -681,7 +721,7 @@ hostname_match(const char *pattern, const char *actual_hostname) * Check to see if a connecting IP matches a given host name. */ static bool -check_hostname(hbaPort *port, const char *hostname) +check_hostname(hbaPort *port, const char *hostname, regex_t re) { struct addrinfo *gai_result, *gai; @@ -712,8 +752,17 @@ check_hostname(hbaPort *port, const char *hostname) port->remote_hostname = pstrdup(remote_hostname); } + if (hostname[0] == '/') + { + /* + * When hostname starts with a slash, treat it as a regular + * expression. + */ + if (!token_regexec(port->remote_hostname, &re)) + return false; + } /* Now see if remote host name matches this pg_hba line */ - if (!hostname_match(hostname, port->remote_hostname)) + else if (!hostname_match(hostname, port->remote_hostname)) return false; /* If we already verified the forward lookup, we're done */ @@ -939,13 +988,13 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel) struct addrinfo *gai_result; struct addrinfo hints; int ret; - char *cidr_slash; char *unsupauth; ListCell *field; List *tokens; ListCell *tokencell; AuthToken *token; HbaLine *parsedline; + char *cidr_slash = NULL; /* keep compiler quiet */ parsedline = palloc0(sizeof(HbaLine)); parsedline->linenumber = line_num; @@ -1049,9 +1098,27 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel) return NULL; } parsedline->databases = NIL; + parsedline->databases_re = NIL; tokens = lfirst(field); foreach(tokencell, tokens) { + AuthToken *tok = lfirst(tokencell); + + if (!tok->quoted && tok->string[0] == '/') + { + /* + * When tok->string starts with a slash, treat it as a regular + * expression. Pre-compile it. + */ + regex_t *re; + + re = (regex_t *) palloc(sizeof(regex_t)); + if (token_regcomp(re, tok->string + 1, HbaFileName, line_num, + err_msg, elevel)) + parsedline->databases_re = lappend(parsedline->databases_re, re); + else + return NULL; + } parsedline->databases = lappend(parsedline->databases, copy_auth_token(lfirst(tokencell))); } @@ -1069,9 +1136,27 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel) return NULL; } parsedline->roles = NIL; + parsedline->roles_re = NIL; tokens = lfirst(field); foreach(tokencell, tokens) { + AuthToken *tok = lfirst(tokencell); + + if (!tok->quoted && tok->string[0] == '/') + { + /* + * When tok->string starts with a slash, treat it as a regular + * expression. Pre-compile it. + */ + regex_t *re; + + re = (regex_t *) palloc(sizeof(regex_t)); + if (token_regcomp(re, tok->string + 1, HbaFileName, line_num, + err_msg, elevel)) + parsedline->roles_re = lappend(parsedline->roles_re, re); + else + return NULL; + } parsedline->roles = lappend(parsedline->roles, copy_auth_token(lfirst(tokencell))); } @@ -1120,6 +1205,8 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel) } else { + bool is_regexp = token->string[0] == '/' ? true : false; + /* IP and netmask are specified */ parsedline->ip_cmp_method = ipCmpMask; @@ -1127,9 +1214,12 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel) str = pstrdup(token->string); /* Check if it has a CIDR suffix and if so isolate it */ - cidr_slash = strchr(str, '/'); - if (cidr_slash) - *cidr_slash = '\0'; + if (!is_regexp) + { + cidr_slash = strchr(str, '/'); + if (cidr_slash) + *cidr_slash = '\0'; + } /* Get the IP address either way */ hints.ai_flags = AI_NUMERICHOST; @@ -1168,7 +1258,7 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel) pg_freeaddrinfo_all(hints.ai_family, gai_result); /* Get the netmask */ - if (cidr_slash) + if (cidr_slash && !is_regexp) { if (parsedline->hostname) { @@ -1199,7 +1289,7 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel) parsedline->masklen = parsedline->addrlen; pfree(str); } - else if (!parsedline->hostname) + else if (!parsedline->hostname && !is_regexp) { /* Read the mask field. */ pfree(str); @@ -1261,6 +1351,18 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel) return NULL; } } + else if (is_regexp) + { + /* + * When token->string starts with a slash, treat it as a + * regular expression. Pre-compile it. + */ + if (!token_regcomp(&parsedline->hostname_re, + token->string + 1, HbaFileName, + line_num, err_msg, elevel)) + return NULL; + parsedline->hostname = str; + } } } /* != ctLocal */ @@ -2135,7 +2237,8 @@ check_hba(hbaPort *port) if (hba->hostname) { if (!check_hostname(port, - hba->hostname)) + hba->hostname, hba->hostname_re)) + continue; } else @@ -2162,10 +2265,10 @@ check_hba(hbaPort *port) /* Check database and role */ if (!check_db(port->database_name, port->user_name, roleid, - hba->databases)) + hba->databases, hba->databases_re)) continue; - if (!check_role(port->user_name, roleid, hba->roles)) + if (!check_role(port->user_name, roleid, hba->roles, hba->roles_re)) continue; /* Found a record that matched! */ @@ -2342,34 +2445,9 @@ parse_ident_line(TokenizedAuthLine *tok_line, int elevel) * When system username starts with a slash, treat it as a regular * expression. Pre-compile it. */ - int r; - pg_wchar *wstr; - int wlen; - - wstr = palloc((strlen(parsedline->ident_user + 1) + 1) * sizeof(pg_wchar)); - wlen = pg_mb2wchar_with_len(parsedline->ident_user + 1, - wstr, strlen(parsedline->ident_user + 1)); - - r = pg_regcomp(&parsedline->re, wstr, wlen, REG_ADVANCED, C_COLLATION_OID); - if (r) - { - char errstr[100]; - - pg_regerror(r, &parsedline->re, errstr, sizeof(errstr)); - ereport(elevel, - (errcode(ERRCODE_INVALID_REGULAR_EXPRESSION), - errmsg("invalid regular expression \"%s\": %s", - parsedline->ident_user + 1, errstr), - errcontext("line %d of configuration file \"%s\"", - line_num, IdentFileName))); - - *err_msg = psprintf("invalid regular expression \"%s\": %s", - parsedline->ident_user + 1, errstr); - - pfree(wstr); + if (!token_regcomp(&parsedline->re, parsedline->ident_user + 1, + IdentFileName, line_num, err_msg, elevel)) return NULL; - } - pfree(wstr); } return parsedline; @@ -2706,3 +2784,68 @@ hba_authname(UserAuth auth_method) return UserAuthName[auth_method]; } + +/* + * Compile the regular expression "re" and return whether it compiles + * successfully or not. + * + * If not, the last 4 parameters are used to add extra details while reporting + * the error. + */ +static bool +token_regcomp(regex_t *re, char *string, char *filename, int line_num, + char **err_msg, int elevel) +{ + int r; + pg_wchar *wstr; + int wlen; + + wstr = palloc((strlen(string) + 1) * sizeof(pg_wchar)); + wlen = pg_mb2wchar_with_len(string, + wstr, strlen(string)); + + r = pg_regcomp(re, wstr, wlen, REG_ADVANCED, C_COLLATION_OID); + if (r) + { + char errstr[100]; + + pg_regerror(r, re, errstr, sizeof(errstr)); + ereport(elevel, + (errcode(ERRCODE_INVALID_REGULAR_EXPRESSION), + errmsg("invalid regular expression \"%s\": %s", + string, errstr), + errcontext("line %d of configuration file \"%s\"", + line_num, filename))); + + *err_msg = psprintf("invalid regular expression \"%s\": %s", + string, errstr); + + pfree(wstr); + return false; + } + + pfree(wstr); + return true; +} + +/* + * Return whether "match" is matching the regular expression "re" or not. + */ +static bool +token_regexec(const char *match, regex_t *re) +{ + pg_wchar *wmatchstr; + int wmatchlen; + + wmatchstr = palloc((strlen(match) + 1) * sizeof(pg_wchar)); + wmatchlen = pg_mb2wchar_with_len(match, wmatchstr, strlen(match)); + + if (pg_regexec(re, wmatchstr, wmatchlen, 0, NULL, 0, NULL, 0) == REG_OKAY) + { + pfree(wmatchstr); + return true; + } + + pfree(wmatchstr); + return false; +} diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h index d06da81806..24fe502d06 100644 --- a/src/include/libpq/hba.h +++ b/src/include/libpq/hba.h @@ -120,6 +120,9 @@ typedef struct HbaLine char *radiusidentifiers_s; List *radiusports; char *radiusports_s; + List *roles_re; + List *databases_re; + regex_t hostname_re; } HbaLine; typedef struct IdentLine diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl index 3e3079c824..210583c406 100644 --- a/src/test/authentication/t/001_password.pl +++ b/src/test/authentication/t/001_password.pl @@ -23,29 +23,32 @@ if (!$use_unix_sockets) # and then execute a reload to refresh it. sub reset_pg_hba { - my $node = shift; - my $hba_method = shift; + my $node = shift; + my $host = shift; + my $database = shift; + my $role = shift; + my $method = shift; unlink($node->data_dir . '/pg_hba.conf'); # just for testing purposes, use a continuation line - $node->append_conf('pg_hba.conf', "local all all\\\n $hba_method"); + $node->append_conf('pg_hba.conf', "$host $database $role\\\n $method"); $node->reload; return; } # Test access for a single role, useful to wrap all tests into one. Extra # named parameters are passed to connect_ok/fails as-is. -sub test_role +sub test_conn { local $Test::Builder::Level = $Test::Builder::Level + 1; - my ($node, $role, $method, $expected_res, %params) = @_; + my ($node, $conn, $method, $expected_res, %params) = @_; my $status_string = 'failed'; $status_string = 'success' if ($expected_res eq 0); - my $connstr = "user=$role"; + my $connstr = "$conn"; my $testname = - "authentication $status_string for method $method, role $role"; + "authentication $status_string for method $method, conn $conn"; if ($expected_res eq 0) { @@ -61,7 +64,11 @@ sub test_role # Initialize primary node my $node = PostgreSQL::Test::Cluster->new('primary'); $node->init; -$node->append_conf('postgresql.conf', "log_connections = on\n"); +$node->append_conf( + 'postgresql.conf', qq{ +listen_addresses = '127.0.0.1' +log_connections = on +}); $node->start; # Create 3 roles with different password methods for each one. The same @@ -74,61 +81,66 @@ $node->safe_psql('postgres', ); $ENV{"PGPASSWORD"} = 'pass'; +# Create a database to test regular expression +$node->safe_psql('postgres', + "CREATE database testdb;" +); + # For "trust" method, all users should be able to connect. These users are not # considered to be authenticated. -reset_pg_hba($node, 'trust'); -test_role($node, 'scram_role', 'trust', 0, +reset_pg_hba($node, 'local','all', 'all', 'trust'); +test_conn($node, 'user=scram_role', 'trust', 0, log_unlike => [qr/connection authenticated:/]); -test_role($node, 'md5_role', 'trust', 0, +test_conn($node, 'user=md5_role', 'trust', 0, log_unlike => [qr/connection authenticated:/]); # For plain "password" method, all users should also be able to connect. -reset_pg_hba($node, 'password'); -test_role($node, 'scram_role', 'password', 0, +reset_pg_hba($node, 'local', 'all', 'all', 'password'); +test_conn($node, 'user=scram_role', 'password', 0, log_like => [qr/connection authenticated: identity="scram_role" method=password/]); -test_role($node, 'md5_role', 'password', 0, +test_conn($node, 'user=md5_role', 'password', 0, log_like => [qr/connection authenticated: identity="md5_role" method=password/]); # For "scram-sha-256" method, user "scram_role" should be able to connect. -reset_pg_hba($node, 'scram-sha-256'); -test_role( +reset_pg_hba($node, 'local', 'all', 'all', 'scram-sha-256'); +test_conn( $node, - 'scram_role', + 'user=scram_role', 'scram-sha-256', 0, log_like => [ qr/connection authenticated: identity="scram_role" method=scram-sha-256/ ]); -test_role($node, 'md5_role', 'scram-sha-256', 2, +test_conn($node, 'user=md5_role', 'scram-sha-256', 2, log_unlike => [qr/connection authenticated:/]); # Test that bad passwords are rejected. $ENV{"PGPASSWORD"} = 'badpass'; -test_role($node, 'scram_role', 'scram-sha-256', 2, +test_conn($node, 'user=scram_role', 'scram-sha-256', 2, log_unlike => [qr/connection authenticated:/]); $ENV{"PGPASSWORD"} = 'pass'; # For "md5" method, all users should be able to connect (SCRAM # authentication will be performed for the user with a SCRAM secret.) -reset_pg_hba($node, 'md5'); -test_role($node, 'scram_role', 'md5', 0, +reset_pg_hba($node, 'local', 'all', 'all', 'md5'); +test_conn($node, 'user=scram_role', 'md5', 0, log_like => [qr/connection authenticated: identity="scram_role" method=md5/]); -test_role($node, 'md5_role', 'md5', 0, +test_conn($node, 'user=md5_role', 'md5', 0, log_like => [qr/connection authenticated: identity="md5_role" method=md5/]); # Tests for channel binding without SSL. # Using the password authentication method; channel binding can't work -reset_pg_hba($node, 'password'); +reset_pg_hba($node, 'local', 'all', 'all', 'password'); $ENV{"PGCHANNELBINDING"} = 'require'; -test_role($node, 'scram_role', 'scram-sha-256', 2); +test_conn($node, 'user=scram_role', 'scram-sha-256', 2); # SSL not in use; channel binding still can't work -reset_pg_hba($node, 'scram-sha-256'); +reset_pg_hba($node, 'local', 'all', 'all', 'scram-sha-256'); $ENV{"PGCHANNELBINDING"} = 'require'; -test_role($node, 'scram_role', 'scram-sha-256', 2); +test_conn($node, 'user=scram_role', 'scram-sha-256', 2); # Test .pgpass processing; but use a temp file, don't overwrite the real one! my $pgpassfile = "${PostgreSQL::Test::Utils::tmp_check}/pgpass"; @@ -145,15 +157,45 @@ append_to_file( !); chmod 0600, $pgpassfile or die; -reset_pg_hba($node, 'password'); -test_role($node, 'scram_role', 'password from pgpass', 0); -test_role($node, 'md5_role', 'password from pgpass', 2); +reset_pg_hba($node, 'local', 'all', 'all', 'password'); +test_conn($node, 'user=scram_role', 'password from pgpass', 0); +test_conn($node, 'user=md5_role', 'password from pgpass', 2); append_to_file( $pgpassfile, qq! *:*:*:md5_role:p\\ass !); -test_role($node, 'md5_role', 'password from pgpass', 0); +test_conn($node, 'user=md5_role', 'password from pgpass', 0); + +# Testing with regular expression for username +reset_pg_hba($node, 'local', 'all', '/^.*nomatch.*$, baduser, /^.*md.*$', 'password'); +test_conn($node, 'user=md5_role', 'password, matching regexp for username', 0); + +reset_pg_hba($node, 'local', 'all', '/^.*nomatch.*$, baduser, /^.*m_d.*$', 'password'); +test_conn($node, 'user=md5_role', 'password, non matching regexp for username', 2, + log_unlike => [qr/connection authenticated:/]); + +# Testing with regular expression for dbname +reset_pg_hba($node, 'local', '/^.*nomatch.*$, baddb, /^t.*b$', 'all', 'password'); +test_conn($node, 'user=md5_role dbname=testdb', 'password, matching regexp for dbname', 0); +reset_pg_hba($node, 'local', '/^.*nomatch.*$, baddb, /^t.*ba$', 'all', 'password'); +test_conn($node, 'user=md5_role dbname=testdb', 'password, non matching regexp for dbname', 2, + log_unlike => [qr/connection authenticated:/]); + +# Testing with regular expression for hostname +SKIP: +{ + # Being able to do a reverse lookup of a hostname on Windows for localhost + # is not guaranteed on all environments by default. + # So, skip the regular expression test for hostname on Windows. + skip "Regular expression for hostname not tested on Windows", 2 if ($windows_os); + + reset_pg_hba($node, 'host', 'all', 'all /^.*$', 'password'); + test_conn($node, 'user=md5_role host=localhost', 'password, matching regexp for hostname', 0); + + reset_pg_hba($node, 'host', 'all', 'all /^$', 'password'); + test_conn($node, 'user=md5_role host=localhost', 'password, non matching regexp for hostname', 2); +} done_testing();