Commit 86444fc994c for php

commit 86444fc994cb7cf8c8f8883e52cc4b01b0ec582b
Author: NickSdot <32384907+NickSdot@users.noreply.github.com>
Date:   Thu Oct 1 19:36:44 2026 +0700

    ext/uri: Allow empty credentials in the WHATWG URL Builder (#23816)

    Uri\WhatWg\UrlBuilder previously treated empty usernames and passwords as credentials (https://url.spec.whatwg.org/#include-credentials) during validation, rejecting them for URLs that cannot contain credentials (https://url.spec.whatwg.org/#cannot-have-a-username-password-port) and for references without an authority.

    These checks now reject only non-empty values, since empty strings do not introduce credentials.

diff --git a/ext/uri/tests/whatwg/builder/basic_success_empty_reference_with_credentials_in_base.phpt b/ext/uri/tests/whatwg/builder/basic_success_empty_reference_with_credentials_in_base.phpt
new file mode 100644
index 00000000000..b9187ab9d11
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/basic_success_empty_reference_with_credentials_in_base.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - empty reference with credentials in base URL
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url('https://user:pass@example.com:123/base/path?oldQuery#oldFragment');
+
+$url = new Uri\WhatWg\UrlBuilder()
+    ->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+var_dump($url->equals(new Uri\WhatWg\Url('', $base), Uri\UriComparisonMode::IncludeFragment));
+
+?>
+--EXPECTF--
+string(52) "https://user:pass@example.com:123/base/path?oldQuery"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  string(4) "user"
+  ["password"]=>
+  string(4) "pass"
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  int(123)
+  ["path"]=>
+  string(10) "/base/path"
+  ["query"]=>
+  string(8) "oldQuery"
+  ["fragment"]=>
+  NULL
+}
+bool(true)
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/password_success_empty_string_with_base.phpt b/ext/uri/tests/whatwg/builder/password_success_empty_string_with_base.phpt
new file mode 100644
index 00000000000..e4ff80092e7
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/password_success_empty_string_with_base.phpt
@@ -0,0 +1,39 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPassword() - success - empty string with credentials in base URL
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url('https://user:pass@example.com/base/path?oldQuery#oldFragment');
+
+$url = new Uri\WhatWg\UrlBuilder()
+    ->setPassword('')
+    ->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+var_dump($url->equals(new Uri\WhatWg\Url('', $base), Uri\UriComparisonMode::IncludeFragment));
+
+?>
+--EXPECTF--
+string(48) "https://user:pass@example.com/base/path?oldQuery"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  string(4) "user"
+  ["password"]=>
+  string(4) "pass"
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(10) "/base/path"
+  ["query"]=>
+  string(8) "oldQuery"
+  ["fragment"]=>
+  NULL
+}
+bool(true)
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/password_success_empty_string_with_file_scheme.phpt b/ext/uri/tests/whatwg/builder/password_success_empty_string_with_file_scheme.phpt
new file mode 100644
index 00000000000..085c2d66ff5
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/password_success_empty_string_with_file_scheme.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPassword() - success - empty string with file scheme
+--FILE--
+<?php
+
+$url = new Uri\WhatWg\UrlBuilder()
+    ->setScheme('file')
+    ->setPassword('')
+    ->setHost('example.net')
+    ->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(19) "file://example.net/"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(4) "file"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(11) "example.net"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/password_success_empty_string_with_localhost_and_file_base.phpt b/ext/uri/tests/whatwg/builder/password_success_empty_string_with_localhost_and_file_base.phpt
new file mode 100644
index 00000000000..5aa41828294
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/password_success_empty_string_with_localhost_and_file_base.phpt
@@ -0,0 +1,50 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPassword() - success - empty string with localhost and file base URL
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url('file:///base/path');
+
+$errors = [];
+$referenceErrors = [];
+
+$reference = new Uri\WhatWg\Url('//localhost', $base, $referenceErrors);
+
+$url = new Uri\WhatWg\UrlBuilder()
+    ->setHost('localhost')
+    ->setPassword('')
+    ->build($base, $errors);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($errors);
+var_dump($errors == $referenceErrors);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+var_dump($url->equals($reference, Uri\UriComparisonMode::IncludeFragment));
+
+?>
+--EXPECTF--
+string(8) "file:///"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(4) "file"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(0) ""
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+array(0) {
+}
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/password_success_empty_string_with_normalized_empty_host_and_file_base.phpt b/ext/uri/tests/whatwg/builder/password_success_empty_string_with_normalized_empty_host_and_file_base.phpt
new file mode 100644
index 00000000000..f84710d23eb
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/password_success_empty_string_with_normalized_empty_host_and_file_base.phpt
@@ -0,0 +1,59 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPassword() - success - empty string with host normalised to empty and file base URL
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url('file:///base/path');
+
+$errors = [];
+$referenceErrors = [];
+
+$reference = new Uri\WhatWg\Url("//\t\n", $base, $referenceErrors);
+
+$url = new Uri\WhatWg\UrlBuilder()
+    ->setHost("\t\n")
+    ->setPassword('')
+    ->build($base, $errors);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($errors);
+var_dump($errors == $referenceErrors);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+var_dump($url->equals($reference, Uri\UriComparisonMode::IncludeFragment));
+
+?>
+--EXPECTF--
+string(8) "file:///"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(4) "file"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(0) ""
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+array(1) {
+  [0]=>
+  object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+    ["context"]=>
+    string(2) "%r\x09\x0A%r"
+    ["type"]=>
+    enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+    ["failure"]=>
+    bool(false)
+  }
+}
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/username_success_empty_string_with_base.phpt b/ext/uri/tests/whatwg/builder/username_success_empty_string_with_base.phpt
new file mode 100644
index 00000000000..f5a60976ef2
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/username_success_empty_string_with_base.phpt
@@ -0,0 +1,39 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setUsername() - success - empty string with credentials in base URL
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url('https://user:pass@example.com/base/path?oldQuery#oldFragment');
+
+$url = new Uri\WhatWg\UrlBuilder()
+    ->setUsername('')
+    ->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+var_dump($url->equals(new Uri\WhatWg\Url('', $base), Uri\UriComparisonMode::IncludeFragment));
+
+?>
+--EXPECTF--
+string(48) "https://user:pass@example.com/base/path?oldQuery"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  string(4) "user"
+  ["password"]=>
+  string(4) "pass"
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(10) "/base/path"
+  ["query"]=>
+  string(8) "oldQuery"
+  ["fragment"]=>
+  NULL
+}
+bool(true)
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/username_success_empty_string_with_empty_host_and_file_base.phpt b/ext/uri/tests/whatwg/builder/username_success_empty_string_with_empty_host_and_file_base.phpt
new file mode 100644
index 00000000000..40d72e0ef08
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/username_success_empty_string_with_empty_host_and_file_base.phpt
@@ -0,0 +1,50 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setUsername() - success - empty string with empty host and file base URL
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url('file:///base/path');
+
+$errors = [];
+$referenceErrors = [];
+
+$reference = new Uri\WhatWg\Url('//', $base, $referenceErrors);
+
+$url = new Uri\WhatWg\UrlBuilder()
+    ->setHost('')
+    ->setUsername('')
+    ->build($base, $errors);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($errors);
+var_dump($errors == $referenceErrors);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+var_dump($url->equals($reference, Uri\UriComparisonMode::IncludeFragment));
+
+?>
+--EXPECTF--
+string(8) "file:///"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(4) "file"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(0) ""
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+array(0) {
+}
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/username_success_empty_string_with_file_base.phpt b/ext/uri/tests/whatwg/builder/username_success_empty_string_with_file_base.phpt
new file mode 100644
index 00000000000..5b4075dc41d
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/username_success_empty_string_with_file_base.phpt
@@ -0,0 +1,40 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setUsername() - success - empty string with file base URL
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url('file:///base/path?oldQuery#oldFragment');
+
+$url = new Uri\WhatWg\UrlBuilder()
+    ->setUsername('')
+    ->setHost('example.net')
+    ->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+var_dump($url->equals(new Uri\WhatWg\Url('//example.net', $base), Uri\UriComparisonMode::IncludeFragment));
+
+?>
+--EXPECTF--
+string(19) "file://example.net/"
+object(Uri\WhatWg\Url)#%d (8) {
+  ["scheme"]=>
+  string(4) "file"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(11) "example.net"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
+bool(true)
diff --git a/ext/uri/tests/whatwg/modification/password_success_empty_string_with_existing_password.phpt b/ext/uri/tests/whatwg/modification/password_success_empty_string_with_existing_password.phpt
new file mode 100644
index 00000000000..23e94917cba
--- /dev/null
+++ b/ext/uri/tests/whatwg/modification/password_success_empty_string_with_existing_password.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\Url::withPassword() - success - empty string removes existing password
+--FILE--
+<?php
+
+$url1 = new Uri\WhatWg\Url('https://user:pass@example.com/path');
+$url2 = $url1->withPassword('');
+
+var_dump($url1->getPassword());
+var_dump($url2->getPassword());
+var_dump($url2->toAsciiString());
+
+?>
+--EXPECT--
+string(4) "pass"
+string(0) ""
+string(29) "https://user@example.com/path"
diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c
index 5170849e328..2c123cd238d 100644
--- a/ext/uri/uri_parser_whatwg.c
+++ b/ext/uri/uri_parser_whatwg.c
@@ -1071,12 +1071,12 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser
 	}

 	/* Credentials and ports require an authority in the reference itself. */
-	if (Z_TYPE_P(username) == IS_STRING) {
+	if (Z_TYPE_P(username) == IS_STRING && Z_STRLEN_P(username) > 0) {
 		php_uri_parser_whatwg_throw_exception("The specified URL cannot have username");
 		return NULL;
 	}

-	if (Z_TYPE_P(password) == IS_STRING) {
+	if (Z_TYPE_P(password) == IS_STRING && Z_STRLEN_P(password) > 0) {
 		php_uri_parser_whatwg_throw_exception("The specified URL cannot have password");
 		return NULL;
 	}
@@ -1310,12 +1310,12 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh
 	if (lexbor_url->host.type == LXB_URL_HOST_TYPE__UNDEF
 		|| lexbor_url->host.type == LXB_URL_HOST_TYPE_EMPTY
 		|| lexbor_url->scheme.type == LXB_URL_SCHEMEL_TYPE_FILE) {
-		if (Z_TYPE_P(username) != IS_NULL) {
+		if (Z_TYPE_P(username) == IS_STRING && Z_STRLEN_P(username) > 0) {
 			php_uri_parser_whatwg_throw_exception("The specified URL cannot have username");
 			goto failure;
 		}

-		if (Z_TYPE_P(password) != IS_NULL) {
+		if (Z_TYPE_P(password) == IS_STRING && Z_STRLEN_P(password) > 0) {
 			php_uri_parser_whatwg_throw_exception("The specified URL cannot have password");
 			goto failure;
 		}