v3-0002-libpq-Drain-all-pending-bytes-from-SSL-GSS-during.patch

text/x-patch

Filename: v3-0002-libpq-Drain-all-pending-bytes-from-SSL-GSS-during.patch
Type: text/x-patch
Part: 1
Message: Re: libpq: Process buffered SSL read bytes to support records >8kB on async API

Patch

Same data as JSON: GET /api/v1/attachments/:id/patch the parsed metadata as JSON — format, series position, per-file stats; never the diff bytes. API reference →
Format: format-patch
Series: patch v3-0002
Subject: libpq: Drain all pending bytes from SSL/GSS during pqReadData()
File+
src/interfaces/libpq/fe-misc.c 137 3
src/interfaces/libpq/fe-secure.c 9 5
src/interfaces/libpq/fe-secure-gssapi.c 4 3
src/interfaces/libpq/fe-secure-openssl.c 35 3
src/interfaces/libpq/libpq-int.h 4 4
From 11a8f0a2c91b9ab81df90c6d306896f2269d82b5 Mon Sep 17 00:00:00 2001
From: Heikki Linnakangas <heikki.linnakangas@iki.fi>
Date: Fri, 5 Dec 2025 15:11:49 +0200
Subject: [PATCH v3 2/2] libpq: Drain all pending bytes from SSL/GSS during
 pqReadData()

The previous commit strengthened a workaround for a hang when large
messages are split across TLS records/GSS tokens. Because that
workaround is implemented in libpq internals, it can only help us when
libpq itself is polling on the socket. In nonblocking situations, where
the client above libpq is expected to poll, the same bugs can show up.

As a contrived example, consider a large protocol-2.0 error coming back
from a server during PQconnectPoll(), split in an odd way across two
records:

    -- TLS record (8192-byte payload) --
    EEEE[...repeated a total of 8192 times]
    -- TLS record (8193-byte payload) --
    EEEE[...repeated a total of 8192 times]\0

The first record will fill the first half of the libpq receive buffer,
which is 16k long by default. The second record completely fills the
last half with its first 8192 bytes, leaving the terminating NULL in the
OpenSSL buffer. Since we still haven't seen the terminator at our level,
PQconnectPoll() will return PGRES_POLLING_READING, expecting to come
back when the server has sent "the rest" of the data.  But there is
nothing left to read from the socket; OpenSSL had to pull all of the
data in the 8193-byte record off of the wire to decrypt it.

(A real server would probably not split up the records this way, nor
keep the connection open after sending a fatal connection error. But
servers that regularly use larger TLS records can get the libpq receive
buffer into the same state if DataRows are big enough, as reported on
the list.)

This is a layering violation. libpq makes decisions based on data in the
application buffer, above the transport buffer (whether SSL or GSS), but
clients are polling the socket, below the transport buffer. One way to
fix this in a backportable way, without changing APIs too much, is to
ensure data never stays in the transport buffer. Then pqReadData's
postconditions will look similar for both raw sockets and SSL/GSS: any
available data is either in the application buffer, or still on the
socket.

Building on the prior commit, make pqReadData() to drain all pending
data from the transport layer into conn->inBuffer, expanding the
buffer as necessary. This is not particularly efficient from an
architectural perspective (the pqsecure_read() implementations take
care to fit their packets into the current buffer, and that effort is
now completely discarded), but it's hopefully easier to reason about
than a full rewrite would be for the back branches.

Author: Jacob Champion <jacob.champion@enterprisedb.com>
Reported-by: Lars Kanis <lars@greiz-reinsdorf.de>
Discussion: https://postgr.es/m/2039ac58-d3e0-434b-ac1a-2a987f3b4cb1%40greiz-reinsdorf.de
Backpatch-through: 14
---
 src/interfaces/libpq/fe-misc.c           | 140 ++++++++++++++++++++++-
 src/interfaces/libpq/fe-secure-gssapi.c  |   7 +-
 src/interfaces/libpq/fe-secure-openssl.c |  38 +++++-
 src/interfaces/libpq/fe-secure.c         |  14 ++-
 src/interfaces/libpq/libpq-int.h         |   8 +-
 5 files changed, 189 insertions(+), 18 deletions(-)

diff --git a/src/interfaces/libpq/fe-misc.c b/src/interfaces/libpq/fe-misc.c
index 434216ff89f..dd555799ae3 100644
--- a/src/interfaces/libpq/fe-misc.c
+++ b/src/interfaces/libpq/fe-misc.c
@@ -55,6 +55,8 @@ static int	pqPutMsgBytes(const void *buf, size_t len, PGconn *conn);
 static int	pqSendSome(PGconn *conn, int len);
 static int	pqSocketCheck(PGconn *conn, int forRead, int forWrite,
 						  pg_usec_time_t end_time);
+static int	pqReadData_internal(PGconn *conn);
+static int	pqDrainPending(PGconn *conn);
 
 /*
  * PQlibVersion: return the libpq version number
@@ -593,6 +595,13 @@ pqPutMsgEnd(PGconn *conn)
 
 /* ----------
  * pqReadData: read more data, if any is available
+ *
+ * Upon a successful return, callers may assume that either 1) all available
+ * bytes have been consumed from the socket, or 2) the socket is still marked
+ * readable by the OS. (In other words: after a successful pqReadData, it's safe
+ * to tell a client to poll for readable bytes on the socket without any further
+ * draining of the SSL/GSS transport buffers.)
+ *
  * Possible return values:
  *	 1: successfully loaded at least one more byte
  *	 0: no data is presently available, but no error detected
@@ -605,8 +614,7 @@ pqPutMsgEnd(PGconn *conn)
 int
 pqReadData(PGconn *conn)
 {
-	int			someread = 0;
-	int			nread;
+	int			available;
 
 	if (conn->sock == PGINVALID_SOCKET)
 	{
@@ -614,6 +622,40 @@ pqReadData(PGconn *conn)
 		return -1;
 	}
 
+	available = pqReadData_internal(conn);
+	if (available < 0)
+		return -1;
+	else if (available > 0)
+	{
+		/*
+		 * Make sure there are no bytes stuck in layers between conn->inBuffer
+		 * and the socket, to make it safe for clients to poll on PQsocket().
+		 */
+		if (pqDrainPending(conn))
+			return -1;
+	}
+	else
+	{
+		/*
+		 * If we're not returning any bytes from the underlying transport,
+		 * that must imply there aren't any in the transport buffer...
+		 */
+		Assert(pqsecure_bytes_pending(conn) == 0);
+	}
+
+	return available;
+}
+
+/*
+ * Workhorse for pqReadData(). It's kept separate from the pqDrainPending()
+ * logic to avoid adding to this function's goto complexity.
+ */
+static int
+pqReadData_internal(PGconn *conn)
+{
+	int			someread = 0;
+	int			nread;
+
 	/* Left-justify any data in the buffer to make room */
 	if (conn->inStart < conn->inEnd)
 	{
@@ -724,6 +766,8 @@ retry3:
 	 * SSL_read() could still say WANT_READ because the data received was not
 	 * a complete SSL record.  So we must play dumb and assume there is more
 	 * data, relying on the SSL layer to detect true EOF.
+	 *
+	 * XXX: do we have the same issue with GSS encryption?
 	 */
 
 #ifdef USE_SSL
@@ -800,6 +844,96 @@ definitelyFailed:
 	return -1;
 }
 
+/*---
+ * Drains any transport data that is already buffered in userspace and adds it
+ * to conn->inBuffer, enlarging inBuffer if necessary. The drain fails if
+ * inBuffer cannot be made to hold all available transport data.
+ *
+ * We assume that the underlying secure transport implementation does not
+ * attempt to read any more data from the socket while draining the transport
+ * buffer. After a successful return, pqsecure_bytes_pending() must be zero.
+ *
+ * This operation is necessary to prevent deadlock, due to a layering violation
+ * designed into our asynchronous client API: pqReadData() and all the parsing
+ * routines above it receive data from the SSL/GSS transport buffer, but clients
+ * poll on the raw PQsocket() handle. So data can be "lost" in the intermediate
+ * layer if we don't take it out here.
+ *
+ * To illustrate what we're trying to prevent, say that the server is sending
+ * two messages at once in response to a query (Aaaa and Bb), the libpq buffer
+ * is five characters in size, and TLS records max out at three-character
+ * payloads.
+ *
+ *   Client    libpq      SSL      Socket
+ *     |         |         |         |
+ *     |      [     ]    [   ]     [   ]    [1] Buffers are empty, client is
+ *     x --------------------------> |          polling on socket
+ *     |         |         |         |
+ *     |      [     ]    [   ]     [xxx]    [2] First record is received; poll
+ *     | <-------------------------- |          signals read-ready
+ *     |         |         |         |
+ *     x ---> [     ]    [   ]     [xxx]    [3] Client calls PQconsumeInput()
+ *     |         |         |         |
+ *     |      [     ] -> [   ]     [xxx]    [4] libpq calls pqReadData() to fill
+ *     |         |         |         |          the receive buffer
+ *     |      [     ]    [Aaa] <-- [   ]    [5] SSL pulls payload off the wire
+ *     |         |         |         |          and decrypts it
+ *     |      [Aaa  ] <- [   ]     [   ]    [6] pqsecure_read() takes all data
+ *     |         |         |         |
+ *     | <--- [Aaa  ]    [   ]     [   ]    [7] PQconsumeInput() returns with a
+ *     x --------------------------> |          partial message, PQisBusy() is
+ *     |         |         |         |          still true, client polls again
+ *     |      [Aaa  ]    [   ]     [xxx]    [8] Second record is received; poll
+ *     | <-------------------------- |          signals read-ready
+ *     |         |         |         |
+ *     x ---> [Aaa  ]    [   ]     [xxx]    [9] Client calls PQconsumeInput()
+ *     |         |         |         |
+ *     |      [Aaa  ] -> [   ]     [xxx]   [10] libpq calls pqReadData() to fill
+ *     |         |         |         |          the receive buffer
+ *     |      [Aaa  ]    [aBb] <-- [   ]   [11] SSL decrypts
+ *     |         |         |         |
+ *     |      [AaaaB] <- [b  ]     [   ]   [12] pqsecure_read() fills its
+ *     |         |         |         |          buffer, taking only two bytes
+ *     | <--- [AaaaB]    [b  ]     [   ]   [13] PQconsumeInput() returns with a
+ *     |         |         |         |          complete message buffered;
+ *     |         |         |         |          PQisBusy() is false
+ *     x ---> [AaaaB]    [b  ]     [   ]   [14] Client calls PQgetResult()
+ *     |         |         |         |
+ *     | <--- [B    ]    [b  ]     [   ]   [15] Aaaa is returned; PQisBusy() is
+ *     x --------------------------> |          true and client polls again
+ *     .         |         |         .
+ *     .      [B    ]    [b  ]       .     [16] No packets, and client hangs.
+ *     .         |         |         .
+ *
+ */
+static int
+pqDrainPending(PGconn *conn)
+{
+	ssize_t		bytes_pending;
+	ssize_t		nread;
+
+	bytes_pending = pqsecure_bytes_pending(conn);
+	if (bytes_pending <= 0)
+		return bytes_pending;
+
+	/* Expand the input buffer if necessary. */
+	if (pqCheckInBufferSpace(conn->inEnd + (size_t) bytes_pending, conn))
+		return -1;				/* errorMessage already set */
+
+	nread = pqsecure_read(conn, conn->inBuffer + conn->inEnd,
+						  bytes_pending);
+
+	/* When there are bytes pending, the read function is not supposed to fail */
+	if (nread != bytes_pending)
+	{
+		libpq_append_conn_error(conn,
+								"drained only %zu of %zd pending bytes in transport buffer",
+								nread, bytes_pending);
+		return -1;
+	}
+	return 0;
+}
+
 /*
  * pqSendSome: send data waiting in the output buffer.
  *
@@ -1100,7 +1234,7 @@ pqSocketCheck(PGconn *conn, int forRead, int forWrite, pg_usec_time_t end_time)
 		}
 
 		/* Check for SSL/GSS library buffering read bytes */
-		if (forRead && pqsecure_read_is_pending(conn))
+		if (forRead && pqsecure_bytes_pending(conn) != 0)
 		{
 			/* short-circuit the select */
 			return 1;
diff --git a/src/interfaces/libpq/fe-secure-gssapi.c b/src/interfaces/libpq/fe-secure-gssapi.c
index 5067b3de9f4..05abdbffcc6 100644
--- a/src/interfaces/libpq/fe-secure-gssapi.c
+++ b/src/interfaces/libpq/fe-secure-gssapi.c
@@ -471,10 +471,11 @@ gss_read(PGconn *conn, void *recv_buffer, size_t length, ssize_t *ret)
 	return PGRES_POLLING_OK;
 }
 
-bool
-pg_GSS_read_is_pending(PGconn *conn)
+ssize_t
+pg_GSS_bytes_pending(PGconn *conn)
 {
-	return PqGSSResultLength > PqGSSResultNext;
+	Assert(PqGSSResultLength >= PqGSSResultNext);
+	return (ssize_t) (PqGSSResultLength - PqGSSResultNext);
 }
 
 /*
diff --git a/src/interfaces/libpq/fe-secure-openssl.c b/src/interfaces/libpq/fe-secure-openssl.c
index 69256c91cdb..5f652486a9c 100644
--- a/src/interfaces/libpq/fe-secure-openssl.c
+++ b/src/interfaces/libpq/fe-secure-openssl.c
@@ -230,10 +230,42 @@ rloop:
 	return n;
 }
 
-bool
-pgtls_read_is_pending(PGconn *conn)
+ssize_t
+pgtls_bytes_pending(PGconn *conn)
 {
-	return SSL_pending(conn->ssl) > 0;
+	int			pending;
+
+	/*
+	 * OpenSSL readahead is documented to break SSL_pending(). Plus, we can't
+	 * afford to have OpenSSL take bytes off the socket without processing
+	 * them; that breaks the postconditions for pqsecure_drain_pending().
+	 */
+	Assert(!SSL_get_read_ahead(conn->ssl));
+
+	/* Figure out how many bytes to take off the connection. */
+	pending = SSL_pending(conn->ssl);
+
+	if (pending < 0)
+	{
+		/* shouldn't be possible */
+		Assert(false);
+		libpq_append_conn_error(conn, "OpenSSL reports negative bytes pending");
+		return -1;
+	}
+	else if (pending == INT_MAX)
+	{
+		/*
+		 * If we ever found a legitimate way to hit this, we'd need to loop
+		 * around in the caller to call pgtls_bytes_pending() again. Throw an
+		 * error rather than complicate the code in that way, because
+		 * SSL_read() should be bounded to the size of a single TLS record,
+		 * and conn->inBuffer can't currently go past INT_MAX in size anyway.
+		 */
+		libpq_append_conn_error(conn, "OpenSSL reports INT_MAX bytes pending");
+		return -1;
+	}
+
+	return (ssize_t) pending;
 }
 
 ssize_t
diff --git a/src/interfaces/libpq/fe-secure.c b/src/interfaces/libpq/fe-secure.c
index 94c97ec26fb..8ee4969d820 100644
--- a/src/interfaces/libpq/fe-secure.c
+++ b/src/interfaces/libpq/fe-secure.c
@@ -244,18 +244,22 @@ pqsecure_raw_read(PGconn *conn, void *ptr, size_t len)
 }
 
 /*
- * Returns true if there are any bytes available in the transport buffer.
+ *  Return the number of bytes available in the transport buffer.
+ *
+ * If pqsecure_read() is called for this number of bytes, it's guaranteed to
+ * return successfully without reading from the underlying socket.  See
+ * pqDrainPending() for a more complete discussion of the concepts involved.
  */
-bool
-pqsecure_read_is_pending(PGconn *conn)
+ssize_t
+pqsecure_bytes_pending(PGconn *conn)
 {
 #ifdef USE_SSL
 	if (conn->ssl_in_use)
-		return pgtls_read_is_pending(conn);
+		return pgtls_bytes_pending(conn);
 #endif
 #ifdef ENABLE_GSS
 	if (conn->gssenc)
-		return pg_GSS_read_is_pending(conn);
+		return pg_GSS_bytes_pending(conn);
 #endif
 
 	/* Plaintext connections have no transport buffer. */
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index e8a1f248805..37fe77e9ca7 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -819,7 +819,7 @@ extern int	pqWriteReady(PGconn *conn);
 extern PostgresPollingStatusType pqsecure_open_client(PGconn *);
 extern void pqsecure_close(PGconn *);
 extern ssize_t pqsecure_read(PGconn *, void *ptr, size_t len);
-extern bool pqsecure_read_is_pending(PGconn *);
+extern ssize_t pqsecure_bytes_pending(PGconn *);
 extern ssize_t pqsecure_write(PGconn *, const void *ptr, size_t len);
 extern ssize_t pqsecure_raw_read(PGconn *, void *ptr, size_t len);
 extern ssize_t pqsecure_raw_write(PGconn *, const void *ptr, size_t len);
@@ -856,9 +856,9 @@ extern void pgtls_close(PGconn *conn);
 extern ssize_t pgtls_read(PGconn *conn, void *ptr, size_t len);
 
 /*
- *	Is there unread data waiting in the SSL read buffer?
+ *  Return the number of bytes available in the transport buffer.
  */
-extern bool pgtls_read_is_pending(PGconn *conn);
+extern ssize_t pgtls_bytes_pending(PGconn *conn);
 
 /*
  *	Write data to a secure connection.
@@ -906,7 +906,7 @@ extern PostgresPollingStatusType pqsecure_open_gss(PGconn *conn);
  */
 extern ssize_t pg_GSS_write(PGconn *conn, const void *ptr, size_t len);
 extern ssize_t pg_GSS_read(PGconn *conn, void *ptr, size_t len);
-extern bool pg_GSS_read_is_pending(PGconn *conn);
+extern ssize_t pg_GSS_bytes_pending(PGconn *conn);
 #endif
 
 /* === in fe-trace.c === */
-- 
2.47.3