Skip to content

Commit 0fced49

Browse files
committed
I18N: Harden against undefined index in _load_script_textdomain_from_src().
This guards against an undefined index warning being raised when a script or script module is registered with a URL that lacks a path component. This also adds full PHPStan type definitions for `wp_parse_url()`, ensuring that the `_load_script_textdomain_from_src()` function has no PHPStan errors at rule level 10. Developed in #11690 Follow-up to r62278. Props westonruter, manzoorwanijk, mukesh27. See #65015, #64238. git-svn-id: https://develop.svn.wordpress.org/trunk@62293 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 9e0b63d commit 0fced49

3 files changed

Lines changed: 166 additions & 3 deletions

File tree

src/wp-includes/http.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,26 @@ function ms_allowed_http_request_hosts( $is_external, $host ) {
716716
* When a specific component has been requested: null if the component
717717
* doesn't exist in the given URL; a string or - in the case of
718718
* PHP_URL_PORT - integer when it does. See parse_url()'s return values.
719+
*
720+
* @phpstan-param int<-1, 7> $component
721+
* @phpstan-return (
722+
* $component is -1
723+
* ? false|array{
724+
* scheme?: string,
725+
* host?: string,
726+
* port?: int<0, 65535>,
727+
* user?: string,
728+
* pass?: string,
729+
* path?: string,
730+
* query?: string,
731+
* fragment?: string,
732+
* }
733+
* : (
734+
* $component is 2
735+
* ? int<0, 65535>|null
736+
* : string|null
737+
* )
738+
* )
719739
*/
720740
function wp_parse_url( $url, $component = -1 ) {
721741
$to_unset = array();
@@ -763,6 +783,36 @@ function wp_parse_url( $url, $component = -1 ) {
763783
* When a specific component has been requested: null if the component
764784
* doesn't exist in the given URL; a string or - in the case of
765785
* PHP_URL_PORT - integer when it does. See parse_url()'s return values.
786+
*
787+
* @phpstan-param false|array{
788+
* scheme?: string,
789+
* host?: string,
790+
* port?: int<0, 65535>,
791+
* user?: string,
792+
* pass?: string,
793+
* path?: string,
794+
* query?: string,
795+
* fragment?: string,
796+
* } $url_parts
797+
* @phpstan-param int<-1, 7> $component
798+
* @phpstan-return (
799+
* $component is -1
800+
* ? false|array{
801+
* scheme?: string,
802+
* host?: string,
803+
* port?: int<0, 65535>,
804+
* user?: string,
805+
* pass?: string,
806+
* path?: string,
807+
* query?: string,
808+
* fragment?: string,
809+
* }
810+
* : (
811+
* $component is 2
812+
* ? int<0, 65535>|null
813+
* : string|null
814+
* )
815+
* )
766816
*/
767817
function _get_component_from_parsed_url_array( $url_parts, $component = -1 ) {
768818
if ( -1 === $component ) {
@@ -789,6 +839,9 @@ function _get_component_from_parsed_url_array( $url_parts, $component = -1 ) {
789839
*
790840
* @param int $constant PHP_URL_* constant.
791841
* @return string|false The named key or false.
842+
*
843+
* @phpstan-param int<-1, 7> $constant
844+
* @phpstan-return 'scheme'|'host'|'port'|'user'|'pass'|'path'|'query'|'fragment'|false
792845
*/
793846
function _wp_translate_php_url_constant_to_key( $constant ) {
794847
$translation = array(

src/wp-includes/l10n.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,6 +1206,7 @@ function load_script_module_textdomain( string $id, string $domain = 'default',
12061206
* @return string|false The JSON-encoded translated strings on success, false otherwise.
12071207
*/
12081208
function _load_script_textdomain_from_src( string $handle, string $src, string $domain, string $path, bool $is_module ) {
1209+
/** @var WP_Textdomain_Registry $wp_textdomain_registry */
12091210
global $wp_textdomain_registry;
12101211

12111212
$locale = determine_locale();
@@ -1214,7 +1215,9 @@ function _load_script_textdomain_from_src( string $handle, string $src, string $
12141215
$path = $wp_textdomain_registry->get( $domain, $locale );
12151216
}
12161217

1217-
$path = untrailingslashit( $path );
1218+
if ( $path ) {
1219+
$path = untrailingslashit( $path );
1220+
}
12181221

12191222
// If a path was given and the handle file exists simply return it.
12201223
$file_base = 'default' === $domain ? $locale : $domain . '-' . $locale;
@@ -1231,8 +1234,17 @@ function _load_script_textdomain_from_src( string $handle, string $src, string $
12311234
$relative = false;
12321235
$languages_path = WP_LANG_DIR;
12331236

1234-
$src_url = wp_parse_url( $src );
1237+
$src_url = wp_parse_url( $src );
1238+
if ( ! $src_url ) {
1239+
return load_script_translations( false, $handle, $domain );
1240+
}
1241+
$src_url['path'] ??= '';
1242+
12351243
$content_url = wp_parse_url( content_url() );
1244+
if ( ! $content_url ) {
1245+
return load_script_translations( false, $handle, $domain );
1246+
}
1247+
12361248
$plugins_url = wp_parse_url( plugins_url() );
12371249
$site_url = wp_parse_url( site_url() );
12381250
$theme_root = get_theme_root();
@@ -1304,7 +1316,7 @@ function _load_script_textdomain_from_src( string $handle, string $src, string $
13041316
$relative = apply_filters( 'load_script_textdomain_relative_path', $relative, $src, $is_module );
13051317

13061318
// If the source is not from WP.
1307-
if ( false === $relative ) {
1319+
if ( ! is_string( $relative ) ) {
13081320
return load_script_translations( false, $handle, $domain );
13091321
}
13101322

tests/phpunit/tests/l10n/loadScriptTextdomain.php

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,102 @@ public function test_does_not_throw_deprecation_notice_for_rtrim_with_default_pa
172172
$expected = file_get_contents( DIR_TESTDATA . '/languages/en_US-813e104eb47e13dd4cc5af844c618754.json' );
173173
$this->assertSame( $expected, load_script_textdomain( $handle ) );
174174
}
175+
176+
/**
177+
* Tests that an unparseable script source URL short-circuits to
178+
* `load_script_translations( false, ... )` instead of falling through
179+
* to the relative-path computation.
180+
*
181+
* @ticket 65015
182+
*/
183+
public function test_unparseable_src_returns_false(): void {
184+
$handle = 'test-unparseable-src';
185+
$src = 'http:///example';
186+
187+
$this->assertFalse( wp_parse_url( $src ), 'Test prerequisite failed: the test src should be unparseable.' );
188+
189+
wp_enqueue_script( $handle, $src, array(), null );
190+
191+
$this->assertFalse( load_script_textdomain( $handle, 'default', DIR_TESTDATA . '/languages' ) );
192+
}
193+
194+
/**
195+
* Tests that an unparseable `content_url()` return value short-circuits
196+
* to `load_script_translations( false, ... )` instead of computing
197+
* `$relative` from a corrupted parsed-URL array.
198+
*
199+
* The `MockAction` spy on `pre_load_script_translations` is necessary
200+
* here because the function's tail end also calls `load_script_translations( false, ... )`,
201+
* so a regression that bypasses the early return would still return false
202+
* via the fallback path. Asserting on the recorded `$file` arguments pins
203+
* the test to the intended branch.
204+
*
205+
* @ticket 65015
206+
*/
207+
public function test_unparseable_content_url_returns_false(): void {
208+
$handle = 'test-unparseable-content-url';
209+
$src = '/wp-includes/js/script.js';
210+
211+
add_filter(
212+
'content_url',
213+
static function () {
214+
return 'http:///example';
215+
}
216+
);
217+
218+
$mock = new MockAction();
219+
add_filter( 'pre_load_script_translations', array( $mock, 'filter' ), 10, 4 );
220+
221+
wp_enqueue_script( $handle, $src, array(), null );
222+
223+
$this->assertFalse( load_script_textdomain( $handle, 'default', DIR_TESTDATA . '/languages' ) );
224+
$this->assertSame(
225+
array(
226+
DIR_TESTDATA . '/languages/en_US-' . $handle . '.json',
227+
false,
228+
),
229+
array_column( $mock->get_args(), 1 ),
230+
'Expected the unparseable content_url branch to short-circuit before any relative-path lookup.'
231+
);
232+
}
233+
234+
/**
235+
* Tests that the `load_script_textdomain_relative_path` filter returning
236+
* a non-string, non-false value (e.g., a callback that forgets to return)
237+
* short-circuits via the `! is_string( $relative )` guard rather than
238+
* falling through to string functions like `str_ends_with()` and `md5()`.
239+
*
240+
* @ticket 65015
241+
*/
242+
public function test_non_string_relative_path_filter_returns_false(): void {
243+
$handle = 'test-non-string-relative-path';
244+
$src = '/wp-includes/js/script.js';
245+
246+
add_filter( 'load_script_textdomain_relative_path', '__return_null' );
247+
248+
wp_enqueue_script( $handle, $src, array(), null );
249+
250+
$this->assertFalse( load_script_textdomain( $handle, 'default', DIR_TESTDATA . '/languages' ) );
251+
}
252+
253+
/**
254+
* Tests that a script source URL with no path component does not trigger
255+
* an undefined index warning when the path is read further down in the
256+
* function. The result is reached via the regular fallback path
257+
* (no host/path match) rather than an early return.
258+
*
259+
* @ticket 65015
260+
*/
261+
public function test_src_without_path_component_does_not_warn(): void {
262+
$handle = 'test-src-without-path';
263+
$src = 'https://example.com';
264+
265+
$parsed = wp_parse_url( $src );
266+
$this->assertIsArray( $parsed, 'Test prerequisite failed: the test src should parse.' );
267+
$this->assertArrayNotHasKey( 'path', $parsed, 'Test prerequisite failed: the test src should have no path component.' );
268+
269+
wp_enqueue_script( $handle, $src, array(), null );
270+
271+
$this->assertFalse( load_script_textdomain( $handle, 'default', DIR_TESTDATA . '/languages' ) );
272+
}
175273
}

0 commit comments

Comments
 (0)