Commit 7b3369c0c26 for php

commit 7b3369c0c26962b8199b2396bb2e9ebb8d50f292
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Sun Oct 25 07:01:05 2026 -0400

    Fix GH-24315: Harden SoapClient cross-origin redirect handling (#24329)

    SoapClient followed any Location header, so a redirect to another host,
    port, or scheme resent the HTTP authentication credentials, the stream
    context headers, and the SOAP envelope to that origin. Such redirects now
    fail with a SoapFault. A same-host upgrade from http to https on port 443
    is still followed. The new follow_cross_origin_redirects option restores
    the previous behavior. Cookies were already scoped by domain and
    Proxy-Authorization only reaches the proxy, so neither changes.

    Fixes GH-24315

diff --git a/NEWS b/NEWS
index efec2b84e88..cd9ddd3994c 100644
--- a/NEWS
+++ b/NEWS
@@ -127,6 +127,8 @@ PHP                                                                        NEWS
 - SOAP:
   . Fixed the SOAP client accepting a response body shorter than its
     Content-Length and reusing the connection. (Ilia Alshanetsky)
+  . Fixed bug GH-24315 (Harden SoapClient cross-origin redirect handling).
+    (Ilia Alshanetsky)

 - SPL:
   . Fixed bug GH-23385 (SplDoublyLinkedList::serialize() use-after-free when
diff --git a/UPGRADING b/UPGRADING
index baa930594bd..49d1ebc8258 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -36,6 +36,14 @@ PHP 8.7 UPGRADE NOTES
     ReflectionZendExtension, ReflectionAttribute and ReflectionConstant are
     now readonly.

+- SOAP:
+  . SoapClient no longer follows an HTTP redirect to a different origin
+    (scheme, host, or port) and throws a SoapFault instead. Previously the
+    request body and the configured credentials and headers were resent to
+    the new origin. A redirect from http to https on the same host is still
+    followed. Set the new follow_cross_origin_redirects option to true to
+    restore the previous behavior.
+
 - Session:
   . Setting session.cookie_path to a value containing one of ";\r\n", or
     session.cookie_domain to a value containing one of ",; \t\r\n\013\014",
diff --git a/ext/soap/php_http.c b/ext/soap/php_http.c
index d64a110cb1c..b4b368cfead 100644
--- a/ext/soap/php_http.c
+++ b/ext/soap/php_http.c
@@ -347,6 +347,28 @@ static bool in_domain(const zend_string *host, const zend_string *domain)
 	}
 }

+static bool is_allowed_redirect(const php_uri *from, const php_uri *to)
+{
+	if (to->scheme == NULL || to->host == NULL) {
+		return true;
+	}
+
+	if (!zend_string_equals_ci(from->host, to->host)) {
+		return false;
+	}
+
+	zend_long to_port = to->port;
+	if (to_port == 0) {
+		to_port = zend_string_equals_literal(to->scheme, "https") ? 443 : 80;
+	}
+
+	if (zend_string_equals(from->scheme, to->scheme)) {
+		return from->port == to_port;
+	}
+
+	return zend_string_equals_literal(from->scheme, "http") && zend_string_equals_literal(to->scheme, "https") && to_port == 443;
+}
+
 bool make_http_soap_request(
 	zval *this_ptr, zend_string *buf, zend_string *location, char *soapaction,
 	int soap_version, zend_string *uri_parser_class, zval *return_value
@@ -1191,6 +1213,15 @@ bool make_http_soap_request(
 						}
 					}
 				}
+
+				if (Z_TYPE_P(Z_CLIENT_FOLLOW_CROSS_ORIGIN_REDIRECTS_P(this_ptr)) != IS_TRUE && !is_allowed_redirect(uri, new_uri)) {
+					php_uri_struct_free(new_uri);
+					add_soap_fault(this_ptr, "HTTP", "Redirection to a different origin is not allowed", NULL, NULL, soap_lang_en);
+					smart_str_free(&soap_headers_z);
+					efree(http_msg);
+					return false;
+				}
+
 				uri = new_uri;

 				if (--redirect_max < 1) {
diff --git a/ext/soap/php_soap.h b/ext/soap/php_soap.h
index c067b633f48..7e143b63d15 100644
--- a/ext/soap/php_soap.h
+++ b/ext/soap/php_soap.h
@@ -261,6 +261,7 @@ static zend_always_inline zval *php_soap_deref(zval *zv) {
 #define Z_CLIENT_LAST_REQUEST_HEADERS_P(zv) OBJ_PROP_NUM(Z_OBJ_P(zv), 33)
 #define Z_CLIENT_LAST_RESPONSE_HEADERS_P(zv) OBJ_PROP_NUM(Z_OBJ_P(zv), 34)
 #define Z_CLIENT_KEEP_HEADERS_P(zv) OBJ_PROP_NUM(Z_OBJ_P(zv), 35)
+#define Z_CLIENT_FOLLOW_CROSS_ORIGIN_REDIRECTS_P(zv) OBJ_PROP_NUM(Z_OBJ_P(zv), 36)

 typedef struct soap_url_object {
 	php_uri *uri;
diff --git a/ext/soap/soap.c b/ext/soap/soap.c
index 086c5ab0370..8d5fc9a694d 100644
--- a/ext/soap/soap.c
+++ b/ext/soap/soap.c
@@ -2331,6 +2331,12 @@ PHP_METHOD(SoapClient, __construct)
 			ZVAL_FALSE(Z_CLIENT_KEEP_ALIVE_P(this_ptr));
 		}

+		if ((tmp = zend_hash_str_find(ht, "follow_cross_origin_redirects", sizeof("follow_cross_origin_redirects")-1)) != NULL &&
+				(Z_TYPE_P(tmp) == IS_TRUE ||
+				 (Z_TYPE_P(tmp) == IS_LONG && Z_LVAL_P(tmp) == 1))) {
+			ZVAL_TRUE(Z_CLIENT_FOLLOW_CROSS_ORIGIN_REDIRECTS_P(this_ptr));
+		}
+
 		if ((tmp = zend_hash_str_find(ht, "ssl_method", sizeof("ssl_method")-1)) != NULL &&
 			Z_TYPE_P(tmp) == IS_LONG) {
 			ZVAL_LONG(Z_CLIENT_SSL_METHOD_P(this_ptr), Z_LVAL_P(tmp));
diff --git a/ext/soap/soap.stub.php b/ext/soap/soap.stub.php
index d614fe8d963..68a43ff081a 100644
--- a/ext/soap/soap.stub.php
+++ b/ext/soap/soap.stub.php
@@ -573,6 +573,7 @@ class SoapClient
         private ?string $__last_request_headers = null;
         private ?string $__last_response_headers = null;
         private int $_keep_headers = 0;
+        private bool $_follow_cross_origin_redirects = false;

         public function __construct(?string $wsdl, array $options = []) {}

diff --git a/ext/soap/soap_arginfo.h b/ext/soap/soap_arginfo.h
index 5757cea7e36..fef124a8191 100644
Binary files a/ext/soap/soap_arginfo.h and b/ext/soap/soap_arginfo.h differ
diff --git a/ext/soap/tests/bugs/gh21421.phpt b/ext/soap/tests/bugs/gh21421.phpt
index e20b79aed56..5acd8a27837 100644
--- a/ext/soap/tests/bugs/gh21421.phpt
+++ b/ext/soap/tests/bugs/gh21421.phpt
@@ -15,7 +15,7 @@
 var_dump($client);
 ?>
 --EXPECTF--
-object(SoapClient)#%d (36) {
+object(SoapClient)#%d (37) {
   ["uri":"SoapClient":private]=>
   string(26) "http://schemas.nothing.com"
   ["style":"SoapClient":private]=>
@@ -89,4 +89,6 @@
   NULL
   ["_keep_headers":"SoapClient":private]=>
   int(0)
+  ["_follow_cross_origin_redirects":"SoapClient":private]=>
+  bool(false)
 }
diff --git a/ext/soap/tests/bugs/gh24315.phpt b/ext/soap/tests/bugs/gh24315.phpt
new file mode 100644
index 00000000000..edb2720cb80
--- /dev/null
+++ b/ext/soap/tests/bugs/gh24315.phpt
@@ -0,0 +1,75 @@
+--TEST--
+GH-24315 (SoapClient follows redirects to a different origin)
+--EXTENSIONS--
+soap
+openssl
+--SKIPIF--
+<?php
+if (!file_exists(__DIR__ . "/../../../../sapi/cli/tests/php_cli_server.inc")) {
+    echo "skip sapi/cli/tests/php_cli_server.inc required but not found";
+}
+?>
+--FILE--
+<?php
+include __DIR__ . "/../../../../sapi/cli/tests/php_cli_server.inc";
+
+$args = ["-d", "extension_dir=" . ini_get("extension_dir"), "-d", "extension=" . (substr(PHP_OS, 0, 3) == "WIN" ? "php_" : "") . "soap." . PHP_SHLIB_SUFFIX];
+if (php_ini_loaded_file()) {
+    $args[] = "-c";
+    $args[] = php_ini_loaded_file();
+}
+
+$code = <<<'PHP'
+if ($_SERVER["REQUEST_URI"] === "/ok") {
+    header("Content-Type: text/xml; charset=utf-8");
+    echo '<?xml version="1.0" encoding="UTF-8"?>',
+        '<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">',
+        '<SOAP-ENV:Body><fooResponse><result>ok</result></fooResponse></SOAP-ENV:Body>',
+        '</SOAP-ENV:Envelope>';
+} else {
+    http_response_code(302);
+    header("Location: " . base64_decode(substr($_SERVER["REQUEST_URI"], 1)));
+}
+PHP;
+
+php_cli_server_start($code, null, $args);
+
+$targets = [
+    "different host" => "http://127.0.0.1:" . PHP_CLI_SERVER_PORT . "/ok",
+    "different port" => "http://" . PHP_CLI_SERVER_HOSTNAME . ":" . (PHP_CLI_SERVER_PORT + 1) . "/ok",
+    "protocol-relative different host" => "//127.0.0.1:" . PHP_CLI_SERVER_PORT . "/ok",
+    "https upgrade to a non-default port" => "https://" . PHP_CLI_SERVER_ADDRESS . "/ok",
+    "same origin" => "http://" . PHP_CLI_SERVER_ADDRESS . "/ok",
+    "same origin, host case differs" => "http://" . strtoupper(PHP_CLI_SERVER_HOSTNAME) . ":" . PHP_CLI_SERVER_PORT . "/ok",
+    "https upgrade on the same host" => "https://" . PHP_CLI_SERVER_HOSTNAME . "/ok",
+];
+
+$targets["different host, follow_cross_origin_redirects"] = $targets["different host"];
+
+foreach ($targets as $label => $target) {
+    $client = new SoapClient(null, [
+        'location' => 'http://' . PHP_CLI_SERVER_ADDRESS . '/' . base64_encode($target),
+        'uri' => 'test-uri',
+        'login' => 'user',
+        'password' => 'secret',
+        'follow_cross_origin_redirects' => str_ends_with($label, 'follow_cross_origin_redirects'),
+    ]);
+
+    $rejected = false;
+    try {
+        $client->__soapCall("foo", []);
+    } catch (SoapFault $e) {
+        $rejected = $e->getMessage() === "Redirection to a different origin is not allowed";
+    }
+    echo $label, ": ", $rejected ? "rejected" : "not rejected", "\n";
+}
+?>
+--EXPECT--
+different host: rejected
+different port: rejected
+protocol-relative different host: rejected
+https upgrade to a non-default port: rejected
+same origin: not rejected
+same origin, host case differs: not rejected
+https upgrade on the same host: not rejected
+different host, follow_cross_origin_redirects: not rejected