diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml index c6f1b70fd3..406628ef35 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. @@ -739,6 +743,24 @@ host all all ::1/128 trust # TYPE DATABASE USER ADDRESS METHOD host all all localhost trust +# The same using a regular expression for host name, which allows connection for +# host name ending with "test". +# +# TYPE DATABASE USER ADDRESS METHOD +host all all /^.*test$ trust + +# The same using regular expression for DATABASE, which allows connection to the +# db1 and testdb databases and any database with a name ending with "test". +# +# TYPE DATABASE USER ADDRESS METHOD +local db1,/^.*test$,testdb all /^.*test$ trust + +# The same using regular expression for USER, which allows connection to the +# user1 and testuser users and any user with a name ending with "test". +# +# TYPE DATABASE USER ADDRESS METHOD +local db1,/^.*test$,testdb user1,/^.*test$,testuser /^.*test$ trust + # Allow any user from any host with IP address 192.168.93.x to connect # to database "postgres" as the same user name that ident reports for # the connection (typically the operating system user name). @@ -785,16 +807,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 ending 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..6fe8c40098 100644 --- a/src/backend/libpq/hba.c +++ b/src/backend/libpq/hba.c @@ -66,6 +66,7 @@ typedef struct check_network_data } check_network_data; +#define token_is_regexp(t) (t->is_regex) #define token_is_keyword(t, k) (!t->quoted && strcmp(t->string, k) == 0) #define token_matches(t, k) (strcmp(t->string, k) == 0) @@ -117,6 +118,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,24 +578,30 @@ 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) { ListCell *cell; - AuthToken *tok; + AuthTokenOrRegex *tokreg; foreach(cell, tokens) { - tok = lfirst(cell); - if (!tok->quoted && tok->string[0] == '+') + tokreg = lfirst(cell); + if (!token_is_regexp(tokreg)) { - if (is_member(roleid, tok->string + 1)) + if (!tokreg->authtoken->quoted && tokreg->authtoken->string[0] == '+') + { + if (is_member(roleid, tokreg->authtoken->string + 1)) + return true; + } + else if (token_matches(tokreg->authtoken, role) || + token_is_keyword(tokreg->authtoken, "all")) return true; } - else if (token_matches(tok, role) || - token_is_keyword(tok, "all")) + else if (token_regexec(role, tokreg->regex)) return true; } return false; @@ -604,36 +614,41 @@ static bool check_db(const char *dbname, const char *role, Oid roleid, List *tokens) { ListCell *cell; - AuthToken *tok; + AuthTokenOrRegex *tokreg; foreach(cell, tokens) { - tok = lfirst(cell); - if (am_walsender && !am_db_walsender) - { - /* - * physical replication walsender connections can only match - * replication keyword - */ - if (token_is_keyword(tok, "replication")) - return true; - } - else if (token_is_keyword(tok, "all")) - return true; - else if (token_is_keyword(tok, "sameuser")) + tokreg = lfirst(cell); + if (!token_is_regexp(tokreg)) { - if (strcmp(dbname, role) == 0) + if (am_walsender && !am_db_walsender) + { + /* + * physical replication walsender connections can only match + * replication keyword + */ + if (token_is_keyword(tokreg->authtoken, "replication")) + return true; + } + else if (token_is_keyword(tokreg->authtoken, "all")) return true; - } - else if (token_is_keyword(tok, "samegroup") || - token_is_keyword(tok, "samerole")) - { - if (is_member(roleid, dbname)) + else if (token_is_keyword(tokreg->authtoken, "sameuser")) + { + if (strcmp(dbname, role) == 0) + return true; + } + else if (token_is_keyword(tokreg->authtoken, "samegroup") || + token_is_keyword(tokreg->authtoken, "samerole")) + { + if (is_member(roleid, dbname)) + return true; + } + else if (token_is_keyword(tokreg->authtoken, "replication")) + continue; /* never match this if not walsender */ + else if (token_matches(tokreg->authtoken, dbname)) return true; } - else if (token_is_keyword(tok, "replication")) - continue; /* never match this if not walsender */ - else if (token_matches(tok, dbname)) + else if (token_regexec(dbname, tokreg->regex)) return true; } return false; @@ -681,7 +696,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 AuthTokenOrRegex *tok_hostname) { struct addrinfo *gai_result, *gai; @@ -712,8 +727,13 @@ check_hostname(hbaPort *port, const char *hostname) port->remote_hostname = pstrdup(remote_hostname); } + if (token_is_regexp(tok_hostname)) + { + if (!token_regexec(port->remote_hostname, tok_hostname->regex)) + return false; + } /* Now see if remote host name matches this pg_hba line */ - if (!hostname_match(hostname, port->remote_hostname)) + else if (!hostname_match(tok_hostname->authtoken->string, port->remote_hostname)) return false; /* If we already verified the forward lookup, we're done */ @@ -761,7 +781,7 @@ check_hostname(hbaPort *port, const char *hostname) if (!found) elog(DEBUG2, "pg_hba.conf host name \"%s\" rejected because address resolution did not return a match with IP address of client", - hostname); + tok_hostname->authtoken->string); port->remote_hostname_resolv = found ? +1 : -1; @@ -939,13 +959,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; @@ -1052,8 +1072,31 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel) tokens = lfirst(field); foreach(tokencell, tokens) { - parsedline->databases = lappend(parsedline->databases, - copy_auth_token(lfirst(tokencell))); + AuthTokenOrRegex *tokreg; + AuthToken *tok = lfirst(tokencell); + + tokreg = (AuthTokenOrRegex *) palloc0(sizeof(AuthTokenOrRegex)); + tokreg->authtoken = copy_auth_token(lfirst(tokencell)); + if (tok->string[0] == '/') + { + /* + * When tok->string starts with a slash, treat it as a regular + * expression. Pre-compile it. + */ + regex_t *re; + + tokreg->is_regex = true; + re = (regex_t *) palloc(sizeof(regex_t)); + if (token_regcomp(re, tok->string + 1, HbaFileName, line_num, + err_msg, elevel)) + tokreg->regex = re; + else + return NULL; + } + else + tokreg->is_regex = false; + + parsedline->databases = lappend(parsedline->databases, tokreg); } /* Get the roles. */ @@ -1072,8 +1115,31 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel) tokens = lfirst(field); foreach(tokencell, tokens) { - parsedline->roles = lappend(parsedline->roles, - copy_auth_token(lfirst(tokencell))); + AuthTokenOrRegex *tokreg; + AuthToken *tok = lfirst(tokencell); + + tokreg = (AuthTokenOrRegex *) palloc0(sizeof(AuthTokenOrRegex)); + tokreg->authtoken = copy_auth_token(lfirst(tokencell)); + if (tok->string[0] == '/') + { + /* + * When tok->string starts with a slash, treat it as a regular + * expression. Pre-compile it. + */ + regex_t *re; + + tokreg->is_regex = true; + re = (regex_t *) palloc(sizeof(regex_t)); + if (token_regcomp(re, tok->string + 1, HbaFileName, line_num, + err_msg, elevel)) + tokreg->regex = re; + else + return NULL; + } + else + tokreg->is_regex = false; + + parsedline->roles = lappend(parsedline->roles, tokreg); } if (parsedline->conntype != ctLocal) @@ -1120,6 +1186,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 +1195,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; @@ -1149,7 +1220,16 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel) parsedline->addrlen = gai_result->ai_addrlen; } else if (ret == EAI_NONAME) - parsedline->hostname = str; + { + parsedline->tok_hostname.is_regex = false; + + /* + * This is ok to copy the token->string and not str here, as + * we'll error and report "specifying both host name and CIDR + * mask is invalid" below should they differ. + */ + parsedline->tok_hostname.authtoken = copy_auth_token(token); + } else { ereport(elevel, @@ -1168,9 +1248,9 @@ 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) + if (parsedline->tok_hostname.authtoken) { ereport(elevel, (errcode(ERRCODE_CONFIG_FILE_ERROR), @@ -1199,7 +1279,7 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel) parsedline->masklen = parsedline->addrlen; pfree(str); } - else if (!parsedline->hostname) + else if (!parsedline->tok_hostname.authtoken && !is_regexp) { /* Read the mask field. */ pfree(str); @@ -1261,9 +1341,25 @@ 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. + */ + regex_t *re; + + re = (regex_t *) palloc(sizeof(regex_t)); + parsedline->tok_hostname.is_regex = true; + if (!token_regcomp(re, + token->string + 1, HbaFileName, + line_num, err_msg, elevel)) + return NULL; + + parsedline->tok_hostname.regex = re; + } } } /* != ctLocal */ - /* Get the authentication method */ field = lnext(tok_line->fields, field); if (!field) @@ -2132,10 +2228,11 @@ check_hba(hbaPort *port) switch (hba->ip_cmp_method) { case ipCmpMask: - if (hba->hostname) + if (hba->tok_hostname.authtoken || hba->tok_hostname.is_regex) { if (!check_hostname(port, - hba->hostname)) + &hba->tok_hostname)) + continue; } else @@ -2342,34 +2439,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 +2778,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/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c index 9e5794071c..2b7ab0bd63 100644 --- a/src/backend/utils/adt/hbafuncs.c +++ b/src/backend/utils/adt/hbafuncs.c @@ -242,9 +242,9 @@ fill_hba_line(Tuplestorestate *tuple_store, TupleDesc tupdesc, foreach(lc, hba->databases) { - AuthToken *tok = lfirst(lc); + AuthTokenOrRegex *tok = lfirst(lc); - names = lappend(names, tok->string); + names = lappend(names, tok->authtoken->string); } values[index++] = PointerGetDatum(strlist_to_textarray(names)); } @@ -259,9 +259,9 @@ fill_hba_line(Tuplestorestate *tuple_store, TupleDesc tupdesc, foreach(lc, hba->roles) { - AuthToken *tok = lfirst(lc); + AuthTokenOrRegex *tok = lfirst(lc); - roles = lappend(roles, tok->string); + roles = lappend(roles, tok->authtoken->string); } values[index++] = PointerGetDatum(strlist_to_textarray(roles)); } @@ -274,10 +274,8 @@ fill_hba_line(Tuplestorestate *tuple_store, TupleDesc tupdesc, switch (hba->ip_cmp_method) { case ipCmpMask: - if (hba->hostname) - { - addrstr = hba->hostname; - } + if (hba->tok_hostname.authtoken) + addrstr = hba->tok_hostname.authtoken->string; else { /* diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h index d06da81806..7a53a31771 100644 --- a/src/include/libpq/hba.h +++ b/src/include/libpq/hba.h @@ -77,6 +77,32 @@ typedef enum ClientCertName clientCertDN } ClientCertName; +/* + * A single string token lexed from an authentication configuration file + * (pg_ident.conf or pg_hba.conf), together with whether the token has + * been quoted. + */ +typedef struct AuthToken +{ + char *string; + bool quoted; +} AuthToken; + +/* + * Distinguish the case a token has to be treated as a regular + * expression or not. + */ +typedef struct AuthTokenOrRegex +{ + bool is_regex; + + /* + * Not an union as we still need the token string for fill_hba_line(). + */ + AuthToken *authtoken; + regex_t *regex; +} AuthTokenOrRegex; + typedef struct HbaLine { int linenumber; @@ -89,7 +115,7 @@ typedef struct HbaLine struct sockaddr_storage mask; int masklen; /* zero if we don't have a valid mask */ IPCompareMethod ip_cmp_method; - char *hostname; + AuthTokenOrRegex tok_hostname; UserAuth auth_method; char *usermap; char *pamservice; @@ -132,17 +158,6 @@ typedef struct IdentLine regex_t re; } IdentLine; -/* - * A single string token lexed from an authentication configuration file - * (pg_ident.conf or pg_hba.conf), together with whether the token has - * been quoted. - */ -typedef struct AuthToken -{ - char *string; - bool quoted; -} AuthToken; - /* * TokenizedAuthLine represents one line lexed from an authentication * configuration file. Each item in the "fields" list is a sub-list of diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl index 93df77aa4e..5063a2601c 100644 --- a/src/test/authentication/t/001_password.pl +++ b/src/test/authentication/t/001_password.pl @@ -81,6 +81,14 @@ $node->safe_psql( GRANT ALL ON sysuser_data TO md5_role;"); $ENV{"PGPASSWORD"} = 'pass'; +# Create a role that contains a comma to stress the parsing. +$node->safe_psql('postgres', + q{SET password_encryption='md5'; CREATE ROLE "md5,role" LOGIN PASSWORD 'pass';} +); + +# Create a database to test regular expression. +$node->safe_psql('postgres', "CREATE database regex_testdb;"); + # For "trust" method, all users should be able to connect. These users are not # considered to be authenticated. reset_pg_hba($node, 'all', 'all', 'trust'); @@ -200,4 +208,37 @@ append_to_file( test_conn($node, 'user=md5_role', 'password from pgpass', 0); +# Testing with regular expression for username. Note that the third regex +# matches in this case. +reset_pg_hba($node, 'all', '/^.*nomatch.*$, baduser, /^md.*$', 'password'); +test_conn($node, 'user=md5_role', 'password, matching regexp for username', + 0); + +# The third regex does not match anymore. +reset_pg_hba($node, 'all', '/^.*nomatch.*$, baduser, /^m_d.*$', 'password'); +test_conn($node, 'user=md5_role', + 'password, non matching regexp for username', + 2, log_unlike => [qr/connection authenticated:/]); + +# test with a comma in the regular expression +reset_pg_hba($node, 'all', '"/^.*5,.*e$"', 'password'); +test_conn($node, 'user=md5,role', 'password', 'matching regexp for username', + 0); + +# Testing with regular expression for dbname. The third regex matches. +reset_pg_hba($node, '/^.*nomatch.*$, baddb, /^regex_t.*b$', 'all', + 'password'); +test_conn( + $node, 'user=md5_role dbname=regex_testdb', 'password, + matching regexp for dbname', 0); + +# The third regex does not match anymore. +reset_pg_hba($node, '/^.*nomatch.*$, baddb, /^regex_t.*ba$', + 'all', 'password'); +test_conn( + $node, + 'user=md5_role dbname=regex_testdb', + 'password, non matching regexp for dbname', + 2, log_unlike => [qr/connection authenticated:/]); + done_testing(); diff --git a/src/test/ssl/t/002_scram.pl b/src/test/ssl/t/002_scram.pl index deaa4aa086..b575557b37 100644 --- a/src/test/ssl/t/002_scram.pl +++ b/src/test/ssl/t/002_scram.pl @@ -22,7 +22,8 @@ if ($ENV{with_ssl} ne 'openssl') } elsif ($ENV{PG_TEST_EXTRA} !~ /\bssl\b/) { - plan skip_all => 'Potentially unsafe test SSL not enabled in PG_TEST_EXTRA'; + plan skip_all => + 'Potentially unsafe test SSL not enabled in PG_TEST_EXTRA'; } my $ssl_server = SSL::Server->new(); @@ -37,6 +38,20 @@ sub switch_server_cert $ssl_server->switch_server_cert(@_); } +# Delete pg_hba.conf from the given node, add a new entry to it +# and then execute a reload to refresh it. +sub reset_pg_hba +{ + my $node = shift; + my $hostname = shift; + + unlink($node->data_dir . '/pg_hba.conf'); + # just for testing purposes, use a continuation line + $node->append_conf('pg_hba.conf', "host all all $hostname scram-sha-256"); + $node->reload; + return; +} + # This is the hostname used to connect to the server. my $SERVERHOSTADDR = '127.0.0.1'; @@ -136,4 +151,25 @@ $node->connect_ok( qr/connection authenticated: identity="ssltestuser" method=scram-sha-256/ ]); +# 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); + + # Test regular expression on hostname, this one matches any host. + reset_pg_hba($node, '/^.*$'); + $node->connect_ok("$common_connstr user=ssltestuser", + "Basic SCRAM authentication with SSL matching regexp on hostname"); + # Test regular expression on hostname, this one does not match. + reset_pg_hba($node, '/^$'); + $node->connect_fails( + "$common_connstr user=ssltestuser", + "Basic SCRAM authentication with SSL non matching regexp on hostname", + log_like => [ qr/no pg_hba.conf entry for host/ ]); +} + done_testing();