Subject: [PATCH] Accept IP addresses in server certificate SANs

From: Jacob Champion <[email-masked]>
Date: Thu, 16 Dec 2021 01:13:57 +0000

Hello all,

libpq currently supports server certificates with a single IP address
in the Common Name. It's fairly brittle; as far as I can tell, the
single name you choose has to match the client's address exactly.

Attached is a patch for libpq to support IP addresses in the server's
Subject Alternative Names, which would allow admins to issue certs for
multiple IP addresses, both IPv4 and IPv6, and mix them with
alternative DNS hostnames. These addresses are compared bytewise
instead of stringwise, so the client can contact the server via
alternative spellings of the same IP address.

This patch arose because I was writing tests for the NSS implementation
that used a server cert with both DNS names and IP addresses, and then
they failed when I ran those tests against the OpenSSL implementation.
NSS supports this functionality natively. Anecdotally, I've heard from
at least one client group who is utilizing IP-based certificates in
their cloud deployments. It seems uncommon but still useful.

There are two open questions I have; they're based on NSS
implementation details that I did not port here:

- NSS allows an IPv4 SAN to match an IPv6 mapping of that same address,
  and vice-versa. I chose not to implement that behavior, figuring it
  is easy enough for people to issue a certificate with both addresses.
  Is that okay?

- If a certificate contains only iPAddress SANs, and none of them
  match, I fall back to check the certificate Common Name. OpenSSL will
  not do this (its X509_check_ip considers only SANs). NSS will only do
  this if the client's address is itself a DNS name. The spec says that
  we can't fall back to Common Name if the SANs contain any DNS
  entries, but it's silent on the subject of IP addresses. What should
  the behavior be?

The patchset roadmap:

- 0001 moves inet_net_pton() to src/port, since libpq will need it.
- 0002 implements the new functionality and adds tests.

WDYT?

Thanks,
--Jacob
From db56d6593a4574c243ccee1dbb4c8c1f1c712795 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email-masked]>
Date: Wed, 24 Nov 2021 14:46:11 -0800
Subject: [PATCH 1/2] Move inet_net_pton() to src/port

This will be helpful for IP address verification in libpq.
---
 src/backend/utils/adt/Makefile                  |  1 -
 src/include/port.h                              |  3 +++
 src/include/utils/builtins.h                    |  4 ----
 src/port/Makefile                               |  1 +
 src/{backend/utils/adt => port}/inet_net_pton.c | 16 +++++++++++++++-
 5 files changed, 19 insertions(+), 6 deletions(-)
 rename src/{backend/utils/adt => port}/inet_net_pton.c (96%)

diff --git a/src/backend/utils/adt/Makefile b/src/backend/utils/adt/Makefile
index 41b486bcef..d173d52157 100644
--- a/src/backend/utils/adt/Makefile
+++ b/src/backend/utils/adt/Makefile
@@ -43,7 +43,6 @@ OBJS = \
 	geo_selfuncs.o \
 	geo_spgist.o \
 	inet_cidr_ntop.o \
-	inet_net_pton.o \
 	int.o \
 	int8.o \
 	json.o \
diff --git a/src/include/port.h b/src/include/port.h
index fd9c9d6f94..cca29839bc 100644
--- a/src/include/port.h
+++ b/src/include/port.h
@@ -518,6 +518,9 @@ extern int	pg_codepage_to_encoding(UINT cp);
 extern char *pg_inet_net_ntop(int af, const void *src, int bits,
 							  char *dst, size_t size);
 
+/* port/inet_net_pton.c */
+extern int	pg_inet_net_pton(int af, const char *src, void *dst, size_t size);
+
 /* port/pg_strong_random.c */
 extern void pg_strong_random_init(void);
 extern bool pg_strong_random(void *buf, size_t len);
diff --git a/src/include/utils/builtins.h b/src/include/utils/builtins.h
index 40fcb0ab6d..d063a86260 100644
--- a/src/include/utils/builtins.h
+++ b/src/include/utils/builtins.h
@@ -93,10 +93,6 @@ extern int	xidComparator(const void *arg1, const void *arg2);
 extern char *pg_inet_cidr_ntop(int af, const void *src, int bits,
 							   char *dst, size_t size);
 
-/* inet_net_pton.c */
-extern int	pg_inet_net_pton(int af, const char *src,
-							 void *dst, size_t size);
-
 /* network.c */
 extern double convert_network_to_scalar(Datum value, Oid typid, bool *failure);
 extern Datum network_scan_first(Datum in);
diff --git a/src/port/Makefile b/src/port/Makefile
index b3754d8940..7191cd6633 100644
--- a/src/port/Makefile
+++ b/src/port/Makefile
@@ -43,6 +43,7 @@ OBJS = \
 	bsearch_arg.o \
 	chklocale.o \
 	inet_net_ntop.o \
+	inet_net_pton.o \
 	noblock.o \
 	path.o \
 	pg_bitutils.o \
diff --git a/src/backend/utils/adt/inet_net_pton.c b/src/port/inet_net_pton.c
similarity index 96%
rename from src/backend/utils/adt/inet_net_pton.c
rename to src/port/inet_net_pton.c
index d3221a1313..bae50ba67e 100644
--- a/src/backend/utils/adt/inet_net_pton.c
+++ b/src/port/inet_net_pton.c
@@ -14,14 +14,18 @@
  * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
  * OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  *
- *	  src/backend/utils/adt/inet_net_pton.c
+ *	  src/port/inet_net_pton.c
  */
 
 #if defined(LIBC_SCCS) && !defined(lint)
 static const char rcsid[] = "Id: inet_net_pton.c,v 1.4.2.3 2004/03/17 00:40:11 marka Exp $";
 #endif
 
+#ifndef FRONTEND
 #include "postgres.h"
+#else
+#include "postgres_fe.h"
+#endif
 
 #include <sys/socket.h>
 #include <netinet/in.h>
@@ -29,9 +33,19 @@ static const char rcsid[] = "Id: inet_net_pton.c,v 1.4.2.3 2004/03/17 00:40:11 m
 #include <assert.h>
 #include <ctype.h>
 
+#ifndef FRONTEND
 #include "utils/builtins.h" /* pgrminclude ignore */	/* needed on some
 														 * platforms */
 #include "utils/inet.h"
+#else
+/*
+ * In a frontend build, we can't include inet.h, but we still need to have
+ * sensible definitions of these two constants.  Note that pg_inet_net_ntop()
+ * assumes that PGSQL_AF_INET is equal to AF_INET.
+ */
+#define PGSQL_AF_INET	(AF_INET + 0)
+#define PGSQL_AF_INET6	(AF_INET + 1)
+#endif

 static int	inet_net_pton_ipv4(const char *src, u_char *dst);
-- 
2.25.1

From ddf36f30a5b08ef2db758c32c6e6219b299a6ba9 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email-masked]>
Date: Thu, 18 Nov 2021 15:36:18 -0800
Subject: [PATCH 2/2] libpq: allow IP address SANs in server certs

The current implementation supports exactly one IP address in a server
certificate's Common Name, which is brittle (the strings must match
exactly).  This patch adds support for IPv4 and IPv6 addresses in a
server's Subject Alternative Names.

Open questions:

- Should we fall back to Subject Common Name if an IP address SAN is
  present and the client's expected host is an IP address? See TODO in
  pgtls_verify_peer_name_matches_certificate_guts().

- We don't map IPv4 to IPv6 addresses and vice-versa. NSS does; OpenSSL
  does not. Should we?
---
 src/interfaces/libpq/fe-secure-common.c       | 110 ++++++++++++++++++
 src/interfaces/libpq/fe-secure-common.h       |   3 +
 src/interfaces/libpq/fe-secure-openssl.c      |  73 ++++++++++--
 .../conf/server-cn-and-ip-alt-names.config    |  24 ++++
 src/test/ssl/conf/server-ip-alt-names.config  |  19 +++
 src/test/ssl/conf/server-ip-cn-only.config    |  12 ++
 .../ssl/ssl/server-cn-and-ip-alt-names.crt    |  20 ++++
 .../ssl/ssl/server-cn-and-ip-alt-names.key    |  27 +++++
 src/test/ssl/ssl/server-ip-alt-names.crt      |  19 +++
 src/test/ssl/ssl/server-ip-alt-names.key      |  27 +++++
 src/test/ssl/ssl/server-ip-cn-only.crt        |  18 +++
 src/test/ssl/ssl/server-ip-cn-only.key        |  27 +++++
 src/test/ssl/sslfiles.mk                      |   3 +
 src/test/ssl/t/001_ssltests.pl                |  77 +++++++++++-
 14 files changed, 448 insertions(+), 11 deletions(-)
 create mode 100644 src/test/ssl/conf/server-cn-and-ip-alt-names.config
 create mode 100644 src/test/ssl/conf/server-ip-alt-names.config
 create mode 100644 src/test/ssl/conf/server-ip-cn-only.config
 create mode 100644 src/test/ssl/ssl/server-cn-and-ip-alt-names.crt
 create mode 100644 src/test/ssl/ssl/server-cn-and-ip-alt-names.key
 create mode 100644 src/test/ssl/ssl/server-ip-alt-names.crt
 create mode 100644 src/test/ssl/ssl/server-ip-alt-names.key
 create mode 100644 src/test/ssl/ssl/server-ip-cn-only.crt
 create mode 100644 src/test/ssl/ssl/server-ip-cn-only.key

diff --git a/src/interfaces/libpq/fe-secure-common.c b/src/interfaces/libpq/fe-secure-common.c
index afa5d133e1..38ee65abf2 100644
--- a/src/interfaces/libpq/fe-secure-common.c
+++ b/src/interfaces/libpq/fe-secure-common.c
@@ -19,11 +19,23 @@
 
 #include "postgres_fe.h"
 
+#include <netinet/in.h>
+#include <arpa/inet.h>
+
 #include "fe-secure-common.h"
 
 #include "libpq-int.h"
+#include "port.h"
 #include "pqexpbuffer.h"
 
+/*
+ * In a frontend build, we can't include inet.h, but we still need to have
+ * sensible definitions of these two constants.  Note that pg_inet_net_ntop()
+ * assumes that PGSQL_AF_INET is equal to AF_INET.
+ */
+#define PGSQL_AF_INET	(AF_INET + 0)
+#define PGSQL_AF_INET6	(AF_INET + 1)
+
 /*
  * Check if a wildcard certificate matches the server hostname.
  *
@@ -144,6 +156,104 @@ pq_verify_peer_name_matches_certificate_name(PGconn *conn,
 	return result;
 }
 
+/*
+ * Check if an IP address from a server's certificate matches the peer's
+ * hostname (which must itself be an IPv4/6 address).
+ *
+ * Returns 1 if the address matches, and 0 if it does not. On error, returns
+ * -1, and sets the libpq error message.
+ *
+ * A string representation of the certificate's IP address is returned in
+ * *store_name. The caller is responsible for freeing it.
+ */
+int
+pq_verify_peer_name_matches_certificate_ip(PGconn *conn,
+										   const char *ipdata, size_t iplen,
+										   char **store_name)
+{
+	char	   *addrstr;
+	int			match = 0;
+	char	   *host = conn->connhost[conn->whichhost].host;
+	int			family;
+	char		tmp[sizeof "ffff:ffff:ffff:ffff:ffff:ffff:255.255.255.255"];
+	char		sebuf[PG_STRERROR_R_BUFLEN];
+
+	*store_name = NULL;
+
+	if (!(host && host[0] != '\0'))
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("host name must be specified\n"));
+		return -1;
+	}
+
+	/*
+	 * The data from the certificate is in network byte order. Convert our host
+	 * string to network-ordered bytes as well, for comparison. (The host string
+	 * isn't guaranteed to actually be an IP address, so if this conversion
+	 * fails we need to consider it a mismatch rather than an error.)
+	 */
+	if (iplen == 4)
+	{
+		/* IPv4 */
+		struct in_addr addr;
+
+		family = PGSQL_AF_INET;
+
+		/*
+		 * The use of inet_aton() instead of inet_pton() is deliberate; the
+		 * latter cannot handle alternate IPv4 notations ("numbers-and-dots").
+		 */
+		if (inet_aton(host, &addr))
+		{
+			if (memcmp(ipdata, &addr.s_addr, iplen) == 0)
+				match = 1;
+		}
+	}
+	else if (iplen == 16)
+	{
+		/* IPv6 */
+		unsigned char addr[16];
+
+		family = PGSQL_AF_INET6;
+
+		/*
+		 * pg_inet_net_pton() will accept CIDR masks, which we don't want to
+		 * match, so skip the comparison if the host string contains a slash.
+		 */
+		if (!strchr(host, '/')
+			&& pg_inet_net_pton(PGSQL_AF_INET6, host, addr, -1) == 128)
+		{
+			if (memcmp(ipdata, addr, iplen) == 0)
+				match = 1;
+		}
+	}
+	else
+	{
+		/*
+		 * Not IPv4 or IPv6. We could ignore the field, but leniency seems wrong
+		 * given the subject matter.
+		 */
+		appendPQExpBuffer(&conn->errorMessage,
+						  libpq_gettext("certificate contains IP address with invalid length %lu\n"),
+						  (unsigned long) iplen);
+		return -1;
+	}
+
+	/* Generate a human-readable representation of the certificate's IP. */
+	addrstr = pg_inet_net_ntop(family, ipdata, 8 * iplen, tmp, sizeof(tmp));
+	if (!addrstr)
+	{
+		appendPQExpBuffer(&conn->errorMessage,
+						  libpq_gettext("could not convert certificate's IP address to string: %s\n"),
+						  strerror_r(errno, sebuf, sizeof(sebuf)));
+		return -1;
+	}
+
+	*store_name = strdup(addrstr);
+	return match;
+}
+
 /*
  * Verify that the server certificate matches the hostname we connected to.
  *
diff --git a/src/interfaces/libpq/fe-secure-common.h b/src/interfaces/libpq/fe-secure-common.h
index 2389f6717a..a090a92f60 100644
--- a/src/interfaces/libpq/fe-secure-common.h
+++ b/src/interfaces/libpq/fe-secure-common.h
@@ -21,6 +21,9 @@
 extern int	pq_verify_peer_name_matches_certificate_name(PGconn *conn,
 														 const char *namedata, size_t namelen,
 														 char **store_name);
+extern int	pq_verify_peer_name_matches_certificate_ip(PGconn *conn,
+													   const char *addrdata, size_t addrlen,
+													   char **store_name);
 extern bool pq_verify_peer_name_matches_certificate(PGconn *conn);
 
 #endif							/* FE_SECURE_COMMON_H */
diff --git a/src/interfaces/libpq/fe-secure-openssl.c b/src/interfaces/libpq/fe-secure-openssl.c
index 33f095c12e..16c5ff9223 100644
--- a/src/interfaces/libpq/fe-secure-openssl.c
+++ b/src/interfaces/libpq/fe-secure-openssl.c
@@ -72,6 +72,9 @@ static int	verify_cb(int ok, X509_STORE_CTX *ctx);
 static int	openssl_verify_peer_name_matches_certificate_name(PGconn *conn,
 															  ASN1_STRING *name,
 															  char **store_name);
+static int	openssl_verify_peer_name_matches_certificate_ip(PGconn *conn,
+															ASN1_OCTET_STRING *addr_entry,
+															char **store_name);
 static void destroy_ssl_system(void);
 static int	initialize_SSL(PGconn *conn);
 static PostgresPollingStatusType open_client_SSL(PGconn *);
@@ -509,6 +512,42 @@ openssl_verify_peer_name_matches_certificate_name(PGconn *conn, ASN1_STRING *nam
 	return pq_verify_peer_name_matches_certificate_name(conn, (const char *) namedata, len, store_name);
 }
 
+/*
+ * OpenSSL-specific wrapper around
+ * pq_verify_peer_name_matches_certificate_ip(), converting the
+ * ASN1_OCTET_STRING into a plain C string.
+ */
+static int
+openssl_verify_peer_name_matches_certificate_ip(PGconn *conn,
+												ASN1_OCTET_STRING *addr_entry,
+												char **store_name)
+{
+	int			len;
+	const unsigned char *addrdata;
+
+	/* Should not happen... */
+	if (addr_entry == NULL)
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("SSL certificate's address entry is missing\n"));
+		return -1;
+	}
+
+	/*
+	 * GEN_IPADD is an OCTET STRING containing an IP address in network byte
+	 * order.
+	 */
+#ifdef HAVE_ASN1_STRING_GET0_DATA
+	addrdata = ASN1_STRING_get0_data(addr_entry);
+#else
+	addrdata = ASN1_STRING_data(addr_entry);
+#endif
+	len = ASN1_STRING_length(addr_entry);
+
+	/* OK to cast from unsigned to plain char, since it's all ASCII. */
+	return pq_verify_peer_name_matches_certificate_ip(conn, (const char *) addrdata, len, store_name);
+}
+
 /*
  *	Verify that the server certificate matches the hostname we connected to.
  *
@@ -522,6 +561,7 @@ pgtls_verify_peer_name_matches_certificate_guts(PGconn *conn,
 	STACK_OF(GENERAL_NAME) * peer_san;
 	int			i;
 	int			rc = 0;
+	bool		has_dnsname = false;
 
 	/*
 	 * First, get the Subject Alternative Names (SANs) from the certificate,
@@ -537,24 +577,32 @@ pgtls_verify_peer_name_matches_certificate_guts(PGconn *conn,
 		for (i = 0; i < san_len; i++)
 		{
 			const GENERAL_NAME *name = sk_GENERAL_NAME_value(peer_san, i);
+			char	   *alt_name = NULL;
 
 			if (name->type == GEN_DNS)
 			{
-				char	   *alt_name;
-
+				has_dnsname = true;
 				(*names_examined)++;
 				rc = openssl_verify_peer_name_matches_certificate_name(conn,
 																	   name->d.dNSName,
 																	   &alt_name);
+			}
+			else if (name->type == GEN_IPADD)
+			{
+				(*names_examined)++;
+				rc = openssl_verify_peer_name_matches_certificate_ip(conn,
+																	 name->d.iPAddress,
+																	 &alt_name);
+			}
 
-				if (alt_name)
-				{
-					if (!*first_name)
-						*first_name = alt_name;
-					else
-						free(alt_name);
-				}
+			if (alt_name)
+			{
+				if (!*first_name)
+					*first_name = alt_name;
+				else
+					free(alt_name);
 			}
+
 			if (rc != 0)
 				break;
 		}
@@ -567,8 +615,13 @@ pgtls_verify_peer_name_matches_certificate_guts(PGconn *conn,
 	 *
 	 * (Per RFC 2818 and RFC 6125, if the subjectAltName extension of type
 	 * dNSName is present, the CN must be ignored.)
+	 *
+	 * TODO: we fall back to checking the CN if an iPAddress exists. NSS does
+	 * not, if the client's supplied host is itself an IP address. OpenSSL's
+	 * X509_check_ip() does not, because it doesn't ever consider the CN. Should
+	 * we?
 	 */
-	if (*names_examined == 0)
+	if ((rc == 0) && !has_dnsname)
 	{
 		X509_NAME  *subject_name;
 
diff --git a/src/test/ssl/conf/server-cn-and-ip-alt-names.config b/src/test/ssl/conf/server-cn-and-ip-alt-names.config
new file mode 100644
index 0000000000..a6fa09bad3
--- /dev/null
+++ b/src/test/ssl/conf/server-cn-and-ip-alt-names.config
@@ -0,0 +1,24 @@
+# An OpenSSL format CSR config file for creating a server certificate.
+#
+# This certificate contains a CN and SANs for both IPv4 and IPv6.
+
+
+[ req ]
+distinguished_name     = req_distinguished_name
+req_extensions         = v3_req
+prompt                 = no
+
+[ req_distinguished_name ]
+# Note: According to RFC 2818 and 6125, the CN is ignored, when DNS names are
+# present in the SANs. But they are silent on whether the CN is checked when IP
+# addresses are present.
+CN = common-name.pg-ssltest.test
+OU = PostgreSQL test suite
+
+# For Subject Alternative Names
+[ v3_req ]
+subjectAltName = @alt_names
+
+[ alt_names ]
+IP.1 = 192.0.2.1
+IP.2 = 2001:DB8::1
diff --git a/src/test/ssl/conf/server-ip-alt-names.config b/src/test/ssl/conf/server-ip-alt-names.config
new file mode 100644
index 0000000000..c22f22951a
--- /dev/null
+++ b/src/test/ssl/conf/server-ip-alt-names.config
@@ -0,0 +1,19 @@
+# An OpenSSL format CSR config file for creating a server certificate.
+#
+# This certificate has a two IP-address SANs, and no CN.
+
+[ req ]
+distinguished_name     = req_distinguished_name
+req_extensions         = v3_req
+prompt                 = no
+
+[ req_distinguished_name ]
+OU = PostgreSQL test suite
+
+# For Subject Alternative Names
+[ v3_req ]
+subjectAltName = @alt_names
+
+[ alt_names ]
+IP.1 = 192.0.2.1
+IP.2 = 2001:DB8::1
diff --git a/src/test/ssl/conf/server-ip-cn-only.config b/src/test/ssl/conf/server-ip-cn-only.config
new file mode 100644
index 0000000000..585d8bdae8
--- /dev/null
+++ b/src/test/ssl/conf/server-ip-cn-only.config
@@ -0,0 +1,12 @@
+# An OpenSSL format CSR config file for creating a server certificate.
+#
+
+[ req ]
+distinguished_name     = req_distinguished_name
+prompt                 = no
+
+[ req_distinguished_name ]
+CN = 192.0.2.1
+OU = PostgreSQL test suite
+
+# No Subject Alternative Names
diff --git a/src/test/ssl/ssl/server-cn-and-ip-alt-names.crt b/src/test/ssl/ssl/server-cn-and-ip-alt-names.crt
new file mode 100644
index 0000000000..4e58c85ccb
--- /dev/null
+++ b/src/test/ssl/ssl/server-cn-and-ip-alt-names.crt
@@ -0,0 +1,20 @@
+-----BEGIN CERTIFICATE-----
+-----END CERTIFICATE-----
diff --git a/src/test/ssl/ssl/server-cn-and-ip-alt-names.key b/src/test/ssl/ssl/server-cn-and-ip-alt-names.key
new file mode 100644
index 0000000000..837eef996d
--- /dev/null
+++ b/src/test/ssl/ssl/server-cn-and-ip-alt-names.key
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+-----END RSA PRIVATE KEY-----
diff --git a/src/test/ssl/ssl/server-ip-alt-names.crt b/src/test/ssl/ssl/server-ip-alt-names.crt
new file mode 100644
index 0000000000..8a1bc620bb
--- /dev/null
+++ b/src/test/ssl/ssl/server-ip-alt-names.crt
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+-----END CERTIFICATE-----
diff --git a/src/test/ssl/ssl/server-ip-alt-names.key b/src/test/ssl/ssl/server-ip-alt-names.key
new file mode 100644
index 0000000000..b210b3a991
--- /dev/null
+++ b/src/test/ssl/ssl/server-ip-alt-names.key
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+-----END RSA PRIVATE KEY-----
diff --git a/src/test/ssl/ssl/server-ip-cn-only.crt b/src/test/ssl/ssl/server-ip-cn-only.crt
new file mode 100644
index 0000000000..9bf015cf18
--- /dev/null
+++ b/src/test/ssl/ssl/server-ip-cn-only.crt
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+-----END CERTIFICATE-----
diff --git a/src/test/ssl/ssl/server-ip-cn-only.key b/src/test/ssl/ssl/server-ip-cn-only.key
new file mode 100644
index 0000000000..1966530e72
--- /dev/null
+++ b/src/test/ssl/ssl/server-ip-cn-only.key
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+-----END RSA PRIVATE KEY-----
diff --git a/src/test/ssl/sslfiles.mk b/src/test/ssl/sslfiles.mk
index 270f55a58f..22ce3e1f97 100644
--- a/src/test/ssl/sslfiles.mk
+++ b/src/test/ssl/sslfiles.mk
@@ -22,9 +22,12 @@
 # key/certificate pair will be generated for you, signed by the appropriate CA.
 #
 SERVERS := server-cn-and-alt-names \
+	server-cn-and-ip-alt-names \
 	server-cn-only \
+	server-ip-cn-only \
 	server-single-alt-name \
 	server-multiple-alt-names \
+	server-ip-alt-names \
 	server-no-names \
 	server-revoked
 CLIENTS := client client-dn client-revoked client_ext
diff --git a/src/test/ssl/t/001_ssltests.pl b/src/test/ssl/t/001_ssltests.pl
index 779ab66838..d88201f5e8 100644
--- a/src/test/ssl/t/001_ssltests.pl
+++ b/src/test/ssl/t/001_ssltests.pl
@@ -21,7 +21,7 @@ if ($ENV{with_ssl} ne 'openssl')
 }
 else
 {
-	plan tests => 110;
+	plan tests => 128;
 }
 
 #### Some configuration
@@ -253,6 +253,23 @@ $node->connect_fails(
 	  qr/\Qserver certificate for "common-name.pg-ssltest.test" does not match host name "wronghost.test"\E/
 );
 
+# Test with an IP address in the Common Name. This is a strange corner case that
+# nevertheless is supported, as long as the address string matches exactly.
+switch_server_cert($node, 'server-ip-cn-only');
+
+$common_connstr =
+  "user=ssltestuser dbname=trustdb sslcert=invalid sslrootcert=ssl/root+server_ca.crt hostaddr=$SERVERHOSTADDR sslmode=verify-full";
+
+$node->connect_ok("$common_connstr host=192.0.2.1",
+	"IP address in the Common Name");
+
+$node->connect_fails(
+	"$common_connstr host=192.000.002.001",
+	"mismatch between host name and server certificate IP address",
+	expected_stderr =>
+	  qr/\Qserver certificate for "192.0.2.1" does not match host name "192.000.002.001"\E/
+);
+
 # Test Subject Alternative Names.
 switch_server_cert($node, 'server-multiple-alt-names');
 
@@ -305,6 +322,53 @@ $node->connect_fails(
 	  qr/\Qserver certificate for "single.alt-name.pg-ssltest.test" does not match host name "deep.subdomain.wildcard.pg-ssltest.test"\E/
 );
 
+# Test certificate with IP addresses in the SANs.
+switch_server_cert($node, 'server-ip-alt-names');
+
+$node->connect_ok(
+	"$common_connstr host=192.0.2.1",
+	"host matching an IPv4 address (Subject Alternative Name 1)");
+
+$node->connect_ok(
+	"$common_connstr host=192.000.002.001",
+	"host matching an IPv4 address in alternate form (Subject Alternative Name 1)");
+
+$node->connect_fails(
+	"$common_connstr host=192.0.2.2",
+	"host not matching an IPv4 address (Subject Alternative Name 1)",
+	expected_stderr =>
+	  qr/\Qserver certificate for "192.0.2.1" (and 1 other name) does not match host name "192.0.2.2"\E/
+);
+
+$node->connect_fails(
+	"$common_connstr host=192.0.2.1/32",
+	"IPv4 host with CIDR mask does not match",
+	expected_stderr =>
+	  qr/\Qserver certificate for "192.0.2.1" (and 1 other name) does not match host name "192.0.2.1\/32"\E/
+);
+
+$node->connect_ok(
+	"$common_connstr host=2001:DB8::1",
+	"host matching an IPv6 address (Subject Alternative Name 2)");
+
+$node->connect_ok(
+	"$common_connstr host=2001:db8:0:0:0:0:0:1",
+	"host matching an IPv6 address in alternate form (Subject Alternative Name 2)");
+
+$node->connect_fails(
+	"$common_connstr host=::1",
+	"host not matching an IPv6 address (Subject Alternative Name 2)",
+	expected_stderr =>
+	  qr/\Qserver certificate for "192.0.2.1" (and 1 other name) does not match host name "::1"\E/
+);
+
+$node->connect_fails(
+	"$common_connstr host=2001:DB8::1/128",
+	"IPv6 host with CIDR mask does not match",
+	expected_stderr =>
+	  qr/\Qserver certificate for "192.0.2.1" (and 1 other name) does not match host name "2001:DB8::1\/128"\E/
+);
+
 # Test server certificate with a CN and SANs. Per RFCs 2818 and 6125, the CN
 # should be ignored when the certificate has both.
 switch_server_cert($node, 'server-cn-and-alt-names');
@@ -323,6 +387,17 @@ $node->connect_fails(
 	  qr/\Qserver certificate for "dns1.alt-name.pg-ssltest.test" (and 1 other name) does not match host name "common-name.pg-ssltest.test"\E/
 );
 
+# But we will fall back to check the CN if the SANs contain only IP addresses.
+# TODO: should we?
+switch_server_cert($node, 'server-cn-and-ip-alt-names');
+
+$node->connect_ok("$common_connstr host=common-name.pg-ssltest.test",
+	"certificate with both a CN and IP SANs matches CN");
+$node->connect_ok("$common_connstr host=192.0.2.1",
+	"certificate with both a CN and IP SANs matches SAN 1");
+$node->connect_ok("$common_connstr host=2001:db8::1",
+	"certificate with both a CN and IP SANs matches SAN 2");
+
 # Finally, test a server certificate that has no CN or SANs. Of course, that's
 # not a very sensible certificate, but libpq should handle it gracefully.
 switch_server_cert($node, 'server-no-names');
-- 
2.25.1

---

From: Kyotaro Horiguchi <[email-masked]>
Date: Thu, 16 Dec 2021 14:54:18 +0900 (JST)

At Thu, 16 Dec 2021 01:13:57 +0000, Jacob Champion <[email-masked]> wrote in 

> - 0002 implements the new functionality and adds tests.

In RFC2828 and 6125,

>   In some cases, the URI is specified as an IP address rather than a
>   hostname.  In this case, the iPAddress subjectAltName must be present
>   in the certificate and must exactly match the IP in the URI.

It seems like saying that we must search for iPAddress and mustn't use
CN nor dNSName if the client connected using IP address. Otherwise, if
the host name is a domain name, we use only dNSName if present, and
use CN otherwise.  That behavior seems agreeing to what you wrote as
NSS's behavior.  That being said it seems to me we should preserve
that behavior at least for OpenSSL as an established behavior.

In short, I think the current behavior of the patch is the direction
we would go but some documentation is may be needed.

I'm not sure about ipv4 comptible addresses.  However, I think we can
identify ipv4 compatible address easily.

If a cidr is given, pg_inet_net_pton returns a number less than 128 so
we don't need to check '/' explicity?  (I'm not sure '/128' is
sensible but doesn't harm..)

regards.

-- 
Kyotaro Horiguchi
NTT Open Source Software Center

---

From: Andrew Dunstan <[email-masked]>
Date: Thu, 16 Dec 2021 10:50:28 -0500

> alternative spellings of the same IP address.

Good job, this is certainly going to be useful.

>   is easy enough for people to issue a certificate with both addresses.

Sure.

>   entries, but it's silent on the subject of IP addresses. What should

I don't think we should fall back on the CN. It would seem quite odd to
do so for IP addresses but not for DNS names.

cheers

andrew

--
Andrew Dunstan
EDB: https://www.enterprisedb.com

---

From: Jacob Champion <[email-masked]>
Date: Thu, 16 Dec 2021 18:44:54 +0000

> >   in the certificate and must exactly match the IP in the URI.

Ah, right, I misremembered. Disregard my statement that the spec is
"silent on the subject", sorry.

> NSS's behavior.

NSS departs slightly from the spec and will additionally try to match
an IP address against the CN, but only if there are no iPAddresses in
the SAN. It roughly matches the logic for DNS names.

Here's the description of the NSS behavior and some of the reasoning
behind it, quoted from a developer on Bugzilla [1]:

> Elsewhere in RFC 2818, it says 
> 
>    If a subjectAltName extension of type dNSName is present, that MUST
>    be used as the identity. Otherwise, the (most specific) Common Name
>    field in the Subject field of the certificate MUST be used. 
> 
> Notice that this section is not conditioned upon the URI being a hostname
> and not an IP address.  So this statement conflicts with the one cited 
> above.  
> 
> I implemented this policy:
> 
>     if the URI contains a host name
>         if the subject alt name is present and has one or more DNS names
>             use the DNS names in that extension as the server identity
>         else
>             use the subject common name as the server identity
>     else if the URI contains an IP address
>         if the subject alt name is present and has one or more IP addresses
>             use the IP addresses in that extension as the server identity
>         else
>             compare the URI IP address string with the subject common name.

It sounds like both you and Andrew might be comfortable with that same
behavior? I think it looks like a sane solution, so I'll implement that
and we can see what it looks like. (My work on this will be paused over
the end-of-year holidays.)

> That being said it seems to me we should preserve

That part is interesting. I'll talk more about that in my reply to
Andrew.

> we would go but some documentation is may be needed.

Great!

> identify ipv4 compatible address easily.

Yeah, it would probably not be a difficult feature to add later.

> sensible but doesn't harm..)

Personally I think that, if someone wants your libpq to connect to a
server with a hostname of "some:ipv6::address/128", then they are
trying to pull something (evading a poorly coded blocklist, perhaps?)
and we should not allow that to match an IP. Thoughts?

Thanks for the review!
--Jacob

[1] https://bugzilla.mozilla.org/show_bug.cgi?id=103752

---

From: Jacob Champion <[email-masked]>
Date: Thu, 16 Dec 2021 19:14:58 +0000

> Good job, this is certainly going to be useful.

Thanks!

> do so for IP addresses but not for DNS names.

So there's at least one compatibility concern with disabling the
fallback, in that there could be existing users that are happily using
a certificate with an IP address CN, and libpq is just ignoring any
iPAddress SANs that the certificate has. Once libpq becomes aware of
those, it will stop accepting the CN and the certificate might stop
working.

Personally I think that's acceptable, but it would probably warrant a
release note or some such.

I will work on implementing behavior that's modeled off of the NSS
matching logic (see my reply to Horiguchi-san), which will at least
make it more logically consistent, and we can see what that looks like?

Thanks for the review!
--Jacob

---

From: Kyotaro Horiguchi <[email-masked]>
Date: Fri, 17 Dec 2021 15:40:10 +0900 (JST)

At Thu, 16 Dec 2021 18:44:54 +0000, Jacob Champion <[email-masked]> wrote in 
> the SAN. It roughly matches the logic for DNS names.

OpenSSL seems different. X509_check_host() tries SAN then CN iff SAN
doesn't exist.  X509_check_ip() tries SAN and completely ignores
iPAdress and CN.

> >             compare the URI IP address string with the subject common name.
(Wow. The article is 20-years old.)

*I* am fine with it.

> Yeah, it would probably not be a difficult feature to add later.

I agree.

> and we should not allow that to match an IP. Thoughts?

If the client could connect to the network-address, it could be said
that we can assume that address is the name:p Just kidding.

As the name suggests, the function reads a network address. And the
only user is network_in().  I think we should provide pg_inet_pton()
instead of abusing pg_inet_net_pton().  inet_net_pton_*() functions
can be modified to reject /cidr part without regression so we are able
to have pg_inet_pton() with a small amount of change.

- inet_net_pton_ipv4(const char *src, u_char *dst)
+ inet_net_pton_ipv4_internal(const char *src, u_char *dst, bool netaddr)

+ inet_net_pton_ipv4(const char *src, u_char *dst)
 (calls inet_net_pton_ipv4_internal(src, dst, true))
+ inet_pton_ipv4(const char *src, u_char *dst)
 (calls inet_net_pton_ipv4_internal(src, dst, false))

---

From: Kyotaro Horiguchi <[email-masked]>
Date: Fri, 17 Dec 2021 16:54:30 +0900 (JST)

Sorry for the silly mistake.

At Fri, 17 Dec 2021 15:40:10 +0900 (JST), Kyotaro Horiguchi <[email-masked]> wrote in 

OpenSSL seems different. X509_check_host() tries SAN then CN iff SAN
doesn't exist.  X509_check_ip() tries iPAddress and completely ignores
CN.

regards.