Skip to content

KSES: Reimplement with Tag Processor - #13271

Closed
dmsnell wants to merge 1 commit into
WordPress:trunkfrom
dmsnell:kses/dual-with-tag-processor
Closed

dmsnell wants to merge 1 commit into
WordPress:trunkfrom
dmsnell:kses/dual-with-tag-processor

Conversation

@dmsnell

@dmsnell dmsnell commented Aug 25, 2026 •

Copy link
Copy Markdown
Member

Trac ticket: Core-66208
Trac ticket: Core-65984

Replaces #6577

Description

Rewrites wp_kses() to rely on the HTML API for structural and reliable application of sanitization rules, normalizing the output for improved downstream parsing.

Notables

  • A new filter wp_kses_force_legacy_parser provides the choice of whether to use this new parser or stick with the legacy code.

Fixes

  • Core-25851 Large attribute values may crash PCRE patterns and cause content loss.
  • Core-37698 Global pollution in wp_kses_split() calls.
  • Core-48873 CSS contents are corrupted by wp_kses().
  • Core-51482 wp_kses() turns SCRIPT and STYLE content into renderable text.
  • Core-52333 wp_kses() and HTML disagree on the set of named character references.
  • Core-58377 Block names with consecutive hyphens are corrupted.
  • Core-58921 Valid tag names are rejected from the allow-list.
  • Core-59310 parse_blocks() called unnecessarily.
  • Core-61246 wp_kses() un-comments HTML comments.
  • Core-62024 wp_kses_post() incorrectly escapes "<" attributes values.

Todo

Merge after #13273, which accounts for three of the failing tests.

  • self-closing non-HTML elements
  • [~] remove opening tag when required attributes are missing, and closing tag
    • while this would be a nice enhancement it’s going to be left out of this work to preserve existing behaviors. with the HTML Processor powering wp_kses(), it’s possible to simply wait until an opened element is closed based on depth, and skip that closing element if it exists.
  • replace C0 controls
    • replace C0 controls with their escapes, rather than stripping them away
    • replace C0 controls in attribute values?
    • original commit removing C0 controls is c7fd8c7
    • C0 controls are left in-place to prevent problems with creating new syntax through their removal
  • [~] handle incomplete parsing, including closing all open elements
    • plenty of existing code in Core calls wp_kses() with intentionally-incomplete input, for example, a wrapper opening tag with part of the content, separately from the closer. closing open elements does a good job of isolating content, but legacy behaviors depend too much on the more procedural use of wp_kses() so isolation cannot be reasonably added without mangling websites.
  • if SVG or MATH are not allowed, the entire element should disappear
  • remove default pre_kses filters but then call pre_kses
  • [~] Change character reference handling for XML context (even though this function is not and was never appropriate for XML parsing — use serialize_to_xml() instead).
    • The legacy implementation doesn’t parse a different set of character reference names. Instead, for the xml context, it only leaves the five syntax characters as names, and decodes everything else. This is correlated with the default behavior for the new implementation for XML and HTML.
    • It shouldn’t need saying here, but wp_kses() is entirely inappropriate for XML inputs or XML outputs. That requires serialize_to_xml().
  • Escaped [ and ] should remain escaped through sanitization.

Notes

  • Branch tip with HTML Processor bdbae3a

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@dmsnell
dmsnell force-pushed the kses/dual-with-tag-processor branch 22 times, most recently from c784058 to fdc5e96 Compare August 27, 2026 17:00
@dmsnell
dmsnell force-pushed the kses/dual-with-tag-processor branch 5 times, most recently from bd15b3e to 38470b2 Compare August 28, 2026 04:31
@dmsnell
dmsnell force-pushed the kses/dual-with-tag-processor branch 14 times, most recently from 07d03c9 to 038c1c9 Compare August 31, 2026 00:58
@sirreal

sirreal commented Sep 1, 2026

Copy link
Copy Markdown
Member

I'm supportive of this direction overall. I'll leave some feedback on different points.

I started this review at 0b1535e but it moved while I was reviewing, so some of this review may be stale by the time I post.


General things to debug:

echo wp_kses( '<template>foo</template>', [ 'template' => true ] );
// Prints: </template>
echo wp_kses( '<div style>x</div>', [ 'div' => ['style' => true] ] );
// Prints: <div style="1">x</div>
echo wp_kses( '<div style="col&#x01;or:red">x</div>', [ 'div' => [ 'style' => true ] ] );
// Before (trunk): <div>x</div>
// After         : <div style="color:red">x</div>

This is an interesting one, the block delimiter is preserved (improvement) but for some reason wp:image becomes wp:core/image:

echo wp_kses( '<!-- wp:image {"alt":"<script>x</script>"} -->', [] );
// Before: &lt;!-- wp:image {&quot;alt&quot;:&quot;x"} --&gt;
// After:  <!-- wp:core/image {"alt":""} -->

Incomplete tokens are interesting.

KSES would leave something like a trailing <b this is lost as HTML text (it would escape <). Browsers do not do that and this PR does not do that, the <b… is dropped.

That means that partial tokens like the unclosed <b above but also "atomic elements" are stripped entirely:

echo wp_kses( '<p>keep me</p><b this is lost', ['p'=>[],'b'=>[]] ) . "\n";
echo wp_kses( '<p>keep me</p><title>this is lost', ['p'=>[],'title'=>[]] ) . "\n";
echo wp_kses( '<p>keep me</p><style>*{content:"this is lost";}', ['p'=>[],'title'=>[]] ) . "\n";
echo wp_kses( '<p>keep me</p><style>*{content:"this is lost";}', ['p'=>[],'style'=>[]] ) . "\n";
echo wp_kses( '<p>keep me', ['p'=>[]] ) . "\n";

Before/after diff of output:

diff --git 1/tmp/before.txt 2/tmp/after.txt
index 5122acd7a7..d87549cc15 100644
--- 1/tmp/before.txt
+++ 2/tmp/after.txt
@@ -1,5 +1,5 @@
-<p>keep me</p>&lt;b this is lost
+<p>keep me</p>
-<p>keep me</p><title>this is lost
+<p>keep me</p>
-<p>keep me</p>*{content:"this is lost";}
+<p>keep me</p>
-<p>keep me</p><style>*{content:"this is lost";}
+<p>keep me</p>
 <p>keep me

This is worth considering carefully. The HTML snippets KSES sees don't necessarily align with coherent boundaries. In particular echo wp_kses_post( 'a<b', [] ); is a&lt;b on trunk (which seems reasonable) but on this branch its a.

We could align with the previous behavior, assume that the incomplete tokens are HTML text, and escape them as such. There may be negative implications to that, it's worth some careful consideration.


Foreign content is difficult, is this a case of integration points bailing because they're risky?

echo wp_kses( '<math><mtext>x</mtext></math>', ['math'=>[],'mtext'=>[]] );
// (no output)

</br> produces warnings.

echo wp_kses( '</br>', [ 'br' => true ] );
// Warning: foreach() argument must be of type array|object, true given in /var/www/html/wp-includes/kses.php on line 1499
// Warning: foreach() argument must be of type array|object, null given in /var/www/html/wp-includes/kses.php on line 1536
// Prints: <br>

First warning is the allowed tags. I used true for the allowed tags value.

This change produces a warning where before it did not:

foreach ( $element_attributes as $name => $spec ) {

Maybe true is intentionally supported:

// Are any attributes allowed at all for this element?
$element_low = strtolower( $element );
if ( empty( $allowed_html[ $element_low ] ) || true === $allowed_html[ $element_low ] ) {
return "<$element$xhtml_slash>";
}

Either way, it's filterable and important to to follow whatever KSES did previously. I think if the value is true it should use an empty array (no attributes allowed).

Second warning is here:

foreach ( $attribute_names as $name ) {

As it is now, this needs a null guard. I had proposed a fix at the tag processor level in #9657 that we could consider.

@dmsnell

dmsnell commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

@sirreal this is great. thanks for the awesome review and findings.

  • the TEMPLATE issue was silly; the code was decrementing the depth before processing the closing tags. I’ve resolved that by splitting the depth adjustment to before for openers and after for closers. TEMPLATE is still off by policy. we can open it up, but I haven’t done that yet, as I think it’s probably reasonable to not allow user-provided inputs to add them. for example, to supplant some TEMPLATE that might already be on the page otherwise.

  • style="1" had to do with calling get_attribute() manually rather than relying on the normalized-to-string value. I’ve fixed that and also only run through style processing if the value is non-true. this also removes the C0 controls before processing.

  • style="col&#x01;or;red" this seems like one of the many kinds of improvements occurring with this rewrite. what about this should be debugged?

  • but for some reason wp:image becomes wp:core/image: that’s because it’s being re-serialized and the Block Processor returns a fully-qualified name. I wouldn’t call this a bug, but we could consider removing the core/, and potentially via a new method on the block processor like ->get_minimal_block_type(). this only happens on block delimiters whose JSON attributes are modified.

  • for incomplete tokens I still consider this an improvement, and it goes back to our original rationale for pausing at incomplete tokens. in a browser, this stuff is even ignored. we can look at an example like a<b and think it makes sense to escape, but what about <!-- wp:para or <div class="wp-fullwidth attachment-id-15" data-wp-bi and such? those look far more like tags and comments that should not be escaped and revealed on the page. in the case of the block delimiter, it would invite disclosure of something that was perhaps meant to be private on the server. thus, in the absence of any way to know what would have come next, truncating as a means of inevitable data loss feels preferable to showing syntax.

  • the </br> case has been fixed. I’m not sure why it arose for that one in my testing and not for other tags. added if ( is_array() ) guards.

@sirreal

sirreal commented Sep 2, 2026

Copy link
Copy Markdown
Member

wp:image becomes wp:core/image

I was confused here because I expected it to happen across core blocks, but I didn't observe the same behavior with the paragraph block. I may not have been testing appropriately.

<div style="col&#x01;or:red">x</div>

I called this out just to make sure we know what's different and understand the implications. Was this passed through safecss_filter_attr() before and is it now? What explains the different handling?

  • <div style="col&#x01;or:red">not red</div> is not red, but I guess the control character removing KSES does makes it <div style="color:red">not red</div> which is red and makes sense to keep if we're removing control characters. (Note, this has : in the declaration, your reply had a typo ; between property and value).
  • <div style="col&#x01;or;red">x</div> (with the ; instead of :) seems objectively better. Before it became or;red, now it's color;red which makes more sense. I suspect the lingering problem that there's no valid CSS declaration is a problem at another layer we can eventually address with improved CSS handling.

for incomplete tokens I still consider this an improvement

I mostly agree that removing it is the right thing to do. I think it would be appropriate to escape the incomplete token as HTML in sometimes and drop it others, but that would require a defining where the incomplete token parsing and a set of rules for how to deal with each case. Defining those rules will require some arbitrary decisions that are likely to be surprising as often as not.

One important change that's worth worth documenting clearly is that some elements must be properly closed, while other elements do not need to be closed. This is especially important since we've seen examples where the KSES input starts with end tags. That implies that other KSES input may ends with unclosed tags.

Again, I think on the whole these are probably good to remove, but may still be surprising. An example that lists the "atomic elements" defined by the tag processor and a few examples should help with this, e.g. <div>Everything after this is removed:<title>gone</div>

@sirreal sirreal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a few more findings to consider.

A major concern is that MathML is extremely restricted, most MathML markup I've tested is stripped because it quickly encounters integration points. I think this needs a more permissive solution because as-is it disallows a lot of potentially valid and conforming content that would previously pass.

<template> is always removed, is that correct? The Interactivity API often relies on template elements. It may be unusual for those to come from untrusted sources, but I'm concerned about blocking it entirely.

HTML character references are normalized. Often this is fine, but it can produce shortcode syntax where HTML character references avoided it. For example, in the following snippet plain HTML text becomes a potential shortcode: echo wp_kses( '&#91;PR 13271&#93;', [] );

-&#091;PR 13271&#093;
+[PR 13271]

wp_remove_unwanted_c0_controls() seems to run on text nodes but not the contents of atomic elements. Those are mostly the same category so it's worth considering whether it should be applied there or not.

There were clearly some bugs with the way <object> was either removed or not in KSES. I think this PR implements the intention in a less buggy way. One thing I'd consider is to completely remove known void HTML elements that don't satisfy the required attributes. That seems to align with the intended behavior.

demo
$u = wp_parse_url( wp_upload_dir( null, false )['url'] );
$host = $u['host'];
$port = $u['port'] ? ":{$u['port']}" : '';
$pdf_file = "https://{$host}{$port}/ex.pdf";
echo wp_kses( '<object type="application/pdf" data="'.$pdf_file.'">after', 'post' ) . "\n";
echo wp_kses( '<object type="application/pdf">after', 'post' ) . "\n";
echo wp_kses( '<object type="application/pdf" />after', 'post' ) . "\n";

$example_tag_allow = ['example-tag'=>['r'=>['required'=>true,'values'=>['yes']]]];
echo wp_kses( '<example-tag r="yes">after', $example_tag_allow ) . "\n";
echo wp_kses( '<example-tag r="yes" />after', $example_tag_allow ) . "\n";
echo wp_kses( '<example-tag r="no">after',  $example_tag_allow ) . "\n";
echo wp_kses( '<example-tag r="no" />after',  $example_tag_allow ) . "\n";
 <object data="https://localhost:8888/ex.pdf" type="application/pdf">after
+<object>after
-<object>after
+<object>after
-after
+<object>after
-<example-tag r="yes">after
+<example-tag r="yes">after
-<example-tag r="yes" />after
+<example-tag r="yes">after
-<example-tag>after
+<example-tag>after
-after
+<example-tag>after

This should inject newlines into some HTML elements similar to r61747 and r61754. Possible test:

public function test_pre_leading_newline_is_preserved_and_idempotent(): void {
    $input = "<pre>\n\ncode</pre>";
    $once  = wp_kses( $input, 'post' );
    $twice = wp_kses( $once, 'post' );
    $this->assertSame( $once, $twice, 'wp_kses() must be idempotent for <pre> content.' );
    $this->assertSame( $input, $once, 'wp_kses() must not consume newlines inside <pre>.' );
}

There seem to be two problems with block delimiters. I mentioned that the block name may change for core blocks. This seems to be triggered by sanitization:

echo wp_kses( '<!-- wp:image {"alt":"<script>"} -->', 'post' );
// <!-- wp:core/image {"alt":""} -->
echo wp_kses( '<!-- wp:paragraph {"note":"<script>"} --><p>hi</p><!-- /wp:paragraph -->', 'post' );
// <!-- wp:core/paragraph {"note":""} --><p>hi</p><!-- /wp:paragraph -->

In both cases, I'd expect the block name to remain unchanged. This is likely limited to Core blocks.

There may be problems with block attributes. Existing KSES seems broken in this example (it escapes the comment <!-- to &lt;!--) so I'm not sure what the baseline behavior should be here.

$x = '<!-- wp:more {"customText":"<more>"} --><!--more <more>--><!-- /wp:more -->';
echo "1: " . wp_kses( $x, ['more'=>[]] ) . "\n";
echo "2: " . wp_kses( wp_kses( $x, ['more'=>[]] ), ['more'=>[]] );
1: <!-- wp:more {"customText":"&lt;more>"} --><!--more &lt;more>--><!-- /wp:more -->
2: <!-- wp:core/more {"customText":"\u0026lt;more\u0026gt;"} --><!--more &lt;more>--><!-- /wp:more -->

This block is confusing at many levels (duplicated data, HTML encoded data in comments, etc). It seems to work regardless of the escaping, but I think we can do better here. I'd expect the attribute values to be correctly encoded on the first pass to a fixed point, preserving the intended value and becoming idempotent (line breaks added for clarity):

<!-- wp:more {"customText":"\u003cmore\u003e"} -->
<!--more <more>-->
<!-- /wp:more -->

This raises an additional concern with < escaping in comments, it will cause this to get out of sync 😕 That's the situation right now on trunk, more block with <b> will drift and break for users without unfiltered_html because of the < rewriting to &lt;.

Comment thread src/wp-includes/kses.php Outdated
Comment thread src/wp-includes/kses.php Outdated
Comment thread src/wp-includes/kses.php Outdated
@dmsnell

dmsnell commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

core/image vs. image

I’ve normalized by removing the core/ namespace prefix.

<template> is always removed, is that correct? The Interactivity API often relies on template elements. It may be unusual for those to come from untrusted sources, but I'm concerned about blocking it entirely.

I’ve re-gated this to only block when TEMPLATE isn’t configured. In the earlier drafts, it was qualitatively different, but now, we are tracking foreign content and template content separately from how we track skipping content.

A major concern is that MathML is extremely restricted, most MathML markup I've tested is stripped because it quickly encounters integration points. I think this needs a more permissive solution because as-is it disallows a lot of potentially valid and conforming content that would previously pass.

That’s something we can iterate on. Fundamentally it’s only about how much accounting we duplicate here. I’d rather start more-locked-down and relax the rules as we can.

HTML character references are normalized. Often this is fine, but it can produce shortcode syntax where HTML character references avoided it.

Seems reasonable enough to escape, but also, the appropriate way to “escape” shortcodes is with the double-bracket syntax, like [[gallery]].

wp_remove_unwanted_c0_controls() seems to run on text nodes but not the contents of atomic elements. Those are mostly the same category so it's worth considering whether it should be applied there or not.

I really don’t like this. It gets really confusing when we start applying it to special atomic elements, which have their own unique escaping rules. I still think this was probably the wrong layer at which to address the problem: if we can audit XML generation we should be free to remove C0-controls-removal as it was originally intended to prevent generating broken XML.

One thing I'd consider is to completely remove known void HTML elements that don't satisfy the required attributes. That seems to align with the intended behavior.

This is what it’s supposed to be doing already, and I think it does. Are you seeing it fail to do that?

The problem with the existing behaviors and tests is that OBJECT is not a void element, and wp_kses() is looking to the errant self-closing flag as a proxy for that.

If you change your test/example code to a void or special atomic element, or a self-closing foreign element, then it should fully remove it.

This should inject newlines into some HTML elements

Do you think this should be an easy thing to resolve? Just ensure that the modifiable text always starts with a newline? Or is the problem that we’re pushing out the decoded text and what we should be doing is building a custom <pre>#text</pre> and then chopping off the <pre> and </pre>?

In both cases, I'd expect the block name to remain unchanged. This is likely limited to Core blocks.

This is technically true; the serialization changes, but the block name remains unchanged. I’ve added the aesthetic change, but they are semantically equivalent. I understand the desire for aesthetics, but I’m not scoping this to require byte-identical idempotence. wp_kses() is likely not even idempotent in many places this isn’t. This is a good goal, but I would not want us to stall the work simply because of neutral variations.

That's the situation right now on trunk

This is also the scope-level for merging this work. There are lots of things I don’t like about wp_kses() legacy behavior, but I don}t want to demand that we fix everything at first. After this change, these things should be considerably easier to update than they have been, since we have a structural approach to take here.


pushing updates in bfedf65 which address your points unless stated otherwise.

Comment thread src/wp-includes/kses.php Outdated
Comment thread src/wp-includes/kses.php Outdated

@sirreal sirreal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left a few comments about comment handling. I'd recommend stronger langauge to make it clear that the section is designed to handle normative comments only.

Comment thread src/wp-includes/kses.php
if ( WP_HTML_Tag_Processor::COMMENT_AS_HTML_COMMENT !== $this->get_comment_type() ) {
break;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment handling currently depends on only matching normative HTML comments. If the line above were to change, for example to recognize more comments, there's a risk of not getting the full comment text.

This could use $text = $this->get_full_comment_text(). The result should be identical for normative comments but correct for other types of comments.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did we not handle this in set_modifiable_text()? was it to preserve the malformed comment type?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm don't understand your question. get_full_comment_text() should always return what would appear between <!-- and --> when the comment is parsed. get_modifiable_text() has different behaviors for things like PI-lookalike and other kinds of bogus comments because parts of their "full comment text" are not modifiable text because modification could change the HTML structure. For example, <?xml not allowed in html> is a comment where it's "full" and "modifiable" text are different.

This is all covered at set_mofiable_text() because it only allows normative comments, PI nodes, and certain HTML elements.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was just thinking, if we had ?xml not allowed in html and changed it with set_modifiable_text() to something else, why don’t we also change the comment type?

honestly I just can’t remember, because I am sure we debated this. maybe it would cause issues for repeated calls to set_modifiable_text()?

Comment thread src/wp-includes/kses.php Outdated
*
* For the sake of sanitization, remove the comment entirely.
*/
$was_incorrectly_closed = '!' === $comment[ strlen( $comment ) - 2 ];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to my other comment, this assumes a normative comment. <!> is an example of a non-normative comment that would satisfy this. It's arguably incorrectly closed as well 🙂

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it doesn’t assume because of the check above to abort for non-normative comments. <!> has a comment type of COMMENT_AS_INVALID_HTML, which gets ignored by the code.

I can see your point about risk for future changes to this code, but as written we don’t allow these and we have no known intentions to widen this later.

we might consider adding behavioral tests, but we’re also in kind of a pickle while wp_kses() botches things, as this is supposed to replace it. I’d like to follow-up after the replacement with further hardening and improvements, once we have a foundation on which to discuss rules vs. emergent behaviors of the interlinked KSES code.

anyway, I appreciate your comment and I think it’s good, but I want to be careful about delaying this work, which brings substantial uplift in reliability over wp_kses() now, by planning for and guarding against a future we want to avoid and for supporting features we actively don’t want.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the thought crossed my mind that maybe we just ignore all non-normative comments and also those which end in --!>. this is untrusted input, so it might seem reasonable to do that

Comment thread src/wp-includes/kses.php Outdated
Comment thread src/wp-includes/kses.php Outdated
Comment on lines +1447 to +1461
$block_type = str_starts_with( $block_type, 'core/' )
? substr( $block_type, /* 'core/' */ 5 )
: $block_type;

$filtered_attributes = filter_block_kses_value(
$original_attributes,
$this->allowed_html,
$this->allowed_protocols,
array( 'blockName' => $block_type )
);

if ( $original_attributes !== $filtered_attributes ) {
$serialized_attributes = serialize_block_attributes( $filtered_attributes );
$voider = WP_Block_Processor::VOID === $block_processor->get_delimiter_type() ? '/' : '';
$text = " wp:{$block_type} {$serialized_attributes} {$voider}";

@sirreal sirreal Sep 17, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The full normalized version of $block_type (including core/ prefix) must happen after filter_block_kses_value because that expects to be passed the complete block type including the core/ prefix. See:

if ( isset( $block_context['blockName'] ) && 'core/template-part' === $block_context['blockName'] ) {
$filtered_value = filter_block_core_template_part_attributes( $filtered_value, $filtered_key, $allowed_html );
}

It's essential to remove the core/ implicit prefix for the serialization, but not before:

Suggested change
$block_type = str_starts_with( $block_type, 'core/' )
? substr( $block_type, /* 'core/' */ 5 )
: $block_type;
$filtered_attributes = filter_block_kses_value(
$original_attributes,
$this->allowed_html,
$this->allowed_protocols,
array( 'blockName' => $block_type )
);
if ( $original_attributes !== $filtered_attributes ) {
$serialized_attributes = serialize_block_attributes( $filtered_attributes );
$voider = WP_Block_Processor::VOID === $block_processor->get_delimiter_type() ? '/' : '';
$text = " wp:{$block_type} {$serialized_attributes} {$voider}";
$filtered_attributes = filter_block_kses_value(
$original_attributes,
$this->allowed_html,
$this->allowed_protocols,
array( 'blockName' => $block_type )
);
if ( $original_attributes !== $filtered_attributes ) {
$serialized_attributes = serialize_block_attributes( $filtered_attributes );
$block_type = str_starts_with( $block_type, 'core/' )
? substr( $block_type, /* 'core/' */ 5 )
: $block_type;
$voider = WP_Block_Processor::VOID === $block_processor->get_delimiter_type() ? '/' : '';
$text = " wp:{$block_type} {$serialized_attributes} {$voider}";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

already saw that but hadn’t pushed it. thank you!

@sirreal

sirreal commented Sep 18, 2026 •

Copy link
Copy Markdown
Member

This should inject newlines into some HTML elements

Do you think this should be an easy thing to resolve? Just ensure that the modifiable text always starts with a newline? Or is the problem that we’re pushing out the decoded text and what we should be doing is building a custom <pre>#text</pre> and then chopping off the <pre> and </pre>?

A few elements ignore a first newline: TEXTAREA, PRE, LISTING. TEXTAREA is already covered by set_modifiable_html():

/*
* HTML ignores a single leading newline in this context. If a leading newline
* is intended, preserve it by adding an extra newline.
*/
if (
'TEXTAREA' === $this->get_tag() &&
1 === strspn( $plaintext_content, "\n\r", 0, 1 )
) {
$plaintext_content = "\n{$plaintext_content}";
}

It should be covered on get_modifiable_text() for these elements, so the leading newline be stripped:

/*
* Skip the first line feed after LISTING, PRE, and TEXTAREA opening tags.
*
* Note that this first newline may come in the form of a character
* reference, such as `&#x0a;`, and so it's important to perform
* this transformation only after decoding the raw text content.
*/
if (
( "\n" === ( $decoded[0] ?? '' ) ) &&
( ( $this->skip_newline_at === $this->token_starts_at && '#text' === $tag_name ) || 'TEXTAREA' === $tag_name )
) {
$decoded = substr( $decoded, 1 );
}

I think the best thing to do here for PRE and LISTING is to always inject a leading newline after their open tag, something like this:

$output .= $tag_maker->get_updated_html();
if ( 'html' === $namespace && 'PRE' === $tag_name || 'LISTING' === $tag_name ) {
  $output .= "\n";
}

I shared a potential test for this, it could probably be added to the idempotency test data set:

public function test_pre_leading_newline_is_preserved_and_idempotent(): void {
    $input = "<pre>\n\ncode</pre>";
    $once  = wp_kses( $input, 'post' );
    $twice = wp_kses( $once, 'post' );
    $this->assertSame( $once, $twice, 'wp_kses() must be idempotent for <pre> content.' );
    $this->assertSame( $input, $once, 'wp_kses() must not consume newlines inside <pre>.' );
}

@sirreal

sirreal commented Sep 18, 2026

Copy link
Copy Markdown
Member

There are some behavioral changes around unclosed blocks. Before, KSES would close them (either as void or with a closing delimiter):

Input Before After
<!-- wp:a --> <!-- wp:a /--> <!-- wp:a -->
<!-- wp:a -->in <!-- wp:a -->in<!-- /wp:a --> <!-- wp:a -->in

This patch introduces a new version of `wp_kses()`, temporarily living alongside the legacy implementation in a new function named `wp_sanitize_html_kses()`. This new implementation is based on the HTML API, which provides more reliable parsing of HTML inputs.

 - A new filter, `wp_kses_force_legacy_parser`, offers an opt-out for the new code, and for testing side-by-side.
 - Many tests have been updated because of hard-coded values which result from historic HTML parsing oddities. They have been updated to reflect the newer parsing, the spec-compliant parsing.

There should be no changes necessary to calling function with this change.

Co-Authored-By: Jon Surrell <jonsurrel@git.wordpress.org>
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

A commit was made that fixes the Trac ticket referenced in the description of this pull request.

SVN changeset: 64233
GitHub commit: 7119e1b

This PR will be closed, but please confirm the accuracy of this and reopen if there is more work to be done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants