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