Commit c1773a1da24 for php.net

commit c1773a1da24b840b7fe53d24c460fc4bf99fb387
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Thu Jul 16 08:45:52 2026 -0400

    ext/ftp: apply the connect timeout ceiling to the remaining entry points

    GH-20601 capped the timeout in ftp_connect() so a value that overflows the
    timeval conversion cannot reach php_network_set_limit_time(). ftp_ssl_connect()
    and ftp_set_option(FTP_TIMEOUT_SEC) kept rejecting only non-positive values, so
    PHP_INT_MAX still reached ftp_open() and my_poll() through them.

    Apply the same bound to both, factored into a shared PHP_FTP_TIMEOUT_SEC_MAX
    macro. ftp_ssl_connect() with PHP_INT_MAX hung before this rather than
    reporting the bad argument.

    Closes GH-22767

diff --git a/ext/ftp/php_ftp.c b/ext/ftp/php_ftp.c
index 432270cff19..5cb7fff4886 100644
--- a/ext/ftp/php_ftp.c
+++ b/ext/ftp/php_ftp.c
@@ -36,6 +36,8 @@
 #include "ftp.h"
 #include "ftp_arginfo.h"

+#define PHP_FTP_TIMEOUT_SEC_MAX ((uint64_t)((double) PHP_TIMEOUT_ULL_MAX / 1000000.0))
+
 static zend_class_entry *php_ftp_ce = NULL;
 static zend_object_handlers ftp_object_handlers;

@@ -147,15 +149,13 @@ PHP_FUNCTION(ftp_connect)
 		RETURN_THROWS();
 	}

-	const uint64_t timeoutmax = (uint64_t)((double) PHP_TIMEOUT_ULL_MAX / 1000000.0);
-
 	if (timeout_sec <= 0) {
 		zend_argument_value_error(3, "must be greater than 0");
 		RETURN_THROWS();
 	}

-	if (timeout_sec >= timeoutmax) {
-		zend_argument_value_error(3, "must be less than " ZEND_ULONG_FMT, timeoutmax);
+	if (timeout_sec >= PHP_FTP_TIMEOUT_SEC_MAX) {
+		zend_argument_value_error(3, "must be less than " ZEND_ULONG_FMT, PHP_FTP_TIMEOUT_SEC_MAX);
 		RETURN_THROWS();
 	}

@@ -196,6 +196,11 @@ PHP_FUNCTION(ftp_ssl_connect)
 		RETURN_THROWS();
 	}

+	if (timeout_sec >= PHP_FTP_TIMEOUT_SEC_MAX) {
+		zend_argument_value_error(3, "must be less than " ZEND_ULONG_FMT, PHP_FTP_TIMEOUT_SEC_MAX);
+		RETURN_THROWS();
+	}
+
 	/* connect */
 	if (!(ftp = ftp_open(host, (short)port, timeout_sec))) {
 		RETURN_FALSE;
@@ -1275,6 +1280,10 @@ PHP_FUNCTION(ftp_set_option)
 				zend_argument_value_error(3, "must be greater than 0 for the FTP_TIMEOUT_SEC option");
 				RETURN_THROWS();
 			}
+			if ((uint64_t) Z_LVAL_P(z_value) >= PHP_FTP_TIMEOUT_SEC_MAX) {
+				zend_argument_value_error(3, "must be less than " ZEND_ULONG_FMT " for the FTP_TIMEOUT_SEC option", PHP_FTP_TIMEOUT_SEC_MAX);
+				RETURN_THROWS();
+			}
 			ftp->timeout_sec = Z_LVAL_P(z_value);
 			RETURN_TRUE;
 			break;
diff --git a/ext/ftp/tests/gh20601_set_option.phpt b/ext/ftp/tests/gh20601_set_option.phpt
new file mode 100644
index 00000000000..3317d4b5f2a
--- /dev/null
+++ b/ext/ftp/tests/gh20601_set_option.phpt
@@ -0,0 +1,26 @@
+--TEST--
+GH-20601 (ftp_set_option FTP_TIMEOUT_SEC timeout overflow)
+--EXTENSIONS--
+ftp
+pcntl
+--SKIPIF--
+<?php
+if (PHP_INT_SIZE != 8) die("skip: 64-bit only");
+if (PHP_OS_FAMILY === 'Windows') die("skip not for windows");
+?>
+--FILE--
+<?php
+require 'server.inc';
+
+$ftp = ftp_connect('127.0.0.1', $port);
+$ftp or die("Couldn't connect to the server");
+ftp_login($ftp, 'user', 'pass');
+
+try {
+	ftp_set_option($ftp, FTP_TIMEOUT_SEC, PHP_INT_MAX);
+} catch (\ValueError $e) {
+	echo $e->getMessage();
+}
+?>
+--EXPECTF--
+ftp_set_option(): Argument #3 ($value) must be less than %d for the FTP_TIMEOUT_SEC option
diff --git a/ext/ftp/tests/gh20601_ssl.phpt b/ext/ftp/tests/gh20601_ssl.phpt
new file mode 100644
index 00000000000..005f866e18e
--- /dev/null
+++ b/ext/ftp/tests/gh20601_ssl.phpt
@@ -0,0 +1,21 @@
+--TEST--
+GH-20601 (ftp_ssl_connect timeout overflow)
+--EXTENSIONS--
+ftp
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("ftp_ssl_connect")) die("skip ftp_ssl is disabled");
+if (PHP_INT_SIZE != 8) die("skip: 64-bit only");
+if (PHP_OS_FAMILY === 'Windows') die("skip not for windows");
+?>
+--FILE--
+<?php
+try {
+	ftp_ssl_connect('127.0.0.1', 1024, PHP_INT_MAX);
+} catch (\ValueError $e) {
+	echo $e->getMessage();
+}
+?>
+--EXPECTF--
+ftp_ssl_connect(): Argument #3 ($timeout) must be less than %d