Commit 8330ca37edb for php
commit 8330ca37edb41b5f34a0934c99e70e2d2a6ead71
Author: Jakub Zelenka <bukka@php.net>
Date: Tue Sep 29 20:58:55 2026 +0200
Fix StreamPollHandle changing unread_bytes of a TLS stream when added
StreamPollHandle took the descriptor through the select cast. On a TLS
stream that cast reads: it moves what OpenSSL holds decrypted into the
stream buffer so that stream_select() can report it. Adding a watcher to
an Io\Poll\Context therefore read from the stream, filled the buffer of a
stream set unbuffered with stream_set_read_buffer() and changed
unread_bytes. That is not how the poll API should behave. Adding a handle
is a registration and must leave the stream as it found it, and the data
that moved was not reported by the watcher anyway, as bytes held by the
stream layer are not readiness of the descriptor.
A new cast PHP_STREAM_AS_FD_FOR_POLL returns the descriptor and nothing
else. The socket, TLS, plain and pgsql wrappers answer it, a userspace
wrapper sees it as STREAM_CAST_FOR_SELECT and the stream it returns is
cast the same way, and a filtered stream allows it like the select cast.
StreamPollHandle uses it, so adding a TLS stream leaves its buffer and
unread_bytes as they were. The select cast and stream_select() are
unchanged.
This is fixed in 8.6 because the API is new there and this is the
behaviour it should ship with. Keeping a read inside the registration
would complicate things later: once reads on a TLS stream can suspend,
the cast would become a nested read on a stream with an operation in
flight, and changing the cast then would change the visible behaviour of
a released API.
diff --git a/NEWS b/NEWS
index d9d65d5527a..b94fc6ba52b 100644
--- a/NEWS
+++ b/NEWS
@@ -130,6 +130,8 @@ PHP NEWS
output handler. (Ilia Alshanetsky)
. Fixed proc_open() leaking descriptor zero when descriptor setup fails.
(Ilia Alshanetsky)
+ . Fixed StreamPollHandle changing unread_bytes of a TLS stream when it is
+ added to an Io\Poll\Context. (Jakub Zelenka)
- Tidy:
. Fixed a use-after-free when a tidyNode is used after its document is
diff --git a/ext/openssl/tests/stream_poll_handle_cast.phpt b/ext/openssl/tests/stream_poll_handle_cast.phpt
new file mode 100644
index 00000000000..5ef35f552bf
--- /dev/null
+++ b/ext/openssl/tests/stream_poll_handle_cast.phpt
@@ -0,0 +1,72 @@
+--TEST--
+A stream poll handle takes the descriptor of a TLS stream without touching its buffers
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'stream_poll_handle_cast.pem.tmp';
+
+$serverCode = <<<'CODE'
+ $serverCtx = stream_context_create(['ssl' => ['local_cert' => '%s']]);
+ $sock = stream_socket_server("tls://127.0.0.1:0", $errno, $errstr,
+ STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $serverCtx);
+ phpt_notify_server_start($sock);
+
+ $link = stream_socket_accept($sock);
+ /* One record of 100 bytes */
+ fwrite($link, str_repeat("x", 100));
+ phpt_wait();
+ fclose($link);
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+$clientCode = <<<'CODE'
+ $clientCtx = stream_context_create(['ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ ]]);
+ $sock = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr, 2, STREAM_CLIENT_CONNECT, $clientCtx);
+
+ /* 10 of 100 bytes read, 90 stay inside OpenSSL */
+ stream_set_read_buffer($sock, 0);
+ var_dump(strlen(fread($sock, 10)));
+
+ /* Adding the handle must not move them into the stream buffer */
+ $ctx = new Io\Poll\Context();
+ $w = $ctx->add(new StreamPollHandle($sock), [Io\Poll\Event::Read]);
+ var_dump(stream_get_meta_data($sock)['unread_bytes']);
+
+ /* Not readiness of the socket */
+ var_dump(count($ctx->wait(Time\Duration::fromMilliseconds(100))));
+ var_dump(strlen(fread($sock, 90)));
+
+ /* The peer's close is */
+ phpt_notify();
+ $fired = $ctx->wait(Time\Duration::fromSeconds(2));
+ var_dump(count($fired), $fired[0] === $w);
+ var_dump(fread($sock, 10), feof($sock));
+CODE;
+
+include 'CertificateGenerator.inc';
+(new CertificateGenerator())->saveNewCertAsFileWithKey('stream_poll_handle_cast', $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'stream_poll_handle_cast.pem.tmp');
+?>
+--EXPECT--
+int(10)
+int(0)
+int(0)
+int(90)
+int(1)
+bool(true)
+string(0) ""
+bool(true)
diff --git a/ext/openssl/xp_ssl.c b/ext/openssl/xp_ssl.c
index 8d6f82f9d2b..cf3793fd551 100644
--- a/ext/openssl/xp_ssl.c
+++ b/ext/openssl/xp_ssl.c
@@ -3909,6 +3909,13 @@ static int php_openssl_sockop_cast(php_stream *stream, int castas, void **ret)
}
return SUCCESS;
+ case PHP_STREAM_AS_FD_FOR_POLL:
+ /* Descriptor only, OpenSSL pending bytes stay put */
+ if (ret) {
+ *(php_socket_t *)ret = sslsock->s.socket;
+ }
+ return SUCCESS;
+
case PHP_STREAM_AS_FD:
case PHP_STREAM_AS_SOCKETD:
if (sslsock->ssl_active) {
diff --git a/ext/pgsql/pgsql.c b/ext/pgsql/pgsql.c
index 38ac6cd09ff..144f191da58 100644
--- a/ext/pgsql/pgsql.c
+++ b/ext/pgsql/pgsql.c
@@ -4419,6 +4419,7 @@ static int php_pgsql_fd_cast(php_stream *stream, int cast_as, void **ret) /* {{{
switch (cast_as) {
case PHP_STREAM_AS_FD_FOR_SELECT:
+ case PHP_STREAM_AS_FD_FOR_POLL:
case PHP_STREAM_AS_FD:
case PHP_STREAM_AS_SOCKETD: {
int fd_number = PQsocket(pgsql);
diff --git a/ext/standard/io_poll.c b/ext/standard/io_poll.c
index dd0b27ae006..c92201c1f29 100644
--- a/ext/standard/io_poll.c
+++ b/ext/standard/io_poll.c
@@ -205,7 +205,7 @@ static php_socket_t php_stream_poll_handle_get_fd(php_poll_handle_object *handle
return SOCK_ERR;
}
- if (php_stream_cast(stream, PHP_STREAM_AS_FD_FOR_SELECT | PHP_STREAM_CAST_INTERNAL,
+ if (php_stream_cast(stream, PHP_STREAM_AS_FD_FOR_POLL | PHP_STREAM_CAST_INTERNAL,
(void *) &fd, 1)
!= SUCCESS
|| fd == -1) {
diff --git a/main/php_streams.h b/main/php_streams.h
index 0111a6a7841..093685c1bd9 100644
--- a/main/php_streams.h
+++ b/main/php_streams.h
@@ -536,6 +536,8 @@ END_EXTERN_C()
#define PHP_STREAM_AS_FD_FOR_SELECT 3
/* cast as fd/socket for copy purposes */
#define PHP_STREAM_AS_FD_FOR_COPY 4
+/* cast as fd/socket for polling, buffers untouched */
+#define PHP_STREAM_AS_FD_FOR_POLL 5
/* try really, really hard to make sure the cast happens (avoid using this flag if possible) */
#define PHP_STREAM_CAST_TRY_HARD 0x80000000
diff --git a/main/streams/cast.c b/main/streams/cast.c
index c93b9d9747f..269bb9e2178 100644
--- a/main/streams/cast.c
+++ b/main/streams/cast.c
@@ -194,7 +194,8 @@ PHPAPI zend_result php_stream_cast(php_stream *stream, int castas, void **ret, i
castas &= ~PHP_STREAM_CAST_MASK;
/* synchronize our buffer (if possible) */
- if (ret && castas != PHP_STREAM_AS_FD_FOR_SELECT && castas != PHP_STREAM_AS_FD_FOR_COPY) {
+ if (ret && castas != PHP_STREAM_AS_FD_FOR_SELECT && castas != PHP_STREAM_AS_FD_FOR_COPY
+ && castas != PHP_STREAM_AS_FD_FOR_POLL) {
php_stream_flush(stream);
if (stream->ops->seek && (stream->flags & PHP_STREAM_FLAG_NO_SEEK) == 0) {
zend_off_t dummy;
@@ -304,7 +305,8 @@ PHPAPI zend_result php_stream_cast(php_stream *stream, int castas, void **ret, i
}
}
- if (php_stream_is_filtered(stream) && castas != PHP_STREAM_AS_FD_FOR_SELECT) {
+ if (php_stream_is_filtered(stream) && castas != PHP_STREAM_AS_FD_FOR_SELECT
+ && castas != PHP_STREAM_AS_FD_FOR_POLL) {
if (show_err) {
php_stream_warn(stream, CastNotSupported,
"Cannot cast a filtered stream on this system");
@@ -316,11 +318,13 @@ PHPAPI zend_result php_stream_cast(php_stream *stream, int castas, void **ret, i
if (show_err) {
/* these names depend on the values of the PHP_STREAM_AS_XXX defines in php_streams.h */
- static const char *cast_names[4] = {
+ static const char *cast_names[6] = {
"STDIO FILE*",
"File Descriptor",
"Socket Descriptor",
- "select()able descriptor"
+ "select()able descriptor",
+ "copyable descriptor",
+ "pollable descriptor"
};
php_stream_warn(stream, CastNotSupported,
diff --git a/main/streams/plain_wrapper.c b/main/streams/plain_wrapper.c
index eb9b81b6e2c..24427b41b69 100644
--- a/main/streams/plain_wrapper.c
+++ b/main/streams/plain_wrapper.c
@@ -689,6 +689,7 @@ static int php_stdiop_cast(php_stream *stream, int castas, void **ret)
return SUCCESS;
case PHP_STREAM_AS_FD_FOR_SELECT:
+ case PHP_STREAM_AS_FD_FOR_POLL:
PHP_STDIOP_GET_FD(fd, data);
if (SOCK_ERR == fd) {
return FAILURE;
diff --git a/main/streams/userspace.c b/main/streams/userspace.c
index 9b6f283c075..b42d7f00e97 100644
--- a/main/streams/userspace.c
+++ b/main/streams/userspace.c
@@ -1435,6 +1435,8 @@ static int php_userstreamop_cast(php_stream *stream, int castas, void **retptr)
switch(castas) {
case PHP_STREAM_AS_FD_FOR_SELECT:
+ case PHP_STREAM_AS_FD_FOR_POLL:
+ /* Userland only knows STREAM_CAST_FOR_SELECT */
ZVAL_LONG(&args[0], PHP_STREAM_AS_FD_FOR_SELECT);
break;
default:
diff --git a/main/streams/xp_socket.c b/main/streams/xp_socket.c
index 3844414d804..aae7915b3f0 100644
--- a/main/streams/xp_socket.c
+++ b/main/streams/xp_socket.c
@@ -518,6 +518,7 @@ static int php_sockop_cast(php_stream *stream, int castas, void **ret)
}
return SUCCESS;
case PHP_STREAM_AS_FD_FOR_SELECT:
+ case PHP_STREAM_AS_FD_FOR_POLL:
case PHP_STREAM_AS_FD:
case PHP_STREAM_AS_SOCKETD:
if (ret)