CVE-2026-104407 The nonce that guarded the page and not the save in PowerPress

Intrudify's autonomous pentesting engine discovered a cross-site request forgery vulnerability in the PowerPress Podcasting plugin by Blubrry, affecting all versions up to and including 11.17.9. PowerPress does protect its settings saves with a WordPress nonce. The protection is attached to the question of whether the request is on a PowerPress admin page, and a second handler that writes the same option runs on every admin page, outside that branch. One forged POST, from any page a logged-in administrator opens, stores a setting that PowerPress prints raw inside a script element on every front-end page.

Discovered by
Intrudify autonomous engine
Validated & reported by
Disclosure
5 October 2026
Severity
High · CVSS 7.1
Vulnerability class
CSRF to stored XSS, CWE-352
Affected
PowerPress Podcasting ≤ 11.17.9
Fixed in
11.17.11
Active installs
20,000+
Attacker privilege
None, the attacker has no account
Victim
An administrator who opens a page, then every visitor
Execution
On page load, no click on either side

Summary

PowerPress is the podcasting plugin for WordPress, in use for eighteen years and on about twenty thousand sites. Its settings screens post back to wp-admin, and the plugin guards those posts with check_admin_referer('powerpress-edit'), which is the correct WordPress mechanism.

The guard sits inside an if that first asks whether $_GET['page'] names a PowerPress screen. Everything indented under that question is protected. The function then continues for another sixteen hundred lines, and on its last two lines, outside that branch, it loads the player administration file and calls its handler. That handler reads $_POST['action'], and for two action values it writes $_POST straight into the powerpress_general option. It performs no nonce check of its own, and in 11.17.9 nothing above it performed one either.

The capability check at the top of the function is intact, so the forged request has to arrive in an administrator's session. That is what makes it a cross-site request forgery and not a privilege escalation: the attacker supplies the request, the administrator's browser supplies the authority. What the attacker gets back is a stored setting, and new_window_width is echoed into a script element in wp_head() with no escaping, on every front-end page, for every visitor.

Technical detail

The protected path

// powerpressadmin.php, PowerPress 11.17.9. The protected save.

function powerpress_admin_init()                            // line 112
{
    ...
    if( !current_user_can(POWERPRESS_CAPABILITY_MANAGE_OPTIONS) )   // line 142
        return;                                        // 'manage_options'
    ...
    // Security step, we must be in a powerpress/* page...  // line 193
    if( isset($_GET['page']) && ( strstr($_GET['page'], 'powerpress/' ) !== false
            || strstr($_GET['page'], 'powerpressadmin_' ) !== false ) )
    {
        if( isset($_POST['Feed']) || isset($_POST['General'])  )
        {
            check_admin_referer('powerpress-edit');          // line 199
            ...

This is the branch the comment calls the security step, and inside it the plugin behaves. A settings POST without a valid powerpress-edit nonce is stopped by WordPress with a 403 and the message about an expired link. The measurement below confirms it on the same install, in the same session, with the same payload.

The path that runs anyway

// the same function, 1630 lines further down, after that branch has closed

    require_once( POWERPRESS_ABSPATH .'/powerpressadmin-player.php' );  // line 1830
    powerpress_admin_players_init();                            // line 1831
}

add_action('admin_init', 'powerpress_admin_init');               // line 1862

// No condition on it. admin_init runs on every wp-admin request, so this
// call is reached whatever ?page= says, including when there is no ?page=
// at all and the nonce check 1630 lines above was never entered.

admin_init fires on every request into wp-admin, so the last two lines of the function execute on the dashboard, on the post list, on a plugin's own screen, and on anything else an administrator might have open. No page parameter is needed, which also means the branch carrying the nonce check is never entered.

// powerpressadmin-player.php 11.17.9, the whole of the handler

function powerpress_admin_players_init()
{
    $action = (isset($_GET['action']) ? $_GET['action']
                : (isset($_POST['action']) ? $_POST['action'] : false) );
    if( !$action )
        return;

    switch($action)
    {
        case 'powerpress-select-player': {                 // line 22
            $SaveSettings = $_POST['Player'];
            powerpress_save_settings($SaveSettings, 'powerpress_general');
        } break;
        case 'powerpress_bplayer':{                        // line 40
            $GenSettings = $_POST['General'];
            powerpress_save_settings($GenSettings, 'powerpress_general');
        } break;
    }
}

// Two cases, two writes into the site's option table, no nonce and no
// referer check in either of them.

Two cases, both unprotected. The report that led to this CVE named powerpress_bplayer. The release diff shows the vendor fixed both, and the measurement below confirms that powerpress-select-player reaches the same option with the same effect through a differently named $_POST key.

// powerpress.php:1168, the function both cases call

$Settings = get_option($field);
foreach ($SettingsNew as $key => $value) {
    $Settings[$key] = $value;          // every key the POST carried
}

// A merge, with no allow-list of key names and no sanitiser. The request
// decides which settings exist and what they hold.

The saver merges whatever keys it is handed into the stored option. A forged request is therefore not limited to the two settings used here, and no sanitiser stands between the request body and the row in wp_options.

What one of those settings does

// powerpress.php, printed by wp_head() on every front-end page

$new_window_width = 420;                                     // line 486

if( isset($Powerpress['new_window_width']) && $Powerpress['new_window_width'] > 0 )
    $new_window_width = $Powerpress['new_window_width'];     // line 490

if( empty($Powerpress['new_window_nofactor']) )
    $new_window_width  += 40;                                // line 501
?>
<script type="text/javascript"><!--
  function powerpress_pinw(pinw_url){window.open(pinw_url, 'PowerPressPlayer',
    'toolbar=0,status=0,resizable=1,width=<?php echo ($new_window_width); ?>,height=...');}
                                      ^ line 506, inside a script element

// A fresh install with no configuration already serves this line. The
// lab's home page carried width=460,height=320 before anything was saved.

new_window_width is the width of the pop-up player window, a number in the settings screen and a string in the database. It is concatenated into a JavaScript string literal that lives inside a script element, so the escaping that matters is HTML escaping, and there is none. A value containing </script> ends the element early and everything after it is markup.

Two details decide whether the value survives the trip. The guard on line 489 is a loose comparison against zero, which a string beginning with a digit passes. And line 501 adds forty to the width unless new_window_nofactor is set, and that addition coerces the string to the number 1 and destroys the payload. The attacker sets new_window_nofactor in the same forged POST, because the merge accepts any key.

// 11.17.9, home page, no cookies, after one forged POST

function powerpress_pinw(pinw_url){window.open(pinw_url, 'PowerPressPlayer',
  'toolbar=0,status=0,resizable=1,width=1</script><script>alert(document.domain)</script><script>/*,height=240');}

// and with 420 in that same field, same route, same release

  'toolbar=0,status=0,resizable=1,width=420,height=240');}

// The stored string closes PowerPress's own script element, opens one of
// its own, and opens a third whose /* comments out the rest of the line.

Verification

The chain was driven over HTTP on a lab install running WordPress 7.1.3, PowerPress 11.17.9 and PHP 8.3, with no other plugin active. Each POST was sent in a real administrator session, carrying a Referer from another origin and no nonce, which is what a form on an attacker's page produces. The front page was then fetched with no cookies at all and opened in Chromium.

// WordPress 7.1.3, PHP 8.3, PowerPress alone. Every row is one POST from
// an administrator's session carrying a cross-site Referer and no nonce,
// then the home page opened in Chromium with no cookies at all.

  release   route and payload                      HTTP  stored     width served  dialog
  11.17.9   ?page=powerpressadmin_basic            403   no         (no change)   -        (control)
  11.17.9   index.php  powerpress_bplayer         200   verbatim   the payload   "127.0.0.1"
  11.17.9   index.php  powerpress-select-player   200   verbatim   the payload   "127.0.0.1"
  11.17.9   same route, width=420                  200   420        420           none     (control)
  11.17.9   payload, new_window_nofactor=0         200   verbatim   41            none     (control)
  11.17.11  index.php  powerpress_bplayer         403   no         (no change)   -
  11.17.11  the row 11.17.9 already poisoned       -     verbatim   1             none

// Row 1 is the plugin's own protection working: the same settings, the same
// session, the same missing nonce, on the path the nonce guards. Rows 4 and
// 5 are the instrument's controls. Row 5 is the same hostile string stored
// and rendered harmlessly, because += 40 coerces it to the number 41.

The first row is the control that matters most. It is the same payload, the same session and the same missing nonce, sent to the path the nonce guards, and it is refused. The plugin's CSRF protection works. The finding is that a second door into the same room has no lock on it.

The last two rows are the fixed release answering the same questions: the forged POST is refused, and a value already stored by an earlier version renders as the number 1 with no dialog, because the patch also hardened the point where the value is printed.

The browser half

The table above replays what a browser would send. The browser was then asked to do it for itself, because a cross-site POST is only a cross-site POST if the session cookie travels with it.

// The same chain with no curl in it. The browser logs in as the
// administrator at 127.0.0.1:8296, waits, and is then sent to an attacker
// page that auto-submits the form. localhost and 127.0.0.1 are different
// sites for cookie purposes; 127.0.0.1:8300 and :8296 are the same site.

  browser              attacker page    cookie age   authenticated  option written
  Chromium 148.0.7778  localhost:8300   seconds      yes            yes
  Chromium 148.0.7778  localhost:8300   over 2 min   no             no             (control)
  Chromium 148.0.7778  127.0.0.1:8300   over 2 min   yes            yes
  Firefox 150.0.2      localhost:8300   over 2 min   yes            yes

// Row 2 is the only one that stops: the POST lands on wp-login.php with
// reauth=1, unauthenticated, and nothing is written. Row 3 changes the
// attacker's host and nothing else, so what row 2 measures is site-ness
// and not session expiry. Row 4 changes the browser and nothing else.

WordPress sets no SameSite attribute on its authentication cookies, so what happens to a forged POST is decided by the browser's default. Chromium withholds such a cookie from a cross-site POST once it is more than two minutes old, which is row 2. Changing only the attacker's host, so that the page is served from the target's own site, carries the cookie at the same age. Changing only the browser does the same: Firefox sent it on a genuinely cross-site POST with no age limit at all.

The reason to publish all four rows is that any one of them alone would mislead. Row 2 on its own reads as a verdict of not exploitable in a modern browser. Rows 3 and 4 are what that claim has to survive, and it does not. A browser default is also not something the plugin can rely on, which is the whole argument for the nonce that 11.17.11 adds.

Impact

The attacker needs no account on the target. They need an administrator to open a page they control, which is the ordinary condition for cross-site request forgery: a link in an e-mail, a comment, an advert, or any page on the wider web that can be made to carry a form.

What lands is a stored setting, and the setting chosen here is printed inside a script element on every front-end page of the site. Script injected there runs for every visitor on the site's own origin, logged in or not, on the home page and on every post. For a podcast site that means the audience, which is the whole point of the site, and for the administrator it means their next visit to the front end runs the attacker's code in their session.

The write is also broader than the one setting used to demonstrate it. The saver merges arbitrary keys into powerpress_general, the option that holds the plugin's feed and player configuration, so the same forged request can change settings the proof of concept never touches.

Neither victim has to click. The administrator's part is opening a page that auto-submits a hidden form. The visitor's part is loading a page, because the injected script is in wp_head() and runs during load.

One qualification belongs here, because it was measured. A current Chromium withholds a cookie that carries no SameSite attribute from a form POST made by an unrelated site, unless that cookie is less than two minutes old, and WordPress sets no such attribute on its session cookies. The same measurement shows the two conditions under which the forged request arrives authenticated anyway: a fresh session, and an attacker page served from the site's own domain, which any content-hosting feature or subdomain provides. Firefox 150 sent the cookie on the same cross-site POST that Chromium 148 refused. That protection belongs to the browser, it varies by vendor and it can be switched off. The nonce is the one defence the application owns, and on this path 11.17.9 did not have it.

Remediation

Update PowerPress to 11.17.11 or later. There is no 11.17.10 on wordpress.org: the changelog lists one, and the plugin repository has no such tag and serves no such download, so 11.17.11 is the first release that carries the fix.

// powerpressadmin-player.php in 11.17.11, both cases

   case 'powerpress-select-player': {
+      check_admin_referer('powerpress-edit');

   case 'powerpress_bplayer':{
+      check_admin_referer('powerpress-edit');

// powerpress.php:489, where the value is printed

-  if( isset($Powerpress['new_window_width']) && $Powerpress['new_window_width'] > 0 )
-      $new_window_width = $Powerpress['new_window_width'];
+  if( isset($Powerpress['new_window_width']) && intval($Powerpress['new_window_width']) > 0 )
+      $new_window_width = intval($Powerpress['new_window_width']);

// powerpress.php:1156, where the value is saved

+  if ($field == 'powerpress_general') {
+      foreach (['new_window_width', 'new_window_height'] as $ppDim) {
+          if (isset($Settings[$ppDim]))
+              $Settings[$ppDim] = max(0, min(9999, intval($Settings[$ppDim])));
+      }
+  }

Three layers, and the order is worth noticing. The nonce check closes the route, the intval() at the print closes the sink, and the clamp at the save closes the store. Any one of them would have stopped this payload. The plugin shipped none of them on this path.

After updating, read the stored value back before assuming the site is clean. The fixed release neutralises an already-poisoned row at the point where it prints, measured in the last row of the table above, and it leaves the hostile string in the database until a save runs through the new clamp. On a site that was exploited, wp option get powerpress_general tells you in one line.

For developers, the rule this one is about: a guard protects the code it encloses, and nothing else. A condition that selects which page is being rendered is not a condition that selects which work is being done, and a handler attached to admin_init runs everywhere by definition.

The wider lesson

The nonce in this plugin was never missing. It is minted on the settings form, it is verified on the settings POST, and that verification is one of twenty-four check_admin_referer calls in the same file, naming eighteen distinct actions. This is a team that knows the mechanism and uses it.

What failed is scope. The check is nested two levels deep inside a question about $_GET['page'], and the save it was meant to protect ended up outside that nesting because the function grew and the player handling was appended to the end of it. Nothing in the code reads as a decision to leave that path open. It reads as a function that outgrew the brace its protection lives in.

That makes it the third finding in this research set where CSRF protection was present, correct, and applied to one path and not another. In Photo Gallery by Supsystic the protection was a list of action names and one reachable action was missing from it. In PublishPress Capabilities the check ran but could be made to pass. Here the check runs and the request simply walks past it. All three plugins would pass a review that asks whether the code uses nonces.

The question that finds this class is a different one: for each write, name the guard that stands in front of that write, and show the path by which a request reaches it. Asking it of PowerPress means grepping for powerpress_save_settings, which returns fifty call sites in 11.17.9, and then asking of each one which branch encloses it. The two in the player file are reached by a request that never enters the branch the nonce check lives in.

A closing note on reading a patch. The fix for the printed value landed in powerpress.php, and a second, older copy of the same saver exists in powerpressadmin.php without the clamp. Both are wrapped in if (!function_exists(...)), and the copy in powerpress.php is defined long before the admin file is required, so it is the one that runs and the patch is in the right place. The point is that the answer came from reading the load order, and a duplicate definition is a good reason to go and check it.

Disclosure timeline

  • 2026-09-18 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
  • 2026-10-01 Fixed release 11.17.11 published by Blubrry
  • 2026-10-05 Public disclosure · CVE-2026-104407 assigned

Questions

What is CVE-2026-104407?

CVE-2026-104407 is a cross-site request forgery vulnerability in the PowerPress Podcasting plugin by Blubrry for WordPress, affecting all versions up to and including 11.17.9. The plugin's nonce check is nested inside a condition on the page query parameter, while a second handler that saves the same settings runs on every admin page outside that condition. A forged POST therefore saves arbitrary plugin settings, and one of those settings is printed unescaped inside a script element on every front-end page.

Who can exploit CVE-2026-104407?

Anyone, with no account on the target site. The forged request has to be made by an administrator's browser, so the attacker needs a logged-in administrator to open a page under their control, which is the normal precondition for CSRF. The resulting script then runs for every visitor to the site.

Does the victim have to click anything?

No. The attacker's page submits the form by itself when it loads, and the injected script sits in wp_head() and runs while the front-end page loads. In the measurement published above a browser produced the alert with no input events at all.

How do I fix CVE-2026-104407?

Update PowerPress to 11.17.11 or later. There is no 11.17.10 release on wordpress.org despite the changelog entry, so 11.17.11 is the first version carrying the fix. It adds the nonce check to both unprotected save actions, casts the width setting to an integer where it is printed, and clamps it where it is saved. After updating, read powerpress_general back to see whether a hostile value is still stored there.

References

More discovered CVEs from Intrudify

See what a pentest
finds in your app.