I shipped a WordPress plugin last week whose only job is to reproduce a specific, legally prescribed document on a product page. Exact colours, exact layout, nothing editable.
It went live rendering as unstyled text.
The CSS file was correct. The markup was correct. php -l passed on every file, the official Plugin Check tool reported no errors, and reading the diff told me nothing. The bug was wp_kses, and the reason it took me an hour is that wp_kses does not fail in a way you can see.
What actually happens
wp_kses( $html, $allowed ) filters HTML against an allow-list. Everyone knows that. What is easy to miss is what it does with a tag that is not on the list.
It does not remove the element. It removes the tag and keeps the children.
wp_kses(
'<section class="card"><p>Still here</p></section>',
[ 'p' => [ 'class' => true ] ]
);
// => '<p>Still here</p>'
The <section> is gone. The paragraph survived. No notice, no warning, no return value to check. Run that in wp shell and watch it happen.
That behaviour is correct and deliberate: kses is a sanitiser, and throwing away a user's text because their comment had an unexpected wrapper would be much worse. But when you point kses at your own markup, the failure mode inverts. You do not lose content. You lose structure, and structure is where all your CSS hooks live.
How it looked in practice
My notice is built like this:
<section class="sbgn" role="note">
<header class="sbgn__head">
<span class="sbgn__emblem"></span>
<h2 class="sbgn__title">LEGAL GUARANTEE</h2>
</header>
<div class="sbgn__body">
...
</div>
</section>
and echoed like this:
echo wp_kses( self::markup(), SBGN_Kses::notice() );
My allow-list had div, p, span, ul, li, h1–h3, strong, b, aside, figure, figcaption, br and a.
No section. No header. No ol.
So on every product page the browser received this:
<span class="sbgn__emblem"></span>
<h2 class="sbgn__title">LEGAL GUARANTEE</h2>
<div class="sbgn__body">
...
</div>
The root .sbgn element carried the border, the background and the CSS custom properties everything else referenced. .sbgn__head carried the coloured header bar. Both were deleted, so:
- every
var(--brand)resolved to nothing, because the custom properties were declared on.sbgn - the header bar vanished entirely
-
<ol>was stripped too, so numbered steps quietly became bullets
The page still looked populated. All the text was there, in roughly the right order. It just had none of the design, which for this particular plugin is the entire product.
Why nothing caught it
This is the part worth internalising.
-
php -lpasses. The PHP is valid. It always was. - Plugin Check passes. It reads the source, and the source is correct.
- The CSS is served and parses. I fetched the stylesheet URL directly: HTTP 200, right content, braces balanced. I wasted time on that.
-
The class names match. Grepping the rendered page for
class="sbgnreturnedsbgn__body,sbgn__title,sbgn-qrand friends. They were all present, which made it look like the CSS simply was not applying.
The tell, when I finally saw it, was an absence: class="sbgn" and class="sbgn__head" were the only two selectors in my stylesheet with no counterpart in the DOM. A missing string is much harder to notice than a wrong one.
# the check that actually found it
curl -s "$URL" | grep -o 'class="sbgn[^"]*"' | sort -u
Everything my stylesheet defined, minus the two that mattered.
The fix, and the guard
The fix is obvious once you know. List every tag you emit:
$html = array(
'div' => $attr,
'section' => $attr, // added
'header' => $attr, // added
'footer' => $attr, // added
'p' => $attr,
'ul' => $attr,
'ol' => $attr, // added
'li' => $attr,
// ...
);
Attributes are silently dropped the same way, which is its own small trap. My notice carried role="note"; role was not in the attribute list, so the element survived while its announcement to a screen reader did not. Nothing in the visual output would ever have told me.
The guard I added is a comment pointing at a one-line command, because the allow-list and the markup live in different files and will drift:
grep -ohE "<[a-z][a-z0-9]*" includes/class-*-notice.php includes/class-*-label.php \
| tr -d '<' | sort -u
Run that, compare against the allow-list, done. If you would rather have it fail loudly in development, wp_kses gives you no hook for that, but you can diff the two strings yourself:
$out = wp_kses( $markup, $allowed );
if ( defined( 'WP_DEBUG' ) && WP_DEBUG && $out !== $markup ) {
trigger_error( 'kses altered plugin markup', E_USER_WARNING );
}
Crude, but it turns a silent structural edit into something you can see. For your own trusted markup, where the output should be byte-identical, any difference at all is a bug.
The general lesson
I had two correct artifacts, a correct template and a correct stylesheet, and a broken page. The defect lived in the transformation between them, and every tool I owned inspected the artifacts rather than the result.
Escaping late is the right rule and I am not arguing with it. But an allow-list is a second source of truth about your own markup, and a second source of truth drifts. Whatever you are building, the check that would have caught this in five seconds is looking at what the browser actually received.
I only found it because I screenshotted the rendered output on a real install before writing the listing. That is now a release step rather than a nice-to-have.
The plugin, for the curious, shows the EU's harmonised legal guarantee notice, which becomes mandatory for anyone selling goods to EU consumers on 27 September 2026. It renders in all 24 official EU languages, and as real text rather than the flat artwork everything else in this category ships, which is the only reason a screen reader can read it at all. Free, WooCommerce optional, and I maintain it: EU Legal Guarantee Notice and GARAN Durability Label for WooCommerce.
Top comments (0)