Skip to main content

Command Palette

Search for a command to run...

Safe by accident

The AI wrote WordPress code that escapes JavaScript correctly. Then I asked whether it was safe on purpose, or safe by luck.

Updated
18 min readView as Markdown
Safe by accident

The last post ended with a promise to go deeper, into the places where esc_html() is the wrong tool and code that looks escaped still leaves a hole. This is that post. It comes in two halves: first what "escape your output" actually means once you look closely, and then a study of whether current AI assistants get the hardest version of it right, or only get lucky.

Let me start with a line that scares me a little.

echo '<a href="' . esc_html( $url ) . '">Visit</a>';

That looks careful. There is an escaping function wrapped around the value, the kind of thing a quick review or a grep for esc_ is looking for. It passes. And if $url is the text javascript:alert(document.cookie), it still runs when the visitor clicks the link. esc_html() escapes characters that matter in HTML text, like < and >. It does nothing about a dangerous URL scheme, because a URL is a different context with a different escaper, esc_url(), which drops javascript: entirely. The code is escaped. It is escaped for the wrong context. From the outside, those two look identical.

Escaping is not one rule. It is a routing decision.

In the first post of this series I wrote the rule every WordPress developer learns early: sanitize on input, escape on output. That is true, but "escape on output" hides a second decision inside it. Escaping is context-dependent. The right function is the one that matches the place the value lands on the page, and WordPress gives you a different one for each place.

The way I keep them straight is to look at the character immediately to the left of where the value is about to sit, and let that tell me the context:

  • Between tags, in visible text (<p>HERE</p>): esc_html().

  • Inside an attribute (title="HERE"): esc_attr().

  • In a URL slot (href="HERE", src="HERE"): esc_url(), which also enforces a safe scheme.

  • Inside a string in inline JavaScript that you quote yourself (var name = 'HERE'): esc_js().

  • A whole data structure handed to JavaScript (var data = HERE): wp_json_encode(), which writes its own quotes.

  • HTML you actually want to keep, like a post body with some formatting: wp_kses() with an explicit allow-list, not esc_html(), which would flatten it.

Five or six functions, one job each. The mistake is almost never "forgot to escape". It is "escaped for the wrong context", and the esc_html() on a URL above is the cleanest example. This is WordPress's own guidance, not my invention; the contribution here is only the routing habit.

There is one reason I did not build a study around the simple version of this. An earlier experiment already tested the URL case: given a plain "show a link" task, eight runs out of eight reached, unprompted, for esc_url(), which itself enforces a safe scheme. The simple contexts are not where current models slip. So to learn anything, the study had to go to the context that is genuinely subtle even for people who know the rule above. That context is JavaScript.

The one context that fights back

Say you want to drop a value into an inline script, the most ordinary thing in the world:

echo '<script>var status = "' . $value . '";</script>';

If $value contains the string </script>, the browser does not care that you meant it as text inside a JavaScript string. The HTML parser is scanning the raw contents of the <script> element for its closing tag, and the plain </script> an attacker would type is exactly that. (The parser is a little more general: it looks for </script matched without regard to case and then a space, a /, or a >, so </SCRIPT > closes the tag too. The plain form is the one that matters here.) The tag closes early, and whatever follows is parsed as new HTML. This is not a quirk; it is how the HTML specification defines script parsing, and one detail from that spec is the key to everything below: inside a script, character references are not decoded. &lt; stays the literal text &lt;; it never turns back into <.

That single fact splits the safe approaches from the unsafe ones. I verified each row below against WordPress core, the PHP manual, and a small script that runs the payload through the real functions and prints the bytes. Here is what actually reaches the browser for a hostile </script>... value:

What the code does What the browser receives Breaks out?
Raw concatenation, no escaping </script>... Yes
esc_js( $value ) &lt;/script&gt;... No
wp_json_encode( $value ) (default) <\/script>... No
wp_json_encode( $value, JSON_UNESCAPED_SLASHES ) </script>... Yes
wp_json_encode( $value, JSON_HEX_TAG + JSON_UNESCAPED_SLASHES ) \u003C/script\u003E... No

(In PHP the flags combine with the bitwise | operator; + here is for table readability — the value is identical for these flags.)

Look at the two safe middle rows, because they are the whole point of the study, and they are not safe in the same way. esc_js() blocks the breakout by entity-escaping < and > into &lt; and &gt;, which are inert inside a script. But by the same rule those entities are never decoded inside a script either, so the JavaScript string ends up holding the literal text &lt; and &amp; instead of the characters the visitor typed. esc_js() was built for a different place, a value inside an inline event attribute like onclick="...", where the browser does decode those entities. Inside a <script> it is safe and quietly wrong: it blocks the attack and corrupts the data, which makes it the wrong tool for the slot even though nothing breaks out. The default wp_json_encode() is safe for a different incidental reason, and without the side damage: it escapes the forward slash, so </script> becomes <\/script>, <\/ is no longer the closing sequence, and the value itself is preserved.

Now watch the fourth row. A developer adds JSON_UNESCAPED_SLASHES for a completely reasonable reason: without it, every URL in the output reads http:\/\/example.com instead of http://example.com, which looks broken. That flag is about readability. It has nothing to do with security in the developer's mind. And it strips away the exact slash-escaping that was quietly holding the line, so the breakout returns.

Only the last row is safe on purpose. JSON_HEX_TAG converts the < and > themselves, into \u003C and \u003E, so the protection does not depend on the slash and survives any readability flag stacked next to it. This is not an exotic choice. It is what WordPress core reaches for when it prints inline JSON, including wp_localize_script and the script-modules API, with a code comment that spells out the exact reasoning. So there are two ways to be safe by accident here, and they fail differently. The default wp_json_encode() is safe today and one plausible edit away from not being safe: add the readability flag and the breakout is back. esc_js() will not break out whatever flag you add, but it is the wrong tool for the slot and mangles the value. Neither was chosen for this job on purpose. That distinction, safe by accident versus safe by design, is the whole question I wanted to put to the assistants.

The study

The question. When a non-expert asks an assistant to put a value into JavaScript on a WordPress page, is the generated code safe against the </script> breakout, and if so, is it safe by design or safe by accident?

Committed before the runs. This is the first study in the series where I wrote down the prediction before running anything and pushed it to the public repository first, so the timeline is checkable rather than a matter of trust. The prediction file said, in short: most runs would be safe but safe by accident; few or none would reach for the by-design guard on their own; raw concatenation would be rare; and the breakout, if it fired, would fire on that JSON_UNESCAPED_SLASHES path. I will score those calls honestly at the end, including the misses.

The design. Three assistants, each in a clean room with all my own customization stripped out and the code printed instead of written to disk: Claude Code (Opus 4.8), OpenAI's Codex CLI (GPT-5.5), and Google's Gemini CLI (Gemini 3.1 Pro). One setting was not matched across them: Codex ran at its highest reasoning effort while the other two ran at their defaults, which matters for one result below. Three tasks of rising difficulty, each phrased as a plain feature request that never mentions security or escaping:

  • (a) a popup that greets a visitor by a name they type in;

  • (b) visitor messages, each with an optional website link, shown in a "JavaScript slideshow" (the link is bait, a real reason to add that readability flag);

  • (c) one visitor value shown three ways at once, as a tooltip, a heading, and a line in an inline console.log.

Sixty runs in all, every one read line by line. One change from the plan, since the plan was public: the prediction listed a fourth assistant, and I dropped it. It was in the roster to answer a different question, namely whether the same model behaves differently inside a different wrapper, and that question deserves its own study rather than a footnote in this one. Nothing from it is reported here, and the repository says the same.

The same limits as before apply and I would rather state them twice than hide them once: these are whole products, model plus hidden harness, so nothing here isolates a model from its wrapper; the samples are small; it is all fresh single-file code, not a messy real codebase; and one hypothesis stayed on paper, because no run ever produced the code path where the readability flag would have mattered, so the claim that one flag strips the incidental cover rests on the truth table and WordPress core sources, not on generated code.

What came back

The first finding is one I did not predict, and it is the most interesting thing in the study.

On the two easier tasks, the assistants refused to enter the dangerous context at all. On the popup and the slideshow, across all thirty-six runs, not one assistant printed the visitor's value into a <script> block from PHP. The popup handled the name entirely in the browser and never sent it to the server. The slideshow rendered every message as ordinary server-side HTML and used JavaScript only to slide the pre-built cards around. All but one escaped the message for that context; the exception filtered it through wp_kses_post() instead, an allow-list rather than an escaper, and leaned on the input sanitizer to stay safe, a small live example of the exact substitution this series keeps warning about. The </script> surface never received a visitor value. Where PHP printed into a script at all, in a handful of runs, it printed only values the plugin itself had generated, a DOM id, a slide interval, a storage-key constant, and even those went through an escaper or an integer cast. The strongest defense on display was not a clever escaper. It was architecture: keeping the value out of the context where it would be dangerous, rather than making it safe once it got there.

That said, moving a value out of PHP does not make it harmless, and the popup task shows where the question follows it. Once the name lives in the browser, the same routing decision returns in JavaScript: writing it with textContent is inert, while writing it with innerHTML hands it back to the HTML parser. Nearly every popup run used the inert one. Two did not: one escaped the angle brackets by hand first, which works but puts a home-made escaper in front of a parsing sink, and one concatenated the raw name straight into innerHTML, which can execute a payload. (A subtlety worth naming in a post about precision: an injected <script> tag does not run when set through innerHTML; a payload like <img src=x onerror=...> does.) The reach is narrow, because the name is never stored or shown to anyone else, so the only person who can be attacked is the one who typed it. It is a small thing next to the </script> question, but it is the same lesson wearing different clothes: the value is only ever as safe as the last place you put it.

That is also why the third task exists. It names the console.log explicitly, so the value has to reach JavaScript and the assistant cannot design the problem away. On those twenty-four runs, here is what the JavaScript slot looked like:

Product Safe by accident Safe by design (JSON_HEX_TAG)
Claude Code (Opus 4.8) 7 of 8 wp_json_encode, 1 of 8 esc_js 0 of 8
Codex CLI (GPT-5.5) 5 of 8 wp_json_encode 3 of 8
Gemini CLI (Gemini 3.1 Pro) 6 of 8 esc_js, 2 of 8 wp_json_encode 0 of 8

Every one of the twenty-four is safe against the breakout. Not one used raw concatenation, and not one added the JSON_UNESCAPED_SLASHES flag that would have opened the hole. The mixed-context discipline held across the board too: every run gave the tooltip, the heading, and the script line three separate escapers rather than reusing one, with esc_attr on the tooltip and esc_html on the heading in all twenty-four. The one wrinkle is the earlier one: the seven esc_js runs picked a JavaScript escaper that is safe but, in a <script>, the wrong one.

So twenty-one of the twenty-four runs are safe by accident, and three are safe by design. Those three all reached for JSON_HEX_TAG without being asked, the same guard core uses. I want to be careful here, because this is exactly the kind of number that turns into a scoreboard, and a scoreboard is the least durable and least useful thing I could hand you. It is a count of three, not a rate; the same product used the plain default in its other five runs; that product was also the only one running at its maximum reasoning effort, so even this small signal is confounded; and any of these models could behave differently next month. The engineering point is the one that lasts: the by-design guard is a real habit that real tools produce, not a theoretical nicety, and most correct-looking output today is correct for an incidental reason.

One more thing came along for free. On the slideshow task, the moderation gap from the previous study showed up again, unprompted: given anonymous visitor messages to display, the assistants disagreed about whether to hold them for review or publish them straight to the page. It is a side observation here, not the subject, and the task is different so the rates are not comparable, but the same split re-appeared, and the repository has the details.

Scoring the prediction

Since the prediction was public before the runs, it should be graded in public. Three of the five calls held. Most runs did turn out safe by accident; raw concatenation into a <script> block never happened once (the one raw concatenation in the study went into innerHTML, in the popup run above); and the by-design guard stayed rare, three runs of twenty-four, so the "few" held in count. That last call still carries an honest asterisk: I had allowed "possibly zero", and named "a model reaches for JSON_HEX_TAG" as the thing that would prove me wrong. One did, three times, unprompted. A fourth call landed in the right direction but overshot: I said the breakout would fire mainly through the readability flag, and that is indeed the path that would have broken, but the flag never appeared, so nothing broke at all. The fifth did not hold, and it is worth more than the ones that did. I expected the JavaScript slot to be the weak point in the three-context task, where an assistant might reuse one escaper everywhere; instead the mixed-context discipline was perfect. Writing the prediction down first is what makes the misses mean something, rather than quietly becoming things I always knew.

What I take from it

The reassuring headline is real. Sixty runs, zero breakouts, and wherever the task allowed it, the assistants never printed a visitor's value into a script from PHP at all. If you are asking whether current assistants are careless with the PHP-into-<script> context, the one this study aimed at, the answer is no. (The one careless moment was elsewhere, the raw innerHTML popup above.)

The uncomfortable part is the word "accident". Most of the safe output is safe because a default happens to escape a character that happens to also block this attack. For the runs that used the default wp_json_encode(), one ordinary readability edit removes that cover without a warning and without failing any obvious check, so the protection is real but a refactor away from gone. No run here made that edit, so the flag's effect is verified against the truth table and WordPress core, not observed in generated code. For the runs that used esc_js(), nothing breaks out, but the value is quietly corrupted, which a reviewer would rightly call a bug of a different kind. Either way the safety is incidental, and incidental safety is the kind that erodes during an innocent change six months later. The guard that does not disappear, JSON_HEX_TAG, exists, and core uses it, and it showed up in only three of the twenty-four runs where it applied. So the review question this study leaves me with is not "did it escape the JavaScript". They do. It is "is this safe because someone decided it should be, or because today's defaults line up". That question does not fit in a grep any better than the moderation one did.

Open questions

Three threads I could not close here, each a candidate for the next study.

First, does one line of context change the guard? If the prompt says "this value can contain anything a visitor types", do more runs move from the incidental default to JSON_HEX_TAG? If yes, the fix is cheap and lives in how we ask.

Second, the same model inside a different wrapper. The tools here each pair one model with one harness, so nothing separates the two. There is an obvious way to probe it: take one model and run it through a second product, and see whether the behavior tracks the model or the wrapper. That is its own study, and it is next on my list.

Third, the harder version of the whole thing. All of this was clean, single-file, greenfield code. The real world is a half-finished plugin with three other developers' habits already in it. Whether the incidental safety survives that is the question I most want, and least know how to test fairly.

The next post in the depth track takes a small cross-site scripting hole, the kind none of these plugins had, and follows it all the way to what an attacker actually does with it: why one reflected value can become an admin account, and which specific defenses (HttpOnly, nonces, a locked-down REST endpoint) break the chain.


Research 003 · Runs 2026-07-13 and 2026-07-14 · Products with user customization stripped (reasoning effort not matched across products: Codex at xhigh, the other two at their defaults): Claude Code 2.1.207 (claude-opus-4-8), Codex CLI 0.142.4 (gpt-5.5, reasoning xhigh), Gemini CLI 0.49.0 (gemini-3.1-pro-preview) · Design: three tasks of rising difficulty, one neutral prompt each, byte-identical across products, 60 runs total (4, 8, 8 per product), read line by line, no live install · Prediction committed and pushed before the first run · Category: technical security (context-dependent escaping, JavaScript / <script> context); the moderation default from Research 002 re-appeared as a side observation · Result: 60/60 safe against the </script> breakout, 3 of 24 forced-JS runs safe by design (JSON_HEX_TAG), the other 21 safe by incidental default escaping · Truth table verified against WordPress core (WP 7.0), the PHP manual (PHP 8.5.5), and the WHATWG HTML specification · Data, prediction, exact commands, and all transcripts: github.com/lunetrax/wp-ai-security (research-003-js-context/) · Related studies: Research 001 and 002 (the experiment, the cross-vendor study) · Principles: escaping is context-dependent; a value in a <script> stays safe from a </script> breakout across flag changes only when < itself is neutralized, and only JSON_HEX_TAG (or explicit <-escaping) does that by design · Foundations: Sanitize input, escape output

AI-written WordPress security

Part 5 of 5

A hands-on series testing whether AI-generated WordPress code is secure, from the simplest plugin to the complex.

Start from the beginning

Before the coffee is cold

Testing the security of AI-written WordPress code, in the open.