From 870514c5826a5d097ffacb40922188db213dfbda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Tue, 8 Sep 2026 21:32:47 +0200 Subject: [PATCH 1/7] ext/uri: Reject URL delimiters in builder hosts Reject literal path, query and fragment delimiters before the hostname setter can silently discard the rest of the input. --- .../builder/host_error_trailing_backslash.phpt | 16 ++++++++++++++++ .../builder/host_error_trailing_fragment.phpt | 16 ++++++++++++++++ .../whatwg/builder/host_error_trailing_path.phpt | 16 ++++++++++++++++ .../builder/host_error_trailing_query.phpt | 16 ++++++++++++++++ ext/uri/uri_parser_whatwg.c | 12 +++++++++++- 5 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 ext/uri/tests/whatwg/builder/host_error_trailing_backslash.phpt create mode 100644 ext/uri/tests/whatwg/builder/host_error_trailing_fragment.phpt create mode 100644 ext/uri/tests/whatwg/builder/host_error_trailing_path.phpt create mode 100644 ext/uri/tests/whatwg/builder/host_error_trailing_query.phpt diff --git a/ext/uri/tests/whatwg/builder/host_error_trailing_backslash.phpt b/ext/uri/tests/whatwg/builder/host_error_trailing_backslash.phpt new file mode 100644 index 000000000000..20dcd8b3e8f1 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/host_error_trailing_backslash.phpt @@ -0,0 +1,16 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::setHost() - error - contains a backslash delimiter +--FILE-- +setHost("example.com\\path"); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified host is malformed (HostInvalidCodePoint) diff --git a/ext/uri/tests/whatwg/builder/host_error_trailing_fragment.phpt b/ext/uri/tests/whatwg/builder/host_error_trailing_fragment.phpt new file mode 100644 index 000000000000..ba9c1bf13375 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/host_error_trailing_fragment.phpt @@ -0,0 +1,16 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::setHost() - error - contains a fragment delimiter +--FILE-- +setHost("example.com#fragment"); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified host is malformed (HostInvalidCodePoint) diff --git a/ext/uri/tests/whatwg/builder/host_error_trailing_path.phpt b/ext/uri/tests/whatwg/builder/host_error_trailing_path.phpt new file mode 100644 index 000000000000..372a402f1f4a --- /dev/null +++ b/ext/uri/tests/whatwg/builder/host_error_trailing_path.phpt @@ -0,0 +1,16 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::setHost() - error - contains a path delimiter +--FILE-- +setHost("example.com/path"); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified host is malformed (HostInvalidCodePoint) diff --git a/ext/uri/tests/whatwg/builder/host_error_trailing_query.phpt b/ext/uri/tests/whatwg/builder/host_error_trailing_query.phpt new file mode 100644 index 000000000000..c5449d37d504 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/host_error_trailing_query.phpt @@ -0,0 +1,16 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::setHost() - error - contains a query delimiter +--FILE-- +setHost("example.com?query"); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified host is malformed (HostInvalidCodePoint) diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c index 903f2568d592..1c347471796c 100644 --- a/ext/uri/uri_parser_whatwg.c +++ b/ext/uri/uri_parser_whatwg.c @@ -826,8 +826,18 @@ ZEND_ATTRIBUTE_NONNULL zend_result php_uri_parser_whatwg_host_validate(const zen first++; } + /* Validate the entire host before the hostname setter can stop at a URL delimiter. + * Backslash is a delimiter only for special URLs, but is also forbidden in opaque hosts. + * https://url.spec.whatwg.org/#hostname-state + * https://url.spec.whatwg.org/#opaque-host-parser */ + for (const char *p = first; p < last; p++) { + if (*p == '/' || *p == '?' || *p == '#' || *p == '\\') { + return php_uri_parser_whatwg_component_error("host", LXB_URL_ERROR_TYPE_HOST_INVALID_CODE_POINT); + } + } + if (*first != '[') { - /* Skip validation - The host is not an IPv6 address */ + /* Skip further validation - The host is not an IPv6 address */ return SUCCESS; } From ba4379c8964044e6807b4de585f0dc50e7659b67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Tue, 8 Sep 2026 21:32:47 +0200 Subject: [PATCH 2/7] ext/uri: Initialize builder validation exception errors Populate validation error details when a code is available, and initialize an empty errors array for other builder failures. --- .../build_error_initialized_errors.phpt | 28 +++++++ ...build_error_initialized_global_errors.phpt | 21 +++++ ext/uri/uri_parser_whatwg.c | 76 +++++++++++-------- 3 files changed, 92 insertions(+), 33 deletions(-) create mode 100644 ext/uri/tests/whatwg/builder/build_error_initialized_errors.phpt create mode 100644 ext/uri/tests/whatwg/builder/build_error_initialized_global_errors.phpt diff --git a/ext/uri/tests/whatwg/builder/build_error_initialized_errors.phpt b/ext/uri/tests/whatwg/builder/build_error_initialized_errors.phpt new file mode 100644 index 000000000000..f3da52798c5c --- /dev/null +++ b/ext/uri/tests/whatwg/builder/build_error_initialized_errors.phpt @@ -0,0 +1,28 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::setHost() - error - initializes validation errors +--FILE-- +setHost("example.com/path"); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; + var_dump($e->errors); +} + +?> +--EXPECTF-- +Uri\WhatWg\InvalidUrlException: The specified host is malformed (HostInvalidCodePoint) +array(1) { + [0]=> + object(Uri\WhatWg\UrlValidationError)#%d (%d) { + ["context"]=> + string(0) "" + ["type"]=> + enum(Uri\WhatWg\UrlValidationErrorType::HostInvalidCodePoint) + ["failure"]=> + bool(true) + } +} diff --git a/ext/uri/tests/whatwg/builder/build_error_initialized_global_errors.phpt b/ext/uri/tests/whatwg/builder/build_error_initialized_global_errors.phpt new file mode 100644 index 000000000000..3c25356b3db1 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/build_error_initialized_global_errors.phpt @@ -0,0 +1,21 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - error - initializes errors for invalid component combinations +--FILE-- +setScheme("foo"); +$builder->setUsername("user"); + +try { + $builder->build(); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; + var_dump($e->errors); +} + +?> +--EXPECT-- +Uri\WhatWg\InvalidUrlException: The specified URL cannot have username +array(0) { +} diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c index 1c347471796c..c07e8fae900c 100644 --- a/ext/uri/uri_parser_whatwg.c +++ b/ext/uri/uri_parser_whatwg.c @@ -148,6 +148,23 @@ ZEND_ATTRIBUTE_NONNULL static bool get_reason_from_error_type(const lxb_url_erro } } +ZEND_ATTRIBUTE_NONNULL static bool append_validation_error( + HashTable *errors, lxb_url_error_type_t error_type, const char *context, const char **reason +) { + zval error; + object_init_ex(&error, php_uri_ce_whatwg_url_validation_error); + zend_update_property_string(php_uri_ce_whatwg_url_validation_error, Z_OBJ(error), ZEND_STRL("context"), context); + + bool failure = get_reason_from_error_type(error_type, reason); + zval type; + ZVAL_OBJ(&type, zend_enum_get_case_cstr(php_uri_ce_whatwg_url_validation_error_type, *reason)); + zend_update_property_ex(php_uri_ce_whatwg_url_validation_error, Z_OBJ(error), ZSTR_KNOWN(ZEND_STR_TYPE), &type); + zend_update_property_bool(php_uri_ce_whatwg_url_validation_error, Z_OBJ(error), ZEND_STRL("failure"), failure); + zend_hash_next_index_insert(errors, &error); + + return failure; +} + /** * Creates a Uri\WhatWg\UrlValidationError class by mapping error codes listed in * https://url.spec.whatwg.org/#writing to a Uri\WhatWg\UrlValidationErrorType enum. @@ -159,26 +176,10 @@ ZEND_ATTRIBUTE_NONNULL static const char *fill_errors_inner(HashTable *errors) lexbor_plog_entry_t *lxb_error; while ((lxb_error = lexbor_array_obj_pop(&lexbor_parser.log->list)) != NULL) { - zval error; - object_init_ex(&error, php_uri_ce_whatwg_url_validation_error); - zend_update_property_string(php_uri_ce_whatwg_url_validation_error, Z_OBJ(error), ZEND_STRL("context"), (const char *) lxb_error->data); - - const char *error_str; - zval failure; - - ZVAL_BOOL(&failure, get_reason_from_error_type(lxb_error->id, &error_str)); - - zval error_type; - ZVAL_OBJ(&error_type, zend_enum_get_case_cstr(php_uri_ce_whatwg_url_validation_error_type, error_str)); - zend_update_property_ex(php_uri_ce_whatwg_url_validation_error, Z_OBJ(error), ZSTR_KNOWN(ZEND_STR_TYPE), &error_type); - - zend_update_property(php_uri_ce_whatwg_url_validation_error, Z_OBJ(error), ZEND_STRL("failure"), &failure); - - if (Z_TYPE(failure) == IS_TRUE) { - result = error_str; + const char *reason; + if (append_validation_error(errors, lxb_error->id, (const char *) lxb_error->data, &reason)) { + result = reason; } - - zend_hash_next_index_insert(errors, &error); } return result; @@ -735,22 +736,31 @@ static void php_uri_parser_whatwg_destroy(void *uri) lxb_url_destroy(lexbor_uri); } +ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_throw_exception(const char *message) +{ + zend_object *exception = zend_throw_exception(php_uri_ce_whatwg_invalid_url_exception, message, 0); + zval errors; + ZVAL_EMPTY_ARRAY(&errors); + zend_update_property(exception->ce, exception, ZEND_STRL("errors"), &errors); +} + ZEND_ATTRIBUTE_NONNULL static zend_always_inline zend_result php_uri_parser_whatwg_component_error( const char *component_name, const lxb_url_error_type_t error_type ) { - const char *reason = ""; + zval errors; + array_init(&errors); + + const char *reason = NULL; if (error_type != LXB_URL_ERROR_TYPE__LAST_ENTRY) { - get_reason_from_error_type(error_type, &reason); + append_validation_error(Z_ARRVAL(errors), error_type, "", &reason); } - zend_throw_exception_ex(php_uri_ce_whatwg_invalid_url_exception, - 0, - "The specified %s is malformed%s%s%s", - component_name, - reason ? " (" : "", - reason ? reason : "", - reason ? ")" : "" - ); + zend_object *exception = zend_throw_exception_ex(php_uri_ce_whatwg_invalid_url_exception, + 0, "The specified %s is malformed%s%s%s", component_name, + reason ? " (" : "", reason ? reason : "", reason ? ")" : ""); + + zend_update_property(exception->ce, exception, ZEND_STRL("errors"), &errors); + zval_ptr_dtor(&errors); return FAILURE; } @@ -984,17 +994,17 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh php_uri_parser_whatwg_get_special_scheme(Z_STR_P(scheme)) == LXB_URL_SCHEMEL_TYPE_FILE ) { if (Z_TYPE_P(username) != IS_NULL) { - zend_throw_exception_ex(php_uri_ce_whatwg_invalid_url_exception, 0, "The specified URL cannot have username"); + php_uri_parser_whatwg_throw_exception("The specified URL cannot have username"); return NULL; } if (Z_TYPE_P(password) != IS_NULL) { - zend_throw_exception_ex(php_uri_ce_whatwg_invalid_url_exception, 0, "The specified URL cannot have password"); + php_uri_parser_whatwg_throw_exception("The specified URL cannot have password"); return NULL; } if (Z_TYPE_P(port) != IS_NULL) { - zend_throw_exception_ex(php_uri_ce_whatwg_invalid_url_exception, 0, "The specified URL cannot have port"); + php_uri_parser_whatwg_throw_exception("The specified URL cannot have port"); return NULL; } } @@ -1003,7 +1013,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh lxb_url_t *lexbor_url = lexbor_mraw_calloc(lexbor_parser.mraw, sizeof(*lexbor_url)); if (lexbor_url == NULL) { - zend_throw_exception(php_uri_ce_whatwg_invalid_url_exception, "Memory allocation error", 0); + php_uri_parser_whatwg_throw_exception("Memory allocation error"); return NULL; } From 696b8c085012ffcbd2d9a2ba984a5901e72d9e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Tue, 8 Sep 2026 21:32:47 +0200 Subject: [PATCH 3/7] ext/uri: Return builder soft errors consistently Rename the output argument to softErrors and clear it on error-free success. Preserve it on failure while including earlier soft errors in the exception. This makes the behavior consistent with parsing in the constructor. --- ext/uri/php_uri.c | 6 +-- ext/uri/php_uri.stub.php | 4 +- ext/uri/php_uri_arginfo.h | 4 +- ext/uri/php_uri_decl.h | 8 +-- .../build_error_soft_errors_unchanged.phpt | 46 +++++++++++++++++ .../builder/build_success_soft_errors.phpt | 50 +++++++++++++++++++ .../build_success_soft_errors_reset.phpt | 44 ++++++++++++++++ .../fragment_success_ignorable_char.phpt | 6 +-- .../username_success_special_char.phpt | 4 +- .../builder/username_success_tab_newline.phpt | 6 +-- .../whatwg/parsing/basic_success_reset.phpt | 35 +++++++++++++ ext/uri/uri_parser_whatwg.c | 27 ++++++++-- ext/uri/uri_parser_whatwg.h | 2 +- 13 files changed, 218 insertions(+), 24 deletions(-) create mode 100644 ext/uri/tests/whatwg/builder/build_error_soft_errors_unchanged.phpt create mode 100644 ext/uri/tests/whatwg/builder/build_success_soft_errors.phpt create mode 100644 ext/uri/tests/whatwg/builder/build_success_soft_errors_reset.phpt create mode 100644 ext/uri/tests/whatwg/parsing/basic_success_reset.phpt diff --git a/ext/uri/php_uri.c b/ext/uri/php_uri.c index 457e123bb489..9d6e58dc181e 100644 --- a/ext/uri/php_uri.c +++ b/ext/uri/php_uri.c @@ -1404,12 +1404,12 @@ PHP_METHOD(Uri_WhatWg_UrlBuilder, setFragment) PHP_METHOD(Uri_WhatWg_UrlBuilder, build) { zval *base_url_zv = NULL; - zval *errors = NULL; + zval *soft_errors = NULL; ZEND_PARSE_PARAMETERS_START(0, 2) Z_PARAM_OPTIONAL Z_PARAM_OBJECT_OF_CLASS_OR_NULL(base_url_zv, php_uri_ce_whatwg_url) - Z_PARAM_ZVAL(errors) + Z_PARAM_ZVAL(soft_errors) ZEND_PARSE_PARAMETERS_END(); const zval *scheme = Z_WHATWG_URL_PROP_SCHEME_DEREF_P(ZEND_THIS); @@ -1430,7 +1430,7 @@ PHP_METHOD(Uri_WhatWg_UrlBuilder, build) lxb_url_t *lexbor_url = php_uri_parser_whatwg_build_from_zval( base_url, scheme, username, password, host, port, path, query, fragment, - errors + soft_errors ); if (lexbor_url == NULL) { RETURN_THROWS(); diff --git a/ext/uri/php_uri.stub.php b/ext/uri/php_uri.stub.php index ad1d2fe32dee..feca1871cea4 100644 --- a/ext/uri/php_uri.stub.php +++ b/ext/uri/php_uri.stub.php @@ -240,8 +240,8 @@ public function setQuery(?string $query): static {} public function setFragment(?string $fragment): static {} - /** @param array $errors */ - public function build(?\Uri\WhatWg\Url $baseUrl = null, &$errors = null): \Uri\WhatWg\Url {} + /** @param array $softErrors */ + public function build(?\Uri\WhatWg\Url $baseUrl = null, &$softErrors = null): \Uri\WhatWg\Url {} } /** @strict-properties */ diff --git a/ext/uri/php_uri_arginfo.h b/ext/uri/php_uri_arginfo.h index b9b4fedc10c6..8139d4fbf7be 100644 --- a/ext/uri/php_uri_arginfo.h +++ b/ext/uri/php_uri_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit php_uri.stub.php instead. - * Stub hash: 9e087e3aefdab5662892e7fad9de87857aa63057 + * Stub hash: 0dc5793b7dffdb81e477b6f590faf7d0360f3ea9 * Has decl header: yes */ #include "zend_attributes.h" @@ -175,7 +175,7 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Uri_WhatWg_UrlBuilder_build, 0, 0, Uri\\WhatWg\\\125rl, 0) ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, baseUrl, Uri\\WhatWg\\\125rl, 1, "null") - ZEND_ARG_INFO_WITH_DEFAULT_VALUE(1, errors, "null") + ZEND_ARG_INFO_WITH_DEFAULT_VALUE(1, softErrors, "null") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Uri_WhatWg_Url_parse, 0, 1, IS_STATIC, 1) diff --git a/ext/uri/php_uri_decl.h b/ext/uri/php_uri_decl.h index a55b44a95205..04d12097833d 100644 --- a/ext/uri/php_uri_decl.h +++ b/ext/uri/php_uri_decl.h @@ -1,8 +1,8 @@ /* This is a generated file, edit php_uri.stub.php instead. - * Stub hash: 9e087e3aefdab5662892e7fad9de87857aa63057 */ + * Stub hash: 0dc5793b7dffdb81e477b6f590faf7d0360f3ea9 */ -#ifndef ZEND_PHP_URI_DECL_9e087e3aefdab5662892e7fad9de87857aa63057_H -#define ZEND_PHP_URI_DECL_9e087e3aefdab5662892e7fad9de87857aa63057_H +#ifndef ZEND_PHP_URI_DECL_0dc5793b7dffdb81e477b6f590faf7d0360f3ea9_H +#define ZEND_PHP_URI_DECL_0dc5793b7dffdb81e477b6f590faf7d0360f3ea9_H typedef enum zend_enum_Uri_UriComparisonMode { ZEND_ENUM_Uri_UriComparisonMode_IncludeFragment = 1, @@ -76,4 +76,4 @@ typedef enum zend_enum_Uri_WhatWg_UrlPercentEncodingMode { ZEND_ENUM_Uri_WhatWg_UrlPercentEncodingMode_Fragment = 10, } zend_enum_Uri_WhatWg_UrlPercentEncodingMode; -#endif /* ZEND_PHP_URI_DECL_9e087e3aefdab5662892e7fad9de87857aa63057_H */ +#endif /* ZEND_PHP_URI_DECL_0dc5793b7dffdb81e477b6f590faf7d0360f3ea9_H */ diff --git a/ext/uri/tests/whatwg/builder/build_error_soft_errors_unchanged.phpt b/ext/uri/tests/whatwg/builder/build_error_soft_errors_unchanged.phpt new file mode 100644 index 000000000000..5cba13e51c17 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/build_error_soft_errors_unchanged.phpt @@ -0,0 +1,46 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - error - leaves soft errors unchanged +--FILE-- +setScheme("ht\ttps"); +$builder->setHost(null); +$softErrors = ["unchanged"]; + +try { + $builder->build(softErrors: $softErrors); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; + var_dump($e->errors); +} + +var_dump($softErrors); + +?> +--EXPECTF-- +Uri\WhatWg\InvalidUrlException: The specified host is malformed (HostMissing) +array(2) { + [0]=> + object(Uri\WhatWg\UrlValidationError)#%d (%d) { + ["context"]=> + string(4) " tps" + ["type"]=> + enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) + ["failure"]=> + bool(false) + } + [1]=> + object(Uri\WhatWg\UrlValidationError)#%d (%d) { + ["context"]=> + string(0) "" + ["type"]=> + enum(Uri\WhatWg\UrlValidationErrorType::HostMissing) + ["failure"]=> + bool(true) + } +} +array(1) { + [0]=> + string(9) "unchanged" +} diff --git a/ext/uri/tests/whatwg/builder/build_success_soft_errors.phpt b/ext/uri/tests/whatwg/builder/build_success_soft_errors.phpt new file mode 100644 index 000000000000..b2a860f03f5a --- /dev/null +++ b/ext/uri/tests/whatwg/builder/build_success_soft_errors.phpt @@ -0,0 +1,50 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - returns soft errors +--FILE-- +setScheme("https"); +$builder->setHost("example.com"); +$builder->setFragment("a\tb"); +$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(23) "https://example.com/#ab" +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"]=> + NULL + ["fragment"]=> + string(2) "ab" +} +array(1) { + [0]=> + object(Uri\WhatWg\UrlValidationError)#%d (%d) { + ["context"]=> + string(2) " b" + ["type"]=> + enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) + ["failure"]=> + bool(false) + } +} +bool(true) diff --git a/ext/uri/tests/whatwg/builder/build_success_soft_errors_reset.phpt b/ext/uri/tests/whatwg/builder/build_success_soft_errors_reset.phpt new file mode 100644 index 000000000000..4bfab1fd57e2 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/build_success_soft_errors_reset.phpt @@ -0,0 +1,44 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - success - clears soft errors from a previous build +--FILE-- +setScheme("https"); +$builder->setHost("example.com"); +$builder->setFragment("a\tb"); +$softErrors = []; +$builder->build(softErrors: $softErrors); + +$builder->setFragment("ab"); +$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(23) "https://example.com/#ab" +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"]=> + NULL + ["fragment"]=> + string(2) "ab" +} +array(0) { +} +bool(true) diff --git a/ext/uri/tests/whatwg/builder/fragment_success_ignorable_char.phpt b/ext/uri/tests/whatwg/builder/fragment_success_ignorable_char.phpt index f4940291f75d..8cbbee1f5dfa 100644 --- a/ext/uri/tests/whatwg/builder/fragment_success_ignorable_char.phpt +++ b/ext/uri/tests/whatwg/builder/fragment_success_ignorable_char.phpt @@ -7,12 +7,12 @@ $builder = new Uri\WhatWg\UrlBuilder(); $builder->setScheme("foo"); $builder->setHost("example.com"); $builder->setFragment("\tfo\no"); -$errors = []; -$url = $builder->build(errors: $errors); +$softErrors = []; +$url = $builder->build(softErrors: $softErrors); var_dump($url->toAsciiString()); var_dump($url); -var_dump($errors); +var_dump($softErrors); var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); ?> diff --git a/ext/uri/tests/whatwg/builder/username_success_special_char.phpt b/ext/uri/tests/whatwg/builder/username_success_special_char.phpt index 5e18a2c66cc4..76122e8be00b 100644 --- a/ext/uri/tests/whatwg/builder/username_success_special_char.phpt +++ b/ext/uri/tests/whatwg/builder/username_success_special_char.phpt @@ -7,8 +7,8 @@ $builder = new Uri\WhatWg\UrlBuilder(); $builder->setScheme("https"); $builder->setHost("example.com"); $builder->setUsername("~%#"); -$errors = []; -$url = $builder->build(null, $errors); +$softErrors = []; +$url = $builder->build(null, $softErrors); var_dump($url->toAsciiString()); var_dump($url); diff --git a/ext/uri/tests/whatwg/builder/username_success_tab_newline.phpt b/ext/uri/tests/whatwg/builder/username_success_tab_newline.phpt index 956bd1653ad8..9e920033ab7a 100644 --- a/ext/uri/tests/whatwg/builder/username_success_tab_newline.phpt +++ b/ext/uri/tests/whatwg/builder/username_success_tab_newline.phpt @@ -7,12 +7,12 @@ $builder = new Uri\WhatWg\UrlBuilder(); $builder->setScheme("\tfo\no"); $builder->setHost("example.com"); $builder->setUsername("f\no\ro\t"); -$errors = []; -$url = $builder->build(errors: $errors); +$softErrors = []; +$url = $builder->build(softErrors: $softErrors); var_dump($url->toAsciiString()); var_dump($url); -var_dump($errors); +var_dump($softErrors); var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); ?> diff --git a/ext/uri/tests/whatwg/parsing/basic_success_reset.phpt b/ext/uri/tests/whatwg/parsing/basic_success_reset.phpt new file mode 100644 index 000000000000..826442fac193 --- /dev/null +++ b/ext/uri/tests/whatwg/parsing/basic_success_reset.phpt @@ -0,0 +1,35 @@ +--TEST-- +Test Uri\WhatWg\Url::parse() - success - clears errors when there are no validation errors +--FILE-- +toAsciiString()); +var_dump($errors); + +?> +--EXPECTF-- +object(Uri\WhatWg\Url)#%d (%d) { + ["scheme"]=> + string(5) "https" + ["username"]=> + NULL + ["password"]=> + NULL + ["host"]=> + string(11) "example.org" + ["port"]=> + NULL + ["path"]=> + string(1) "/" + ["query"]=> + NULL + ["fragment"]=> + NULL +} +string(20) "https://example.org/" +array(0) { +} diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c index c07e8fae900c..f48f92053520 100644 --- a/ext/uri/uri_parser_whatwg.c +++ b/ext/uri/uri_parser_whatwg.c @@ -987,7 +987,7 @@ ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_build_errors(zval *erro 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, - zval *errors_zv + zval *soft_errors_zv ) { if (Z_TYPE_P(host) == IS_NULL || Z_STRLEN_P(host) == 0 || @@ -1028,7 +1028,7 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh } zval errors; - ZVAL_UNDEF(&errors); + array_init(&errors); zend_result result = php_uri_parser_whatwg_scheme_write(lexbor_url, scheme, NULL); php_uri_parser_whatwg_build_errors(&errors); @@ -1085,13 +1085,32 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh /* TODO */ } - if (php_uri_pass_errors_by_ref_and_free(errors_zv, &errors) == FAILURE) { - goto failure; + if (php_uri_pass_errors_by_ref_and_free(soft_errors_zv, &errors) == FAILURE) { + /* The errors zval was already consumed; goto failure would destroy it again. */ + lxb_url_destroy(lexbor_url); + return NULL; } return lexbor_url; failure: + /* Include errors from earlier components in the exception raised by a later component. */ + if (zend_hash_num_elements(Z_ARRVAL(errors)) > 0 && EG(exception) + && instanceof_function(EG(exception)->ce, php_uri_ce_whatwg_invalid_url_exception)) { + zval rv; + zval *exception_errors = zend_read_property(php_uri_ce_whatwg_invalid_url_exception, + EG(exception), ZEND_STRL("errors"), true, &rv); + ZEND_ASSERT(Z_TYPE_P(exception_errors) == IS_ARRAY); + + zval *error; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(exception_errors), error) { + Z_TRY_ADDREF_P(error); + zend_hash_next_index_insert(Z_ARRVAL(errors), error); + } ZEND_HASH_FOREACH_END(); + + zval_ptr_dtor(exception_errors); + ZVAL_COPY(exception_errors, &errors); + } zval_ptr_dtor(&errors); lxb_url_destroy(lexbor_url); return NULL; diff --git a/ext/uri/uri_parser_whatwg.h b/ext/uri/uri_parser_whatwg.h index ed19a1067a54..360e8a2cd567 100644 --- a/ext/uri/uri_parser_whatwg.h +++ b/ext/uri/uri_parser_whatwg.h @@ -33,7 +33,7 @@ ZEND_ATTRIBUTE_NONNULL zend_result php_uri_parser_whatwg_port_validate(zend_long 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, - zval *errors_zv + zval *soft_errors_zv ); ZEND_ATTRIBUTE_NONNULL zend_string *php_uri_parser_whatwg_userinfo_percent_encode(const char *str, size_t str_length); From d05da6770c3eb7e73fbf4147d86d3eb529803377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Tue, 8 Sep 2026 21:33:21 +0200 Subject: [PATCH 4/7] ext/uri: Validate credentials against normalized builder hosts Reject credentials and ports when a host becomes empty after normalization instead of silently discarding them. Perform validation once, after setting the host. --- ..._error_normalized_empty_host_password.phpt | 39 +++++++++++++++++++ ...uild_error_normalized_empty_host_port.phpt | 39 +++++++++++++++++++ ..._error_normalized_empty_host_username.phpt | 39 +++++++++++++++++++ .../builder/password_error_missing_host.phpt | 2 +- ext/uri/uri_parser_whatwg.c | 39 +++++++++---------- 5 files changed, 137 insertions(+), 21 deletions(-) create mode 100644 ext/uri/tests/whatwg/builder/build_error_normalized_empty_host_password.phpt create mode 100644 ext/uri/tests/whatwg/builder/build_error_normalized_empty_host_port.phpt create mode 100644 ext/uri/tests/whatwg/builder/build_error_normalized_empty_host_username.phpt diff --git a/ext/uri/tests/whatwg/builder/build_error_normalized_empty_host_password.phpt b/ext/uri/tests/whatwg/builder/build_error_normalized_empty_host_password.phpt new file mode 100644 index 000000000000..76a170f4e262 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/build_error_normalized_empty_host_password.phpt @@ -0,0 +1,39 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - error - password with a host that normalizes to empty +--FILE-- +setScheme("foo"); +$builder->setHost("\t\n"); +$builder->setPassword("pass"); +$softErrors = ["unchanged"]; + +try { + $builder->build(softErrors: $softErrors); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; + var_dump($e->errors); +} + +var_dump($softErrors); + +?> +--EXPECTF-- +Uri\WhatWg\InvalidUrlException: The specified URL cannot have password +array(1) { + [0]=> + object(Uri\WhatWg\UrlValidationError)#%d (%d) { + ["context"]=> + string(2) " +" + ["type"]=> + enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) + ["failure"]=> + bool(false) + } +} +array(1) { + [0]=> + string(9) "unchanged" +} diff --git a/ext/uri/tests/whatwg/builder/build_error_normalized_empty_host_port.phpt b/ext/uri/tests/whatwg/builder/build_error_normalized_empty_host_port.phpt new file mode 100644 index 000000000000..6b63a86d2ee4 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/build_error_normalized_empty_host_port.phpt @@ -0,0 +1,39 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - error - port with a host that normalizes to empty +--FILE-- +setScheme("foo"); +$builder->setHost("\t\n"); +$builder->setPort(123); +$softErrors = ["unchanged"]; + +try { + $builder->build(softErrors: $softErrors); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; + var_dump($e->errors); +} + +var_dump($softErrors); + +?> +--EXPECTF-- +Uri\WhatWg\InvalidUrlException: The specified URL cannot have port +array(1) { + [0]=> + object(Uri\WhatWg\UrlValidationError)#%d (%d) { + ["context"]=> + string(2) " +" + ["type"]=> + enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) + ["failure"]=> + bool(false) + } +} +array(1) { + [0]=> + string(9) "unchanged" +} diff --git a/ext/uri/tests/whatwg/builder/build_error_normalized_empty_host_username.phpt b/ext/uri/tests/whatwg/builder/build_error_normalized_empty_host_username.phpt new file mode 100644 index 000000000000..534271bd29e4 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/build_error_normalized_empty_host_username.phpt @@ -0,0 +1,39 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::build() - error - username with a host that normalizes to empty +--FILE-- +setScheme("foo"); +$builder->setHost("\t\n"); +$builder->setUsername("user"); +$softErrors = ["unchanged"]; + +try { + $builder->build(softErrors: $softErrors); +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; + var_dump($e->errors); +} + +var_dump($softErrors); + +?> +--EXPECTF-- +Uri\WhatWg\InvalidUrlException: The specified URL cannot have username +array(1) { + [0]=> + object(Uri\WhatWg\UrlValidationError)#%d (%d) { + ["context"]=> + string(2) " +" + ["type"]=> + enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) + ["failure"]=> + bool(false) + } +} +array(1) { + [0]=> + string(9) "unchanged" +} diff --git a/ext/uri/tests/whatwg/builder/password_error_missing_host.phpt b/ext/uri/tests/whatwg/builder/password_error_missing_host.phpt index 482612573e55..f83a3336494e 100644 --- a/ext/uri/tests/whatwg/builder/password_error_missing_host.phpt +++ b/ext/uri/tests/whatwg/builder/password_error_missing_host.phpt @@ -15,4 +15,4 @@ try { ?> --EXPECT-- -Uri\WhatWg\InvalidUrlException: The specified URL cannot have password +Uri\WhatWg\InvalidUrlException: The specified host is malformed (HostMissing) diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c index f48f92053520..b1bd2bcd8656 100644 --- a/ext/uri/uri_parser_whatwg.c +++ b/ext/uri/uri_parser_whatwg.c @@ -989,26 +989,6 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh const zval *host, const zval *port, const zval *path, const zval *query, const zval *fragment, zval *soft_errors_zv ) { - if (Z_TYPE_P(host) == IS_NULL || - Z_STRLEN_P(host) == 0 || - php_uri_parser_whatwg_get_special_scheme(Z_STR_P(scheme)) == LXB_URL_SCHEMEL_TYPE_FILE - ) { - if (Z_TYPE_P(username) != IS_NULL) { - php_uri_parser_whatwg_throw_exception("The specified URL cannot have username"); - return NULL; - } - - if (Z_TYPE_P(password) != IS_NULL) { - php_uri_parser_whatwg_throw_exception("The specified URL cannot have password"); - return NULL; - } - - if (Z_TYPE_P(port) != IS_NULL) { - php_uri_parser_whatwg_throw_exception("The specified URL cannot have port"); - return NULL; - } - } - lxb_url_parser_clean(&lexbor_parser); lxb_url_t *lexbor_url = lexbor_mraw_calloc(lexbor_parser.mraw, sizeof(*lexbor_url)); @@ -1042,6 +1022,25 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh goto failure; } + 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) { + php_uri_parser_whatwg_throw_exception("The specified URL cannot have username"); + goto failure; + } + + if (Z_TYPE_P(password) != IS_NULL) { + php_uri_parser_whatwg_throw_exception("The specified URL cannot have password"); + goto failure; + } + + if (Z_TYPE_P(port) != IS_NULL) { + php_uri_parser_whatwg_throw_exception("The specified URL cannot have port"); + goto failure; + } + } + /* Intentionally writing username after host to avoid error when the username is set but the host is missing */ result = php_uri_parser_whatwg_username_write(lexbor_url, username, NULL); php_uri_parser_whatwg_build_errors(&errors); From 1f0cbfb419db24162a0ac82fb9312ae16685ca54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Tue, 8 Sep 2026 21:33:21 +0200 Subject: [PATCH 5/7] ext/uri: Preserve empty query and fragment in URL builders Keep empty components distinct from null so serialization retains the trailing question mark or hashmark. --- .../fragment_success_empty_string.phpt | 38 +++++++++++++++++++ .../builder/query_success_empty_string.phpt | 38 +++++++++++++++++++ ext/uri/uri_parser_whatwg.c | 18 ++++++++- 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 ext/uri/tests/whatwg/builder/fragment_success_empty_string.phpt create mode 100644 ext/uri/tests/whatwg/builder/query_success_empty_string.phpt diff --git a/ext/uri/tests/whatwg/builder/fragment_success_empty_string.phpt b/ext/uri/tests/whatwg/builder/fragment_success_empty_string.phpt new file mode 100644 index 000000000000..2fe13ffd7020 --- /dev/null +++ b/ext/uri/tests/whatwg/builder/fragment_success_empty_string.phpt @@ -0,0 +1,38 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::setFragment() - success - empty string +--FILE-- +setScheme("https"); +$builder->setHost("example.com"); +$builder->setFragment("foo"); +$builder->setFragment(""); +$url = $builder->build(); + +var_dump($url->toAsciiString()); +var_dump($url); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECTF-- +string(21) "https://example.com/#" +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"]=> + NULL + ["fragment"]=> + string(0) "" +} +bool(true) diff --git a/ext/uri/tests/whatwg/builder/query_success_empty_string.phpt b/ext/uri/tests/whatwg/builder/query_success_empty_string.phpt new file mode 100644 index 000000000000..01cdb62a11cb --- /dev/null +++ b/ext/uri/tests/whatwg/builder/query_success_empty_string.phpt @@ -0,0 +1,38 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::setQuery() - success - empty string +--FILE-- +setScheme("https"); +$builder->setHost("example.com"); +$builder->setQuery("foo"); +$builder->setQuery(""); +$url = $builder->build(); + +var_dump($url->toAsciiString()); +var_dump($url); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECTF-- +string(21) "https://example.com/?" +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(0) "" + ["fragment"]=> + NULL +} +bool(true) diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c index b1bd2bcd8656..0e5d9e8a66f5 100644 --- a/ext/uri/uri_parser_whatwg.c +++ b/ext/uri/uri_parser_whatwg.c @@ -1068,13 +1068,27 @@ 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_query_write(lexbor_url, query, NULL); + if (Z_TYPE_P(query) == IS_STRING && Z_STRLEN_P(query) == 0) { + /* The URL API setter treats an empty string as removal. The builder + * distinguishes an empty component from an absent one. */ + lexbor_str_destroy(&lexbor_url->query, lexbor_url->mraw, false); + lexbor_str_init(&lexbor_url->query, lexbor_url->mraw, 1); + } else { + result = php_uri_parser_whatwg_query_write(lexbor_url, query, NULL); + } php_uri_parser_whatwg_build_errors(&errors); if (result == FAILURE) { goto failure; } - result = php_uri_parser_whatwg_fragment_write(lexbor_url, fragment, NULL); + if (Z_TYPE_P(fragment) == IS_STRING && Z_STRLEN_P(fragment) == 0) { + /* The URL API setter treats an empty string as removal. The builder + * distinguishes an empty component from an absent one. */ + lexbor_str_destroy(&lexbor_url->fragment, lexbor_url->mraw, false); + lexbor_str_init(&lexbor_url->fragment, lexbor_url->mraw, 1); + } else { + result = php_uri_parser_whatwg_fragment_write(lexbor_url, fragment, NULL); + } php_uri_parser_whatwg_build_errors(&errors); if (result == FAILURE) { goto failure; From e6bcb9aeb841b9977d92fec14f6a1d132c3acff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Tue, 8 Sep 2026 21:33:21 +0200 Subject: [PATCH 6/7] ext/lexbor: Encode opaque path spaces before delimiters Percent-encode only the space immediately preceding a query or fragment delimiter, retaining validation errors for every parsed space. --- ext/lexbor/lexbor/url/url.c | 11 ++++ ...th_success_opaque_spaces_before_query.phpt | 54 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 ext/uri/tests/whatwg/parsing/path_success_opaque_spaces_before_query.phpt diff --git a/ext/lexbor/lexbor/url/url.c b/ext/lexbor/lexbor/url/url.c index 69d91969a6a1..ff2b13fbb859 100644 --- a/ext/lexbor/lexbor/url/url.c +++ b/ext/lexbor/lexbor/url/url.c @@ -2329,6 +2329,17 @@ lxb_url_parse_basic_h(lxb_url_parser_t *parser, lxb_url_t *url, lxb_url_parse_return(orig_data, buf, status); } + /* Encode only the space immediately before a query or fragment. */ + if (p > begin && p[-1] == ' ') { + tmp_str.length--; + if (lexbor_str_append(&tmp_str, url->mraw, + (const lxb_char_t *) "%20", 3) == NULL) + { + lxb_url_parse_return(orig_data, buf, + LXB_STATUS_ERROR_MEMORY_ALLOCATION); + } + } + status = lxb_url_path_list_push(url, &tmp_str); if (status != LXB_STATUS_OK) { lxb_url_parse_return(orig_data, buf, status); diff --git a/ext/uri/tests/whatwg/parsing/path_success_opaque_spaces_before_query.phpt b/ext/uri/tests/whatwg/parsing/path_success_opaque_spaces_before_query.phpt new file mode 100644 index 000000000000..d93fab4d1cf5 --- /dev/null +++ b/ext/uri/tests/whatwg/parsing/path_success_opaque_spaces_before_query.phpt @@ -0,0 +1,54 @@ +--TEST-- +Test Uri\WhatWg\Url parsing - opaque path - spaces before a query +--FILE-- +toAsciiString()); +var_dump($url); +var_dump($softErrors); +var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); + +?> +--EXPECTF-- +string(13) "foo:abc %20?q" +object(Uri\WhatWg\Url)#%d (%d) { + ["scheme"]=> + string(3) "foo" + ["username"]=> + NULL + ["password"]=> + NULL + ["host"]=> + NULL + ["port"]=> + NULL + ["path"]=> + string(7) "abc %20" + ["query"]=> + string(1) "q" + ["fragment"]=> + NULL +} +array(2) { + [0]=> + object(Uri\WhatWg\UrlValidationError)#%d (%d) { + ["context"]=> + string(3) " ?q" + ["type"]=> + enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) + ["failure"]=> + bool(false) + } + [1]=> + object(Uri\WhatWg\UrlValidationError)#%d (%d) { + ["context"]=> + string(4) " ?q" + ["type"]=> + enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit) + ["failure"]=> + bool(false) + } +} +bool(true) From fbd7eec088c94ac85cb0d0c550e8b9e8d5fcc7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Kocsis?= Date: Tue, 8 Sep 2026 21:33:21 +0200 Subject: [PATCH 7/7] ext/uri: Build hostless URLs with the correct path type Preserve absent hosts for non-special URLs and initialize opaque paths directly. Protect path delimiters, report space errors and encode the space immediately before a query or fragment. --- .../builder/host_success_opaque_null.phpt | 4 +- .../path_success_hierarchical_delimiters.phpt | 37 ++++++++++ .../path_success_opaque_delimiters.phpt | 36 ++++++++++ .../builder/path_success_opaque_spaces.phpt | 67 +++++++++++++++++++ ...h_success_opaque_spaces_with_fragment.phpt | 59 ++++++++++++++++ ...path_success_opaque_spaces_with_query.phpt | 59 ++++++++++++++++ .../builder/path_success_special_char.phpt | 6 +- .../builder/query_success_hashmark.phpt | 37 ++++++++++ .../builder/scheme_success_non_special.phpt | 4 +- .../builder/scheme_success_special.phpt | 4 +- ext/uri/uri_parser_whatwg.c | 64 ++++++++++++++++-- 11 files changed, 363 insertions(+), 14 deletions(-) create mode 100644 ext/uri/tests/whatwg/builder/path_success_hierarchical_delimiters.phpt create mode 100644 ext/uri/tests/whatwg/builder/path_success_opaque_delimiters.phpt create mode 100644 ext/uri/tests/whatwg/builder/path_success_opaque_spaces.phpt create mode 100644 ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_fragment.phpt create mode 100644 ext/uri/tests/whatwg/builder/path_success_opaque_spaces_with_query.phpt create mode 100644 ext/uri/tests/whatwg/builder/query_success_hashmark.phpt 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 9921721af17c..052584e91cca 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 @@ var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); ?> --EXPECTF-- -string(9) "scheme://" +string(7) "scheme:" object(Uri\WhatWg\Url)#%d (%d) { ["scheme"]=> string(6) "scheme" @@ -24,7 +24,7 @@ object(Uri\WhatWg\Url)#%d (%d) { ["password"]=> NULL ["host"]=> - string(0) "" + NULL ["port"]=> NULL ["path"]=> 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 000000000000..f33a3c26ee95 --- /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-- +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_delimiters.phpt b/ext/uri/tests/whatwg/builder/path_success_opaque_delimiters.phpt new file mode 100644 index 000000000000..19286b8daf4d --- /dev/null +++ b/ext/uri/tests/whatwg/builder/path_success_opaque_delimiters.phpt @@ -0,0 +1,36 @@ +--TEST-- +Test Uri\WhatWg\UrlBuilder::setPath() - success - question mark and hashmark in an opaque path +--FILE-- +setScheme("foo"); +$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(13) "foo:a%3Fb%23c" +object(Uri\WhatWg\Url)#%d (%d) { + ["scheme"]=> + string(3) "foo" + ["username"]=> + NULL + ["password"]=> + NULL + ["host"]=> + NULL + ["port"]=> + NULL + ["path"]=> + string(9) "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 000000000000..c66d10f29f50 --- /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-- +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 000000000000..b6d72abded4c --- /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-- +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(13) "foo:abc %20#f" +object(Uri\WhatWg\Url)#%d (%d) { + ["scheme"]=> + string(3) "foo" + ["username"]=> + NULL + ["password"]=> + NULL + ["host"]=> + NULL + ["port"]=> + NULL + ["path"]=> + string(7) "abc %20" + ["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 000000000000..d719feafc3d8 --- /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-- +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(13) "foo:abc %20?q" +object(Uri\WhatWg\Url)#%d (%d) { + ["scheme"]=> + string(3) "foo" + ["username"]=> + NULL + ["password"]=> + NULL + ["host"]=> + NULL + ["port"]=> + NULL + ["path"]=> + string(7) "abc %20" + ["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 b47a44b1e133..53956250242a 100644 --- a/ext/uri/tests/whatwg/builder/path_success_special_char.phpt +++ b/ext/uri/tests/whatwg/builder/path_success_special_char.phpt @@ -15,7 +15,7 @@ var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); ?> --EXPECTF-- -string(16) "scheme:///%23foo" +string(13) "scheme:%23foo" object(Uri\WhatWg\Url)#%d (%d) { ["scheme"]=> string(6) "scheme" @@ -24,11 +24,11 @@ object(Uri\WhatWg\Url)#%d (%d) { ["password"]=> NULL ["host"]=> - string(0) "" + NULL ["port"]=> NULL ["path"]=> - string(7) "/%23foo" + string(6) "%23foo" ["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 000000000000..3c56e6a131f8 --- /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-- +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 c805105ee992..26593beda922 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 @@ var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); ?> --EXPECTF-- -string(6) "foo://" +string(4) "foo:" object(Uri\WhatWg\Url)#%d (%d) { ["scheme"]=> string(3) "foo" @@ -23,7 +23,7 @@ object(Uri\WhatWg\Url)#%d (%d) { ["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 60a175a5f207..39fb4b71c133 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 @@ var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString()))); ?> --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 @@ object(Uri\WhatWg\Url)#%d (%d) { ["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 0e5d9e8a66f5..814e370c0628 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. Let's parse it directly, + * protecting component delimiters from reinterpretation. */ + smart_str opaque_path = {0}; + for (const char *p = Z_STRVAL_P(path); p < path_end; p++) { + if (*p == '?') { + smart_str_appends(&opaque_path, "%3F"); + } else if (*p == '#') { + smart_str_appends(&opaque_path, "%23"); + } else { + smart_str_appendc(&opaque_path, *p); + } + } + + /* 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;