diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 22a1d3e307d3e..f4754488d4de5 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -473,7 +473,7 @@ function wp_get_tooltip_helper( $content, $args = array() ) { $defaults = array( 'id' => wp_unique_id( 'wp-tooltip-' ), - 'button' => '', + 'button' => '', 'label' => __( 'Help' ), 'close_label' => __( 'Close' ), 'icon' => 'dashicons-editor-help', @@ -481,6 +481,9 @@ function wp_get_tooltip_helper( $content, $args = array() ) { 'type' => 'tooltip', ); + // Captured before `wp_parse_args()` merges in the default button markup below. + $is_default_button = empty( $args['button'] ); + $args = wp_parse_args( $args, $defaults ); $classes = ( 'tooltip' === $args['type'] ) ? 'wp-tooltip wp-is-tooltip' : 'wp-tooltip wp-is-toggletip'; @@ -492,11 +495,22 @@ function wp_get_tooltip_helper( $content, $args = array() ) { $id = ( $args['id'] ) ? $args['id'] : $defaults['id']; $button = ( $args['button'] ) ? $args['button'] : $defaults['button']; $processed = false; + + /* + * Placeholder tokens, rather than `sprintf()` conversion specifications, + * are used so the popover target, label, and icon can be filled in with + * `str_replace()` below. The button markup is later passed to `sprintf()` + * as an argument, not concatenated into the format string, so any literal + * `%` characters in caller-supplied markup (e.g. a percent-encoded URL) + * are never parsed as a conversion specification. See #65914. + */ + $popovertarget_token = '{{wp-tooltip-popovertarget}}'; + $processor = new WP_HTML_Tag_Processor( $button ); if ( true === $processor->next_tag( 'button' ) ) { $processor->add_class( 'wp-tooltip__toggle' ); if ( 'tooltip' !== $args['type'] ) { - $processor->set_attribute( 'popovertarget', '%2$s' ); + $processor->set_attribute( 'popovertarget', $popovertarget_token ); $processor->set_attribute( 'aria-haspopup', 'dialog' ); } $button = $processor->get_updated_html(); @@ -512,15 +526,34 @@ function wp_get_tooltip_helper( $content, $args = array() ) { } if ( ! $processed ) { // Button HTML passed was not valid. - $processor = new WP_HTML_Tag_Processor( $defaults['button'] ); + $is_default_button = true; + $processor = new WP_HTML_Tag_Processor( $defaults['button'] ); $processor->add_class( 'wp-tooltip__toggle' ); if ( 'tooltip' !== $args['type'] ) { - $processor->set_attribute( 'popovertarget', '%2$s' ); + $processor->set_attribute( 'popovertarget', $popovertarget_token ); $processor->set_attribute( 'aria-haspopup', 'dialog' ); } $button = $processor->get_updated_html(); } + if ( false !== strpos( $button, $popovertarget_token ) ) { + $button = str_replace( $popovertarget_token, esc_attr( $id ), $button ); + } + + /* + * Only the default, core-generated button template contains the label + * and icon tokens. Caller-supplied button markup is never scanned for + * these tokens, so literal matching text in custom markup is left as-is. + */ + if ( $is_default_button ) { + $button_label = ( 'tooltip' === $args['type'] ) ? wp_strip_all_tags( $content, true ) : $args['label']; + $button = str_replace( + array( '{{wp-tooltip-label}}', '{{wp-tooltip-icon}}' ), + array( esc_attr( $button_label ), esc_attr( $icon ) ), + $button + ); + } + /* * The markup only uses phrasing content so it is valid when nested * in a phrasing context. Sectioning content (e.g. `div`, `dialog`) will @@ -529,19 +562,17 @@ function wp_get_tooltip_helper( $content, $args = array() ) { */ if ( 'tooltip' === $args['type'] ) { // Tooltips are only used to visually display labels. - $label = wp_strip_all_tags( $content, true ); $markup = sprintf( ' - ' . $button . ' + %4$s ' . - '%5$s' . + '%3$s' . '' . '', esc_attr( $classes ), esc_attr( $id ), - esc_attr( $label ), - esc_attr( $icon ), esc_html( $content ), + $button, ); } else { /* @@ -551,10 +582,10 @@ function wp_get_tooltip_helper( $content, $args = array() ) { */ $markup = sprintf( ' - ' . $button . ' + %6$s ' . - '%5$s' . - '' . '' . @@ -562,9 +593,9 @@ function wp_get_tooltip_helper( $content, $args = array() ) { esc_attr( $classes ), esc_attr( $id ), esc_attr( $args['label'] ), - esc_attr( $icon ), esc_html( $content ), esc_attr( $args['close_label'] ), + $button, ); } diff --git a/tests/phpunit/tests/general/wpGetTooltip.php b/tests/phpunit/tests/general/wpGetTooltip.php index e545902e6ed6d..7f51e054b209a 100644 --- a/tests/phpunit/tests/general/wpGetTooltip.php +++ b/tests/phpunit/tests/general/wpGetTooltip.php @@ -206,6 +206,85 @@ public function test_wp_get_toggletip_bubble_uses_dialog_role_and_autofocus() { $this->assertStringContainsString( 'tabindex="-1" autofocus>', $html ); } + /** + * Tests that a percent sign in custom button markup is not parsed as a + * `sprintf()` conversion specification. + * + * @ticket 65914 + */ + public function test_wp_get_tooltip_does_not_treat_percent_encoded_url_as_format_string() { + $html = wp_get_tooltip( + 'Helpful text.', + array( 'button' => 'Search' ) + ); + + $this->assertStringContainsString( 'href="/wp-admin/edit.php?s=hello%20world"', $html ); + } + + /** + * Tests that literal text containing a percent sign is preserved verbatim. + * + * @ticket 65914 + */ + public function test_wp_get_tooltip_preserves_percent_sign_in_button_text() { + $html = wp_get_tooltip( + 'Helpful text.', + array( 'button' => '' ) + ); + + $this->assertStringContainsString( '100% done', $html ); + } + + /** + * Tests that a percent-encoded path is not silently rewritten. + * + * @ticket 65914 + */ + public function test_wp_get_tooltip_does_not_rewrite_percent_encoded_path() { + $html = wp_get_tooltip( + 'Helpful text.', + array( 'button' => 'Link' ) + ); + + $this->assertStringContainsString( 'href="/x?p=a%2Fb"', $html ); + } + + /** + * Tests that a meta box style ID containing a percent sign, as used by + * `do_meta_boxes()`, does not cause a fatal error. + * + * @ticket 65914 + */ + public function test_wp_get_tooltip_does_not_fatal_on_percent_in_id_attribute() { + $html = wp_get_tooltip( + 'Helpful text.', + array( 'button' => '' ) + ); + + $this->assertStringContainsString( 'id="sale_100%_off"', $html ); + } + + /** + * Tests that the default, core-generated button still receives its label, + * icon, and popovertarget correctly after the button markup is no longer + * concatenated into the `sprintf()` format string. + * + * @ticket 65914 + */ + public function test_wp_get_toggletip_default_button_still_receives_label_and_icon() { + $html = wp_get_toggletip( + 'Some help content here.', + array( + 'label' => 'Info', + 'icon' => 'dashicons-info', + ) + ); + + $this->assertStringContainsString( 'aria-label="Info"', $html ); + $this->assertStringContainsString( 'class="dashicons dashicons-info"', $html ); + $this->assertStringNotContainsString( '{{', $html, 'Placeholder tokens must not leak into output.' ); + } + /** * Data provider. *