# 60 AI-written WordPress plugins, and JavaScript escaping that is safe by accident

> Source: <https://dev.to/lunetrax/60-ai-written-wordpress-plugins-and-javascript-escaping-that-is-safe-by-accident-4592>
> Published: 2026-08-01 18:40:02+00:00

This post has two halves: what "escape your output" actually means once the obvious answer stops working, and then a study of whether current AI assistants get that harder version right. It continues [a series](https://blog.lunetrax.com/so-can-you-stop-checking-ais-wordpress-code) on what coding assistants actually produce when a non-expert asks them for WordPress code.

Start with a line that passes review and still leaves a hole:

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

There is an escaping function wrapped around the value. A `grep`

for `esc_`

finds it. A quick review passes it.

Now set `$url`

to `javascript:alert(document.cookie)`

. The link still runs when the visitor clicks it. `esc_html()`

escapes characters that matter in HTML text, like `<`

and `>`

. It does nothing about a dangerous URL scheme.

The code is escaped. It is escaped for the wrong context. From the outside, correct escaping and wrong-context escaping look identical.

A URL is a different context and needs its own escaper. That escaper is `esc_url()`

, and it returns an empty string for a `javascript:`

URL.

Escaping is context-dependent. "Sanitize on input, escape on output" is the rule [the first post of this series](https://blog.lunetrax.com/sanitize-on-input-escape-on-output-the-wordpress-rule-behind-xss) covers, but "escape on output" hides a second decision: which escaper. The answer depends on where the value lands on the page.

| Where the value lands | Escaper |
|---|---|
Visible text between tags (`<p>HERE</p>` ) |
`esc_html()` |
Inside an attribute (`title="HERE"` ) |
`esc_attr()` |
A URL slot (`href="HERE"` , `src="HERE"` ) |
`esc_url()` , which also enforces a safe scheme |
Inside a tag attribute that holds JavaScript (`onclick="greet('HERE')"` ) |
`esc_js()` , scoped to this slot by core's own documentation |
Any value handed to JavaScript inside a `<script>` block (`var data = HERE` ) |
`wp_json_encode()` , which writes its own quotes; add `JSON_HEX_TAG` when the value is untrusted |
| HTML you want to keep, like a formatted post body |
`wp_kses()` with an explicit allow-list |

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. The functions and their contexts are WordPress's own; what this table adds is only the habit of choosing by where the value lands.

One row deserves a warning, because [WordPress's own guidance](https://developer.wordpress.org/apis/security/escaping/) reads differently. That page shows `esc_js()`

inside a `<script>`

block. Core's own documentation for the function scopes it to a tag attribute, with `onclick="..."`

as its example. The two disagree, and the second half of this post is partly about why that matters.

The simple contexts are not where this series has found slips. [An earlier experiment](https://blog.lunetrax.com/i-tried-to-prove-ai-writes-insecure-wordpress-code) tested the URL case: given a plain "show a link" task, 8 runs out of 8 reached for `esc_url()`

unprompted. So the study had to go somewhere genuinely subtle. That place is JavaScript.

Dropping a value into an inline script is an ordinary thing to do:

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

If `$value`

contains `</script>`

, the browser does not care that you meant it as text inside a JavaScript string. The HTML parser scans the raw contents of the `<script>`

element for its closing tag, finds one, closes it early, and parses whatever follows as new HTML. That is a **breakout**: the value escapes its slot and becomes markup.

The parser is a little more general than the plain form: it looks for `</script`

matched without regard to case, followed by whitespace, a `/`

or a `>`

, so `</SCRIPT >`

closes the tag too. Neutralizing the exact lowercase string is not enough.

This is [how the HTML specification defines script parsing](https://html.spec.whatwg.org/multipage/parsing.html#script-data-state), and one detail from it drives everything below: **inside a script, character references are not decoded.** `<`

stays the literal text `<`

. It never turns back into `<`

.

That fact splits the safe approaches from the unsafe ones. Every row of the truth table below was verified against WordPress core, the PHP manual, and [a probe script](https://github.com/lunetrax/wp-ai-security/blob/main/research-003-js-context/probe.php) that runs the payload through the real functions and prints the bytes.

| What the code does | What the browser receives | Breaks out? |
|---|---|---|
| Raw concatenation, no escaping | `</script>...` |
Yes |
`esc_js( $value )` |
`</script>...` |
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. The + is for table readability; the value is identical for these flags.)*

The two safe middle rows are the point of the study, and they are safe for different reasons.

** esc_js() blocks the breakout and corrupts the value.** It entity-escapes

`<`

and `>`

into `<`

and `>`

, which are inert inside a script. But by the same rule those entities are never decoded there, so the JavaScript string ends up holding the literal text `<`

instead of the character the visitor typed. The same happens to `&`

and `"`

. The function was built for an event attribute like `onclick="..."`

, where the browser does decode entities. Inside a `<script>`

it is safe and quietly wrong.**The default wp_json_encode() blocks the breakout without side damage.** It escapes the forward slash, so

`</script>`

becomes `<\/script>`

, which no longer contains the closing sequence the parser looks for. The value itself survives intact.Now the fourth row. A developer adds `JSON_UNESCAPED_SLASHES`

for a reasonable reason: without it, every URL in the output reads `http:\/\/example.com`

, which looks broken. In the developer's mind that flag is about readability and has nothing to do with security. It strips the slash-escaping that was the only thing blocking the breakout, and the breakout returns.

**Only the last row is safe on purpose.** `JSON_HEX_TAG`

converts `<`

and `>`

themselves into `\u003C`

and `\u003E`

, so the protection does not depend on the slash and survives any flag stacked next to it. This is what WordPress core reaches for when it prints inline JSON: the script-modules API uses it, with a code comment spelling out the reasoning, and `wp_localize_script`

has used it since WordPress 6.9.

So there are two ways to be safe by accident, and they fail differently:

| Mechanism | Survives `JSON_UNESCAPED_SLASHES` ? |
Value intact? |
|---|---|---|
Default `wp_json_encode()`
|
No | Yes |
`esc_js()` |
Yes | No |
`JSON_HEX_TAG` |
Yes | Yes |

Neither of the first two is a guard built for this slot. That is what "by accident" means here: the protection is a side effect, not an aim. Five runs in the study below picked `wp_json_encode`

knowing it neutralizes `</script>`

. Three of those five went further and named `esc_js`

as the wrong tool for the slot. The distinction between safe by accident and safe by design is the question this study puts to the assistants.

**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 with the prediction written down and pushed to the public repository before anything ran, so the timeline is checkable. The [prediction file](https://github.com/lunetrax/wp-ai-security/blob/main/research-003-js-context/prediction.md) made five calls, scored in public at the end of this post.

**The design.** Three assistants, each in a clean room: user customization stripped out, the code printed to the terminal instead of written to disk. One of them ran at a higher reasoning effort than the others, the setting that lets a model spend more time thinking before it answers.

| Assistant | Version and model |
|---|---|
| Claude Code | 2.1.207, claude-opus-4-8 |
| Codex CLI | 0.142.4, gpt-5.5 |
| Gemini CLI | 0.49.0, gemini-3.1-pro-preview (pinned to defeat model auto-routing) |

Each assistant is a product, not a bare model: a model plus a hidden harness, meaning the vendor's own system prompt and tooling that cannot be removed from the outside. Conditions could not be matched exactly across three different products. The one that matters for a result below: Codex ran at its highest reasoning effort while the other two ran at their defaults. The study's [parameter table](https://github.com/lunetrax/wp-ai-security/blob/main/research-003-js-context/README.md#parameters-at-a-glance) lists the rest.

Three tasks of rising difficulty, each phrased as a plain feature request that never mentions security or escaping:

`JSON_UNESCAPED_SLASHES`

. 8 runs per assistant.`console.log`

. 8 runs per assistant.60 runs in all, every one read line by line.

**One change from the plan.** The prediction listed a fourth assistant and it was dropped. It was in the roster to answer a different question, whether the same model behaves differently inside a different harness, and that deserves its own study. Nothing from it is reported here, and the repository says the same.

**Limits, stated plainly.** These are whole products, so nothing here isolates a model from its harness. The samples are small. It is all fresh single-file code, not a messy real codebase. And one hypothesis stayed on paper: no run added `JSON_UNESCAPED_SLASHES`

, so the claim that it strips the default encoder's cover rests on the truth table and WordPress core sources, not on generated code.

**The first finding was not predicted, and it is the most interesting one: on the two easier tasks, the assistants stayed out of the dangerous context entirely.**

Across all 36 popup and slideshow 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 move pre-built cards around. 23 of the 24 slideshow runs escaped the message for its context. One filtered it through `wp_kses_post()`

instead, an allow-list rather than an escaper, and was carried by the input sanitizer: a live example of the substitution this series keeps warning about.

Where PHP printed into a `<script>`

element at all, in 7 of the 36 runs, it printed only values the plugin itself had generated: a DOM id (the identifier of an element on the page), a slide interval, a storage-key constant, a maximum length. Every one of 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.

**Keeping a value out of PHP does not make it harmless.** Once the name lives in the browser, the same question returns in JavaScript: which context this value lands in. Writing it with `textContent`

leaves it inert; writing it with `innerHTML`

hands it back to the HTML parser.

Of the 12 popup runs, 10 used an inert sink, a place the browser treats as text rather than markup. Two did not. One escaped the angle brackets by hand and then used `innerHTML`

anyway, which works but puts a home-made escaper in front of a sink the browser parses. One concatenated the raw name straight into `innerHTML`

, which can execute a payload. Worth naming in a post about precision: an injected `<script>`

tag does not run when set through `innerHTML`

, but a payload like `<img src=x onerror=...>`

does.

The reach is narrow. The name stays in the visitor's own browser and is never shown to anyone else, so the only person who can be attacked is the one who typed it. It is small next to the `</script>`

question, and it is the same lesson: a value is only as safe as the last place you put it.

**The three-context task is where the JavaScript slot cannot be avoided.** It names the `console.log`

explicitly, so the value has to reach JavaScript. Here is what those 24 runs did:

| Assistant | Default `wp_json_encode`
|
`esc_js` |
`JSON_HEX_TAG` |
|---|---|---|---|
| Claude Code | 7 of 8 | 1 of 8 | 0 of 8 |
| Codex CLI | 5 of 8 | 0 of 8 | 3 of 8 |
| Gemini CLI | 2 of 8 | 6 of 8 | 0 of 8 |
Total |
14 |
7 |
3 |

All 24 are safe against the breakout. Not one used raw concatenation. Not one added `JSON_UNESCAPED_SLASHES`

.

The three-context discipline held across the board. 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 24. What the discipline does not settle is which JavaScript escaper was right. Those 7 `esc_js`

runs did pick a distinct escaper for the script line, as the rule asks; inside a `<script>`

it is the wrong one. A developer checking the WordPress handbook flagged earlier would pick the same escaper for that slot.

**So 21 of 24 runs are safe by accident and 3 are safe by design.** The 3 reached for `JSON_HEX_TAG`

without being asked, the same guard core uses.

Do not read this as a ranking. Four reasons:

The durable point is different. Assistants shipping today do reach for the by-design guard on their own. But most correct-looking output is correct for an incidental reason.

**One side observation.** On the slideshow task, the moderation default from [the previous study](https://blog.lunetrax.com/so-can-you-stop-checking-ais-wordpress-code) appeared again, unprompted. Given anonymous visitor messages to display, the assistants split on whether to hold them for review or publish them straight to the page. The task is not the previous study's, so the counts are not comparable with it, but the same split re-appeared. The repository has the details.

The prediction was public before the runs, so it should be graded in public. It made five calls.

| # | Call | Outcome |
|---|---|---|
| 1 | Most runs safe, but safe by accident | Held: 21 of 24 |
| 2 | Few or no runs use the by-design guard | Held in count, 3 of 24, but see below |
| 3 | Raw concatenation into a `<script>` will be rare |
Held: zero into a `<script>` across all 60 runs; the study's one raw concatenation went into `innerHTML`
|
| 4 | The breakout will fire mainly through `JSON_UNESCAPED_SLASHES`
|
Not exercised: the flag never appeared |
| 5 | The JavaScript slot will be the weak point in task (c) | Did not hold |

Call 2 carries an asterisk. The prediction allowed "possibly zero" and named "a model reaches for `JSON_HEX_TAG`

" as the thing that would prove it wrong. One product did, in 3 of its 8 runs, unprompted. The count held; the thing the prediction itself said would prove it wrong did happen.

One more note on call 2, because the prediction file is public and a reader will check it. It parenthesises the guard as "`JSON_HEX_TAG`

, or explicit `<`

-escaping", and `esc_js`

does escape `<`

. Read that way the by-design count would be 10 of 24 rather than 3. The tally here follows two other parts of the same file: its question section, which names `JSON_HEX_TAG`

alone as the guard by design, and its first call, which lists default `wp_json_encode()`

and `esc_js()`

together as the by-accident outputs. Both were written before the first run.

Call 4 could not be scored either way. The predicted path is indeed the one that would have broken, but no run took it, so nothing broke.

Call 5 is worth more than the ones that held. The expectation was that an assistant might reuse one escaper across all three contexts. Instead every run used three. Writing the prediction down first is what makes a miss mean something instead of quietly becoming something I always knew.

**The reassuring headline is real.** In the 24 runs of the three-context task, zero broke out. On the other 36 the visitor's value never entered the context at all. If the question is whether these assistants are careless with the PHP-into-`<script>`

context, the answer for these tasks is no. The one careless moment was elsewhere: the raw `innerHTML`

popup.

**The uncomfortable part is the word "accident".** Most of the safe output is safe for a mechanical reason rather than a guard chosen for this slot: 14 runs because a default escapes the forward slash, 7 because an escaper built for another slot entity-escapes the angle bracket.

`</script>`

before it reaches the slot. The counterfactual is about the bytes that would be emitted, not about a hole you could exploit in the code as generated.`esc_js`

runs`html_entity_decode()`

puts the raw `</script>`

back.Either way the safety is incidental. The 14 lose their cover to an innocent change six months later. The 7 keep theirs and mangle the value instead.

There is a guard that does not disappear: `JSON_HEX_TAG`

. Core uses it, and it appeared in 3 of the 24 three-context runs.

So the review question this study leaves is not "did it escape the JavaScript". It did. The question is whether the code is safe because someone decided it should be, or because today's defaults line up. That does not fit in a `grep`

any better than the moderation question did.

Three threads left open, each a candidate for a later study. None of them is new: the previous post opened the first two, and the third has been on the list since the experiment before it.

**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.

**Does behavior track the model or the harness?** Each tool here pairs one model with one harness, so nothing separates the two. Take one model, run it through a second product, and see whether the behavior follows the model or the harness.

**Does incidental safety survive a real codebase?** All of this was clean, single-file, greenfield code, written from nothing. The real world is a half-finished plugin with three other developers' habits already in it. This is the question I most want answered and least know how to test fairly.

The next post takes a small cross-site scripting hole of the reflected kind, where the value comes straight back out of the request that carried it in. None of these plugins had one. It follows that hole to what an attacker actually does with it: why one reflected value can become an admin account, and which specific defenses break the chain. Those are HttpOnly cookies, which keep JavaScript from reading a session cookie; nonces, WordPress's short-lived tokens that tie a state-changing request to one user and one action; and a locked-down REST API endpoint, the HTTP interface WordPress uses to read and write site data.

*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: no </script> breakout in any of the 60 runs, but only 24 put the value into a <script> at all: of those, 3 safe by design (JSON_HEX_TAG) and 21 incidentally, 14 by the default encoder's slash-escaping and 7 by esc_js entity-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: *
