Make WordPress Core

Opened 26 hours ago

Last modified 4 hours ago

#66110 reviewing defect (bug)

Block Hooks run during wp_trim_excerpt(), leaking hooked-block markup into excerpts

Reported by: MythThrazz Owned by: bernhard-reiter
Priority: normal Milestone: 7.2
Component: Formatting Version: 6.8
Severity: normal Keywords: has-patch has-unit-tests
Cc: Focuses:

Description (last modified by MythThrazz)

When a post has no manual excerpt, wp_trim_excerpt() generates one from post content. Since [59523] (#61074), the_content carries a Block Hooks callback at priority 8, apply_block_hooks_to_content originally, replaced by apply_block_hooks_to_content_from_post_object in [59838] (#61074, #62716), both first released in 6.8.0:

// src/wp-includes/default-filters.php:201
add_filter( 'the_content', 'apply_block_hooks_to_content_from_post_object', 8 ); // BEFORE do_blocks().

wp_trim_excerpt() already unhooks do_blocks (priority 9, [56560] / #58682) and wp_filter_content_tags (priority 12, [55850] / #56588) around its inner apply_filters( 'the_content' ) call, but the priority-8 Block Hooks callback was never unhooked, so the Block Hooks algorithm still runs during excerpt generation.

Why this is a bug

  1. Hooked-block markup leaks into excerpts as plain text. apply_block_hooks_to_content_from_post_object() wraps delimiter-free content in a temporary core/freeform block and then in a core/post-content wrapper block (blocks.php:1295-1336) specifically so that first_child/last_child hooked blocks still have an anchor. The algorithm therefore runs and serializes hooked blocks into the content. They can never render, because do_blocks is unhooked, and when wp_trim_words() runs wp_strip_all_tags() on the result (formatting.php:4126), the <!-- --> block delimiters and HTML tags are stripped, so any markup a hooked block carries (e.g. via the public hooked_block filter, blocks.php:1049/1120) leaks into the excerpt as plain text.

Measured: a block hooked to core/post-content as last_child, given innerHTML of <p>LEAKED HOOKED TEXT</p> via the hooked_block filter, turns the excerpt of a post whose content is <p>Visible text.</p> from 'Visible text.' into 'Visible text. LEAKED HOOKED TEXT'.

  1. The work is guaranteed waste. Even when nothing leaks, the run can never have a visible effect, because do_blocks is unhooked, so inserted hooked blocks are discarded. Every excerpt generation pays a parse_blocks() traversal, get_hooked_blocks(), the hooked_block_types/hooked_block filters, and a _wp_ignored_hooked_blocks post-meta read (blocks.php:1310) for nothing.
  1. Wrong context. The callback resolves its post context via get_post() when none is passed (blocks.php:1280-1282), i.e. the current loop post, not the post passed to wp_trim_excerpt(). When an excerpt is generated for a different post (widgets, REST, related-posts lists), _wp_ignored_hooked_blocks meta is read from the wrong post and the wrong post's hooked-block suppression is applied.

Minimal reproduction

// Register a hooked block, e.g. in a plugin or theme functions.php.
register_block_type(
        'tests/hooked-block',
        array(
                'block_hooks' => array(
                        'core/post-content' => 'last_child',
                ),
        )
);

// Give the hooked block markup via the public 'hooked_block' filter.
add_filter(
        'hooked_block',
        static function ( $parsed_hooked_block ) {
                $parsed_hooked_block['innerHTML']    = '<p>LEAKED HOOKED TEXT</p>';
                $parsed_hooked_block['innerContent'] = array( '<p>LEAKED HOOKED TEXT</p>' );
                return $parsed_hooked_block;
        }
);

Then, on the front end (inside the loop so get_post() resolves), for a post whose content is <!-- wp:paragraph --><p>Visible text.</p><!-- /wp:paragraph --> and which has no manual excerpt:

$excerpt = wp_trim_excerpt( '', $post ); // or the_excerpt() in the loop

Expected: Visible text.
Actual: Visible text. LEAKED HOOKED TEXT

Precedent

Core already performs exactly this unhook for this exact callback: insert_hooked_blocks_into_rest_response() removes apply_block_hooks_to_content_from_post_object from the_content around its own inner apply_filters( 'the_content' ) call and restores it afterwards (blocks.php:1559-1573, the unhook/restore was introduced in [59523] for apply_block_hooks_to_content and switched to the current callback in [59838]). The REST API does it to avoid running the algorithm twice; the excerpt case is stronger, because the run can never produce rendered output at all.

Proposed fix

Mirror the existing do_blocks/wp_filter_content_tags dance in wp_trim_excerpt() (formatting.php:4045-4069): capture remove_filter( 'the_content', 'apply_block_hooks_to_content_from_post_object', 8 ) before the inner apply_filters( 'the_content' ), and re-add it afterwards only if it was actually removed, the same conditional-restore pattern, so a site that deliberately unhooked the callback is not changed.

The patch includes six PHPUnit tests: the callback is unhooked inside the inner the_content run, is restored afterwards, is not resurrected when previously unhooked, the Block Hooks algorithm does not run (detected via the hooked_block_types filter, which only fires inside the algorithm, blocks.php:1025/1106), hooked-block markup does not leak into the excerpt, and Block Hooks still apply to a subsequent real the_content run.

Test sweep: 5283 tests pass across the formatting / post / query / REST-posts / feed groups; PHPCS clean. A negative control (the same reproduction on the code path without the fix) produces the leaked excerpt 'Visible text. LEAKED HOOKED TEXT', confirming the leak exists on trunk.

AI assistance: Yes.
Tool(s): Claude Code (orchestration/review) and Devin (implementation).
Used for: root-cause analysis, patch and test authorship; verified against git history (changesets, tickets, line numbers) and a full test sweep; reviewed and taken responsibility for by the contributor.

Change History (8)

This ticket was mentioned in PR #13524 on WordPress/wordpress-develop by @MythThrazz.


26 hours ago
#1

  • Keywords has-patch has-unit-tests added

## What / why

Since [59523] (#61074, WP 6.8), the_content carries a Block Hooks callback at priority 8 —
apply_block_hooks_to_content, swapped for apply_block_hooks_to_content_from_post_object
in [59838] (#61074, #62716). wp_trim_excerpt() already unhooks do_blocks ([56560] / #58682)
and wp_filter_content_tags ([55850] / #56588) around its inner apply_filters( 'the_content' )
call, but the priority-8 callback was never unhooked. Three consequences:

  • Hooked-block markup leaks into excerpts as plain text. The callback wraps content in a temporary core/post-content wrapper block so first_child/last_child hooked blocks have an anchor; the algorithm serializes them into the content, do_blocks is unhooked so they can never render, and wp_strip_all_tags() strips the delimiters — leaving the markup a hooked block carries via the hooked_block filter as literal excerpt text. Reproduced: excerpt goes from 'Visible text.' to 'Visible text. LEAKED HOOKED TEXT'.
  • Guaranteed wasted work — a parse_blocks() traversal, get_hooked_blocks(), the hooked_block_types/hooked_block filters, and a _wp_ignored_hooked_blocks meta read on every generated excerpt, for output that can never be visible.
  • Wrong post context — the callback resolves context via get_post() (current loop post), not the post passed to wp_trim_excerpt(), so off-loop excerpts read the wrong post's _wp_ignored_hooked_blocks suppression.

## The change

Unhook and conditionally restore apply_block_hooks_to_content_from_post_object (priority 8)
inside wp_trim_excerpt(), mirroring the existing do_blocks/wp_filter_content_tags dance and
the identical unhook core already performs in insert_hooked_blocks_into_rest_response()
(blocks.php:1559-1573). A site that deliberately unhooked the callback is left unchanged.

## Testing

  • Six new PHPUnit tests in tests/phpunit/tests/formatting/wpTrimExcerpt.php: callback unhooked during the inner the_content run; restored afterwards; not resurrected when previously unhooked; Block Hooks algorithm does not run (spied via hooked_block_types, which only fires inside the algorithm); hooked-block markup does not leak into the excerpt; Block Hooks still apply to a subsequent real the_content run.
  • Negative control: the same reproduction on the code path without the fix yields the leaked excerpt 'Visible text. LEAKED HOOKED TEXT', confirming the bug on trunk.
  • 5283 tests pass across the formatting / post / query / REST-posts / feed groups (sequential sweep); PHPCS clean.

## Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code (Anthropic), Devin (Cognition)
Model(s): Claude Opus 4.8 (orchestration/review), Devin (implementation)
Used for: root-cause analysis, patch + test authorship, git fact-checking, and full test sweep; reviewed and taken responsibility for by the contributor.

---
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

🤖 Generated with Claude Code

#2 @westonruter
23 hours ago

  • Keywords changes-requested added

I'm seeing strange characters appearing in the description, like "â€". Please correct this and remove the soft line breaks so tha tthe paragraphs reflow freely based on the screen size.

#3 @westonruter
23 hours ago

  • Focuses performance removed

I don't believe this is related to performance. Please correct if otherwise.

#4 @westonruter
23 hours ago

  • Component EditorFormatting

#5 @MythThrazz
17 hours ago

  • Description modified (diff)

Thanks. I fixed the encoding (the orphan characters were em dashes that got mangled on submit) and reflowed the description so the paragraphs wrap freely. Agreed on dropping the performance focus and the move to Formatting; the correctness bug, the hooked-block leak into excerpts and the wrong-post context, is the point here, and the saved work is incidental.

#6 @westonruter
4 hours ago

  • Milestone Awaiting Review7.2

#7 @westonruter
4 hours ago

  • Owner set to bernhard-reiter
  • Status newreviewing

#8 @westonruter
4 hours ago

  • Keywords changes-requested removed
Note: See TracTickets for help on using tickets.