Skip to content
System nominalUTC --:--:--Articles 085
MODULE 06 Lesson 17 / 18 40 MIN

Where it breaks, and shipping anyway

Security, Escaping and the Review Guidelines

Escaping that looks correct and is not, tested against real WordPress, plus the official AI contribution guidelines that name the same failure modes.

Every escaping bug an agent has written looks correct. That is not a comment about agents; it is a property of escaping. The functions are short and their names are honest, so the review that happens is a review of whether the right one was chosen, and that question is answered by the context rather than by the code. A file full of esc_html, esc_attr and esc_url can be entirely safe and can also be entirely broken, and the two versions read the same at a glance.

So this lesson does the two things that make the difference. It tests escaping against hostile input in real WordPress, and it lines the result up against the guidelines WordPress published on 1 February 2026 for AI-assisted contributions, which name the failure modes you are about to reproduce. The guidelines are the plugin directory telling you the same thing this module does, in its own voice, with the consequence attached.

Reading escaping is not review; running input through it is

The test that matters is the one where you supply the value you are afraid of and look at what comes back. Here is a real run against WordPress 7.0.7 with PHP 8.5.6, feeding a message containing a script tag and a colour value attempting CSS injection.

<?php
$lt = chr( 60 );
$gt = chr( 62 );

$message = $lt . 'b' . $gt . 'Hi' . $lt . '/b' . $gt
	. $lt . 'script' . $gt . 'alert(1)' . $lt . '/script' . $gt
	. $lt . 'strong' . $gt . 'kept' . $lt . '/strong' . $gt;

echo "INPUT:   " . $message . "\n";
echo "KSES:    " . wp_kses_post( $message ) . "\n";
echo "ESC_ATT: " . esc_attr( 'red;background:url(evil)' ) . "\n";
$ wp eval-file kses-test.php
INPUT:   <b>Hi</b><script>alert(1)</script><strong>kept</strong>
KSES:    <b>Hi</b>alert(1)<strong>kept</strong>
ESC_ATT: red;background:url(evil)

Two results, and only one of them is the result people expect. wp_kses_post() did its job: the <script> element was stripped, its text content was left behind as text, and the safe inline elements survived. That matches what the recorded agent run in Module 4 produced, which is worth saying plainly, because a lesson about failure modes that pretends everything fails is not useful. The escaping held.

The second line is the one to sit with. esc_attr() returned the injection string unchanged, because esc_attr() escapes for an HTML attribute context and does not validate the syntax of whatever the attribute contains. A CSS value is not an HTML attribute value, even when it is written inside one, and the function that makes a string safe to place between quotes has nothing to say about a url() inside a style declaration.

The right answer for a colour is a validation function, not an escaper

The fix for the colour is not a better escaper. It is a function whose contract is "return this value only if it is a colour". WordPress provides two, and the run below shows exactly what they accept.

<?php
foreach ( array( '#fff8e5', 'red;background:url(evil)', 'red', '#abc', '#ABCDEF', 'rgb(1,2,3)' ) as $candidate ) {
	printf(
		"%-26s sanitize_hex_color=%-12s sanitize_hex_color_no_hash=%s\n",
		$candidate,
		var_export( sanitize_hex_color( $candidate ), true ),
		var_export( sanitize_hex_color_no_hash( $candidate ), true )
	);
}
#fff8e5                    sanitize_hex_color='#fff8e5'    sanitize_hex_color_no_hash='fff8e5'
red;background:url(evil)   sanitize_hex_color=NULL         sanitize_hex_color_no_hash=NULL
red                        sanitize_hex_color=NULL         sanitize_hex_color_no_hash=NULL
#abc                       sanitize_hex_color='#abc'       sanitize_hex_color_no_hash='abc'
#ABCDEF                    sanitize_hex_color='#ABCDEF'    sanitize_hex_color_no_hash='ABCDEF'
rgb(1,2,3)                 sanitize_hex_color=NULL         sanitize_hex_color_no_hash=NULL

Three-character and six-character hex pass. The injection fails, the named colour fails, and rgb() fails. A null return is the function telling you the value is not a colour, and the caller's job is to substitute a default rather than to pass the original through.

<?php
$hostile = 'red;background:url(//evil.example/pixel.png)';

// Believing esc_attr() is enough.
$belief = sprintf( '<div style="background-color:%s">', esc_attr( $hostile ) );

// Validating the colour, then escaping for the attribute.
$checked = sanitize_hex_color( $hostile );
$correct = sprintf(
	'<div style="background-color:%s">',
	$checked ? esc_attr( $checked ) : esc_attr( '#fff8e5' )
);

echo "BELIEF:  " . $belief . "\n";
echo "CORRECT: " . $correct . "\n";
BELIEF:  <div style="background-color:red;background:url(//evil.example/pixel.png)">
CORRECT: <div style="background-color:#fff8e5">

Both lines contain esc_attr(). One of them hands the browser a second declaration that overrides the first, which is enough to make the element fetch a remote image and report the visit. The difference is not in the escaping call, and it is not visible in a diff. It is in whether a validation function was given the chance to reject the value first.

wp_kses_post() keeps style attributes, and that is documented behaviour

Run a span and a div with style attributes through wp_kses_post() on WordPress 7.0.7 and both come back intact, including a background:url() value. The function filters which elements and attributes survive; it does not check that a surviving style declaration contains only safe CSS. The escape hatch is output escaping at the point of printing, which is where the injection becomes inert. Treat wp_kses_post() as the right tool for post content from a trusted-ish author and the wrong tool for a value that lands in a style attribute.

WordPress has written the failure list down, and it is binding

On 1 February 2026 the WordPress project published AI guidelines for contributions, maintained in the AI handbook at make.wordpress.org/ai/handbook/ai-guidelines/. They apply to contributions to WordPress itself, and the plugin directory applies the same expectations to plugins through its own review. Five principles carry the weight, and the third and fifth are the ones an agent workflow collides with.

You are responsible for what you submit. The guideline states that AI tools can assist but are not contributors, and that AI output should be treated like code from an unknown external contributor: reviewed and validated before it is shared.

Disclose meaningful AI assistance. The place named is the pull request description or the Trac ticket, and the same disclosure belongs in the change description of a plugin release.

Keep the licence compatible. Contributions must remain GPLv2-or-later compatible, including AI-assisted output, and the guidelines are explicit that you should not use tools whose terms forbid using their output in GPL projects or impose additional restrictions on redistribution. In a plugin this is the header line and the repository's own licence file. Akismet, which ships in a default WordPress download, declares License: GPLv2 or later in its plugin header, and that header is the shape the directory expects.

Non-code assets count. Documentation, screenshots, images and educational material are covered by the same rules, and for media the guidelines accept licences equivalent to the public domain, naming CC0 as compatible with the GPL.

Quality over volume. This is the principle that names your defects. The guideline lists the failure modes an agent produces: generic, context-free pull request descriptions that do not reflect real testing; large, unreviewed code dumps with minimal explanation or tests; hallucinated references, with APIs, hooks and functions given as examples; and over-architected code where a simple solution would do. Then the consequence, in the project's own words: maintainers may close or reject contributions that appear to be AI slop with little added human insight.

A submission is a claim, so make the claim accurate

The practical effect of those guidelines is that the review note is part of the artefact. What you tested, with what command, and what you did not test are all reviewable statements, and they are the same statements Module 2 taught you to demand from an agent.

Tested:
- Activated on WordPress 7.0.7, PHP 8.5.6. Plugin activates with no notices in debug.log.
- `wp acme-notice list` returns the stored row for a published post.
- Render callback escaped with wp_kses_post(); hostile `<script>` stripped, `<strong>` kept.
- Colour attribute rejected `red;background:url(evil)` and fell back to the default.

Not tested:
- Editor UI in a browser: the block was not inserted in the inserter.
- PHPCS and PHPStan: not installed in the environment used.
- Multisite.

AI assistance: the render callback and block.json were drafted with an AI coding agent and
reviewed line by line. The escaping test above was written and run by hand.

Four lines of "not tested" do more for the review than any amount of prose about quality, because they tell the reviewer where to look. A contribution that claims less and lists its gaps is cheaper to review than one that claims everything, and it is the difference between a maintainer seeing a considered patch and seeing a dump.

Lab 40 minutes

Write a plugin file with four output paths, then try to break each one. Keep every command and its output; the lab is graded on the evidence, not on the fix.

  1. A post title printed inside a <h2>, a value printed inside a class attribute, a value printed inside a style attribute, and a URL printed into an href.
  2. For each, choose the function you believe is correct, then supply hostile input: a script tag in the title, a quote and an onmouseover= handler in the class, a red;background:url(evil) in the style, and a javascript: URL in the href.
  3. Run each path through real WordPress and record the output. Note which values survived unchanged.
  4. Add the validation or escaping step that removes each surviving payload, and re-run the same four inputs.
  5. Write the review note from this lesson for your file, including the licence header and a list of what you did not test.
Show what each context needs and why the obvious answer is sometimes the wrong one

The four contexts have four different correct answers, and the reason is that each one ends up in a place where a different syntax is being parsed.

For text between tags, esc_html() is correct and sufficient: it converts the characters that could start an element. For a value inside an attribute, esc_attr() is correct, and it stops the quote from closing the attribute, which is the attack that matters there. For a URL in an href, esc_url() is the right function because it rejects the scheme rather than escaping it; escaping a javascript: URL leaves a perfectly well-formed attribute pointing at script. The style attribute is the fourth case and the one that catches people, because no escaper validates CSS and the correct answer is a validation function from the set that matches the value.

What the run shows is that only one of those four, the style attribute, survives a naive choice with its payload intact. That is the useful result. It means the review question for escaping is not "did they escape" but "which context was this value built for", and the answer is checkable by running the input rather than by reading.

Two fixes are defensible for the style case. Validate against sanitize_hex_color() when the value is a colour, which is the narrow and correct answer. Or restrict the set of allowed CSS properties yourself if the value is a whole declaration, which is what WordPress's own style sanitising does for user-supplied CSS and which is more code than most plugins need.

The review note is the part people skip and the part the guidelines ask for. Naming what you did not test is not a confession; it is the information the reviewer would otherwise have to discover, and it is what separates a submission with a considered scope from a dump with a confident summary.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.