Commit eb34f8920db for php.net
commit eb34f8920dbcc2c6bf4a1cbd1a7eb832b09b1425
Author: Máté Kocsis <kocsismate@woohoolabs.com>
Date: Tue Sep 8 21:33:21 2026 +0200
ext/uri: Build hostless URLs with the correct path type
Preserve absent hosts for non-special URLs and initialize opaque paths directly. Add validation to reject path delimiters.
Closes GH-23636
diff --git a/ext/uri/tests/whatwg/builder/host_success_opaque_null.phpt b/ext/uri/tests/whatwg/builder/host_success_opaque_null.phpt
index 9921721af17..052584e91cc 100644
--- a/ext/uri/tests/whatwg/builder/host_success_opaque_null.phpt
+++ b/ext/uri/tests/whatwg/builder/host_success_opaque_null.phpt
@@ -15,7 +15,7 @@
?>
--EXPECTF--
-string(9) "scheme://"
+string(7) "scheme:"
object(Uri\WhatWg\Url)#%d (%d) {
["scheme"]=>
string(6) "scheme"
@@ -24,7 +24,7 @@
["password"]=>
NULL
["host"]=>
- string(0) ""
+ NULL
["port"]=>
NULL
["path"]=>
diff --git a/ext/uri/tests/whatwg/builder/path_error_opaque_hashmark.phpt b/ext/uri/tests/whatwg/builder/path_error_opaque_hashmark.phpt
new file mode 100644
index 00000000000..11176137331
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_error_opaque_hashmark.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - error - hashmark in an opaque path
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("foo");
+$builder->setPath("a#b");
+
+try {
+ $builder->build();
+} catch (Throwable $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified path is malformed
diff --git a/ext/uri/tests/whatwg/builder/path_error_opaque_question_mark.phpt b/ext/uri/tests/whatwg/builder/path_error_opaque_question_mark.phpt
new file mode 100644
index 00000000000..c527d87b13d
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_error_opaque_question_mark.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - error - question mark in an opaque path
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("foo");
+$builder->setPath("a?b");
+
+try {
+ $builder->build();
+} catch (Throwable $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified path is malformed
diff --git a/ext/uri/tests/whatwg/builder/path_success_hierarchical_delimiters.phpt b/ext/uri/tests/whatwg/builder/path_success_hierarchical_delimiters.phpt
new file mode 100644
index 00000000000..f33a3c26ee9
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_hierarchical_delimiters.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - question mark and hashmark in a hierarchical path
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setPath("/a?b#c");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(29) "https://example.com/a%3Fb%23c"
+object(Uri\WhatWg\Url)#%d (%d) {
+ ["scheme"]=>
+ string(5) "https"
+ ["username"]=>
+ NULL
+ ["password"]=>
+ NULL
+ ["host"]=>
+ string(11) "example.com"
+ ["port"]=>
+ NULL
+ ["path"]=>
+ string(10) "/a%3Fb%23c"
+ ["query"]=>
+ NULL
+ ["fragment"]=>
+ NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_opaque_spaces.phpt b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces.phpt
new file mode 100644
index 00000000000..c66d10f29f5
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces.phpt
@@ -0,0 +1,67 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - leading and trailing spaces in an opaque path
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("foo");
+$builder->setPath(" abc ");
+$softErrors = [];
+$url = $builder->build(softErrors: $softErrors);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($softErrors);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(10) "foo: abc "
+object(Uri\WhatWg\Url)#%d (%d) {
+ ["scheme"]=>
+ string(3) "foo"
+ ["username"]=>
+ NULL
+ ["password"]=>
+ NULL
+ ["host"]=>
+ NULL
+ ["port"]=>
+ NULL
+ ["path"]=>
+ string(6) " abc "
+ ["query"]=>
+ NULL
+ ["fragment"]=>
+ NULL
+}
+array(3) {
+ [0]=>
+ object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+ ["context"]=>
+ string(1) " "
+ ["type"]=>
+ enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+ ["failure"]=>
+ bool(false)
+ }
+ [1]=>
+ object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+ ["context"]=>
+ string(5) " abc "
+ ["type"]=>
+ enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+ ["failure"]=>
+ bool(false)
+ }
+ [2]=>
+ object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+ ["context"]=>
+ string(6) " abc "
+ ["type"]=>
+ enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+ ["failure"]=>
+ bool(false)
+ }
+}
+bool(false)
diff --git a/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_fragment.phpt b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_fragment.phpt
new file mode 100644
index 00000000000..a5cc306c07f
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_fragment.phpt
@@ -0,0 +1,59 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - trailing spaces before a fragment
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("foo");
+$builder->setPath("abc ");
+$builder->setFragment("f");
+$softErrors = [];
+$url = $builder->build(softErrors: $softErrors);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($softErrors);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()), Uri\UriComparisonMode::IncludeFragment));
+
+?>
+--EXPECTF--
+string(11) "foo:abc #f"
+object(Uri\WhatWg\Url)#%d (%d) {
+ ["scheme"]=>
+ string(3) "foo"
+ ["username"]=>
+ NULL
+ ["password"]=>
+ NULL
+ ["host"]=>
+ NULL
+ ["port"]=>
+ NULL
+ ["path"]=>
+ string(5) "abc "
+ ["query"]=>
+ NULL
+ ["fragment"]=>
+ string(1) "f"
+}
+array(2) {
+ [0]=>
+ object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+ ["context"]=>
+ string(2) " #"
+ ["type"]=>
+ enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+ ["failure"]=>
+ bool(false)
+ }
+ [1]=>
+ object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+ ["context"]=>
+ string(3) " #"
+ ["type"]=>
+ enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+ ["failure"]=>
+ bool(false)
+ }
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_query.phpt b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_query.phpt
new file mode 100644
index 00000000000..4ffa9701e10
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_query.phpt
@@ -0,0 +1,59 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - trailing spaces before a query
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("foo");
+$builder->setPath("abc ");
+$builder->setQuery("q");
+$softErrors = [];
+$url = $builder->build(softErrors: $softErrors);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($softErrors);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(11) "foo:abc ?q"
+object(Uri\WhatWg\Url)#%d (%d) {
+ ["scheme"]=>
+ string(3) "foo"
+ ["username"]=>
+ NULL
+ ["password"]=>
+ NULL
+ ["host"]=>
+ NULL
+ ["port"]=>
+ NULL
+ ["path"]=>
+ string(5) "abc "
+ ["query"]=>
+ string(1) "q"
+ ["fragment"]=>
+ NULL
+}
+array(2) {
+ [0]=>
+ object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+ ["context"]=>
+ string(2) " ?"
+ ["type"]=>
+ enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+ ["failure"]=>
+ bool(false)
+ }
+ [1]=>
+ object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+ ["context"]=>
+ string(3) " ?"
+ ["type"]=>
+ enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+ ["failure"]=>
+ bool(false)
+ }
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_special_char.phpt b/ext/uri/tests/whatwg/builder/path_success_special_char.phpt
index b47a44b1e13..f10ec866a40 100644
--- a/ext/uri/tests/whatwg/builder/path_success_special_char.phpt
+++ b/ext/uri/tests/whatwg/builder/path_success_special_char.phpt
@@ -1,11 +1,11 @@
--TEST--
-Test Uri\WhatWg\UrlBuilder::setPath() - success - contains special character
+Test Uri\WhatWg\UrlBuilder::setPath() - success - contains a C0 control character
--FILE--
<?php
$builder = new Uri\WhatWg\UrlBuilder();
$builder->setScheme("scheme");
-$builder->setPath("#foo");
+$builder->setPath("\x1Ffoo");
$url = $builder->build();
@@ -15,7 +15,7 @@
?>
--EXPECTF--
-string(16) "scheme:///%23foo"
+string(13) "scheme:%1Ffoo"
object(Uri\WhatWg\Url)#%d (%d) {
["scheme"]=>
string(6) "scheme"
@@ -24,11 +24,11 @@
["password"]=>
NULL
["host"]=>
- string(0) ""
+ NULL
["port"]=>
NULL
["path"]=>
- string(7) "/%23foo"
+ string(6) "%1Ffoo"
["query"]=>
NULL
["fragment"]=>
diff --git a/ext/uri/tests/whatwg/builder/query_success_hashmark.phpt b/ext/uri/tests/whatwg/builder/query_success_hashmark.phpt
new file mode 100644
index 00000000000..3c56e6a131f
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/query_success_hashmark.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setQuery() - success - hashmark does not introduce a fragment
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setQuery("a#b");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(26) "https://example.com/?a%23b"
+object(Uri\WhatWg\Url)#%d (%d) {
+ ["scheme"]=>
+ string(5) "https"
+ ["username"]=>
+ NULL
+ ["password"]=>
+ NULL
+ ["host"]=>
+ string(11) "example.com"
+ ["port"]=>
+ NULL
+ ["path"]=>
+ string(1) "/"
+ ["query"]=>
+ string(5) "a%23b"
+ ["fragment"]=>
+ NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/scheme_success_non_special.phpt b/ext/uri/tests/whatwg/builder/scheme_success_non_special.phpt
index c805105ee99..26593beda92 100644
--- a/ext/uri/tests/whatwg/builder/scheme_success_non_special.phpt
+++ b/ext/uri/tests/whatwg/builder/scheme_success_non_special.phpt
@@ -14,7 +14,7 @@
?>
--EXPECTF--
-string(6) "foo://"
+string(4) "foo:"
object(Uri\WhatWg\Url)#%d (%d) {
["scheme"]=>
string(3) "foo"
@@ -23,7 +23,7 @@
["password"]=>
NULL
["host"]=>
- string(0) ""
+ NULL
["port"]=>
NULL
["path"]=>
diff --git a/ext/uri/tests/whatwg/builder/scheme_success_special.phpt b/ext/uri/tests/whatwg/builder/scheme_success_special.phpt
index 60a175a5f20..39fb4b71c13 100644
--- a/ext/uri/tests/whatwg/builder/scheme_success_special.phpt
+++ b/ext/uri/tests/whatwg/builder/scheme_success_special.phpt
@@ -13,7 +13,7 @@
?>
--EXPECTF--
-string(18) "my-12+34.scheme://"
+string(16) "my-12+34.scheme:"
object(Uri\WhatWg\Url)#%d (%d) {
["scheme"]=>
string(15) "my-12+34.scheme"
@@ -22,7 +22,7 @@
["password"]=>
NULL
["host"]=>
- string(0) ""
+ NULL
["port"]=>
NULL
["path"]=>
diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c
index 3ae5a3890e8..acda86877ac 100644
--- a/ext/uri/uri_parser_whatwg.c
+++ b/ext/uri/uri_parser_whatwg.c
@@ -984,6 +984,56 @@ ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_build_errors(zval *erro
fill_errors_inner(Z_ARRVAL_P(errors));
}
+ZEND_ATTRIBUTE_NONNULL static zend_result php_uri_parser_whatwg_build_path(
+ lxb_url_t *lexbor_url, const zval *path, const zval *query, const zval *fragment, zval *errors
+) {
+ zend_result result;
+ const char *path_start = Z_STRVAL_P(path);
+ const char *path_end = path_start + Z_STRLEN_P(path);
+ while (path_start < path_end && php_uri_whatwg_is_ascii_tab_or_newline(*path_start)) {
+ path_start++;
+ }
+
+ if (lexbor_url->host.type == LXB_URL_HOST_TYPE__UNDEF && (path_start == path_end || *path_start != '/')) {
+ /* A pathname setter cannot parse an opaque path. Parse it directly,
+ * rejecting delimiters that would start a query or fragment. */
+ for (const char *p = Z_STRVAL_P(path); p < path_end; p++) {
+ if (*p == '?' || *p == '#') {
+ throw_invalid_url_exception_during_write(NULL, "path");
+
+ return FAILURE;
+ }
+ }
+
+ smart_str opaque_path = {0};
+ smart_str_append(&opaque_path, Z_STR_P(path));
+
+ /* The opaque path parser needs the following delimiter to encode the
+ * immediately preceding space, while still reporting all space errors. */
+ if (Z_TYPE_P(query) != IS_NULL) {
+ smart_str_appendc(&opaque_path, '?');
+ } else if (Z_TYPE_P(fragment) != IS_NULL) {
+ smart_str_appendc(&opaque_path, '#');
+ }
+
+ zend_string *input = smart_str_extract(&opaque_path);
+ lxb_url_parser_clean(&lexbor_parser);
+ const lxb_status_t status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, NULL,
+ (const lxb_char_t *) ZSTR_VAL(input), ZSTR_LEN(input),
+ LXB_URL_STATE_OPAQUE_PATH_STATE, LXB_ENCODING_UTF_8);
+ result = status == LXB_STATUS_OK ? SUCCESS : FAILURE;
+ if (result == FAILURE) {
+ throw_invalid_url_exception_during_write(NULL, "path");
+ }
+ php_uri_parser_whatwg_build_errors(errors);
+ zend_string_release(input);
+ } else {
+ result = php_uri_parser_whatwg_path_write(lexbor_url, path, NULL);
+ }
+
+ return result;
+}
+
ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_whatwg_build_from_zval(
lxb_url_t *lexbor_base_url, const zval *scheme, const zval *username, const zval *password,
const zval *host, const zval *port, const zval *path, const zval *query, const zval *fragment,
@@ -1016,10 +1066,14 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh
goto failure;
}
- result = php_uri_parser_whatwg_host_write(lexbor_url, host, NULL);
- php_uri_parser_whatwg_build_errors(&errors);
- if (result == FAILURE) {
- goto failure;
+ /* Set the host when provided or required by a special scheme (file allows an empty host).
+ * Otherwise, preserve the absent host so the path can be opaque. */
+ if (Z_TYPE_P(host) == IS_STRING || lxb_url_is_special(lexbor_url)) {
+ result = php_uri_parser_whatwg_host_write(lexbor_url, host, NULL);
+ php_uri_parser_whatwg_build_errors(&errors);
+ if (result == FAILURE) {
+ goto failure;
+ }
}
if (lexbor_url->host.type == LXB_URL_HOST_TYPE__UNDEF
@@ -1062,7 +1116,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh
goto failure;
}
- result = php_uri_parser_whatwg_path_write(lexbor_url, path, NULL);
+ result = php_uri_parser_whatwg_build_path(lexbor_url, path, query, fragment, &errors);
php_uri_parser_whatwg_build_errors(&errors);
if (result == FAILURE) {
goto failure;