CVE-2026-25310

Extend Link <= 2.0.0 - Authenticated (Contributor+) Server-Side Request Forgery

mediumServer-Side Request Forgery (SSRF)
6.4
CVSS Score
6.4
CVSS Score
medium
Severity
2.0.1
Patched in
104d
Time to patch

Description

The Extend Link plugin for WordPress is vulnerable to Server-Side Request Forgery in all versions up to, and including, 2.0.0. This makes it possible for authenticated attackers, with Contributor-level access and above, to make web requests to arbitrary locations originating from the web application which can be used to query and modify information from internal services.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Changed
Low
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=2.0.0
PublishedJanuary 21, 2026
Last updatedMay 4, 2026
Affected pluginextend-link

What Changed in the Fix

Changes introduced in v2.0.1

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan: CVE-2026-25310 (Extend Link SSRF) ## 1. Vulnerability Summary The **Extend Link** plugin (<= 2.0.0) is vulnerable to an authenticated Server-Side Request Forgery (SSRF) via the "Link Status Checker" feature. The plugin registers an AJAX action `extend_link_plu_check_li…

Show full research plan

Exploitation Research Plan: CVE-2026-25310 (Extend Link SSRF)

1. Vulnerability Summary

The Extend Link plugin (<= 2.0.0) is vulnerable to an authenticated Server-Side Request Forgery (SSRF) via the "Link Status Checker" feature. The plugin registers an AJAX action extend_link_plu_check_link intended to verify if a URL is active for SEO purposes. This handler fails to restrict requests to external public IP addresses or validate the target port, allowing an authenticated attacker (Contributor+) to force the web server to make requests to internal services, local hostnames, or cloud metadata endpoints.

2. Attack Vector Analysis

  • Endpoint: /wp-admin/admin-ajax.php
  • AJAX Action: extend_link_plu_check_link
  • Vulnerable Parameter: url
  • Authentication Required: Contributor level (edit_posts capability) or higher.
  • Preconditions: The attacker must have access to a nonce generated with the action extend_link_nonce.

3. Code Flow

  1. Hook Registration: In extend-link.php, the Extend_Link_TinyMCE::init() method registers the AJAX action:
    add_action('wp_ajax_extend_link_plu_check_link', array($this, 'check_link_status'));
    
  2. Nonce Generation: The enqueue_translations method (hooked to admin_enqueue_scripts) generates a nonce for users on the post editor screen:
    $translations = array(
        // ...
        'nonce' => wp_create_nonce('extend_link_nonce'),
    );
    wp_localize_script('extend_link_tinymce-button-style', 'extendLinkI18n', $translations);
    
  3. JS Invocation: In js/tinymce-button.js, the "Check" button in the dialog triggers an AJAX call:
    var formData = new FormData();
    formData.append('action', 'extend_link_plu_check_link');
    formData.append('url', url);
    formData.append('nonce', extendLinkI18n.nonce);
    // ... sends to admin-ajax.php
    
  4. Vulnerable Sink (PHP): The check_link_status function (inferred) processes the $_POST['url']. It likely utilizes wp_remote_get() or wp_remote_head() without applying wp_http_validate_url() or similar restrictions, allowing the server to hit localhost, 127.0.0.1, or internal IP ranges.

4. Nonce Acquisition Strategy

The nonce is required and is specifically localized for the post/page editor screens.

  1. Requirement: Create a user with the Contributor role.
  2. Access Page: Navigate to the "New Post" page (/wp-admin/post-new.php).
  3. Extraction: Use browser_eval to extract the nonce from the global JavaScript object extendLinkI18n.
    • JavaScript Variable: extendLinkI18n.nonce
    • Action String: extend_link_nonce

5. Exploitation Strategy

The goal is to demonstrate SSRF by querying an internal service or the server's own loopback interface.

Step 1: Authentication and Nonce Retrieval

  • Log in as a Contributor.
  • Navigate to /wp-admin/post-new.php.
  • Run: browser_eval("window.extendLinkI18n?.nonce").

Step 2: SSRF Execution

Using the http_request tool, send a POST request to admin-ajax.php.

Request Details:

  • URL: http://[TARGET]/wp-admin/admin-ajax.php
  • Method: POST
  • Headers: Content-Type: application/x-www-form-urlencoded
  • Body: action=extend_link_plu_check_link&url=http://127.0.0.1:80&nonce=[NONCE]

Step 3: Payload Variations

  • Local Port Scanning: Set url to http://127.0.0.1:[PORT] (e.g., 22, 3306, 6379) to identify open ports based on the plugin's response.
  • Internal Service Discovery: Set url to http://192.168.1.1 or other RFC1918 addresses.
  • Cloud Metadata (if applicable): Set url to http://169.254.169.254/latest/meta-data/.

6. Test Data Setup

  1. Create User:
    wp user create attacker attacker@example.com --role=contributor --user_pass=password
    
  2. No special content required: The vulnerability is accessible directly via the AJAX endpoint once the nonce is obtained from the post editor.

7. Expected Results

  • Success Response: The plugin typically returns a JSON object or status message containing the HTTP code of the target URL.
  • Proof of SSRF: If the request to http://127.0.0.1:80 returns a 200 OK status (or the HTML of the WP homepage) via the AJAX response, SSRF is confirmed. If a closed port returns a connection error or a different status (e.g., 0 or 500), it confirms the ability to probe internal networking.

8. Verification Steps

Since this is an SSRF, the primary evidence is in the HTTP response body of the AJAX call. To confirm via server state:

  1. Monitor Logs: Check the server access logs to see if the WordPress instance made a request to itself (loopback).
  2. Port Scan Confirmation: Attempt to hit a known closed port (e.g., 127.0.0.1:9999) and compare the response to a known open port (e.g., 127.0.0.1:80).

9. Alternative Approaches

If the extendLinkI18n object is not populated:

  1. Check Script Loading: Ensure the plugin script js/tinymce-button.js is actually loaded. The code suggests it only loads on post.php and post-new.php.
  2. Direct Nonce Search: Use browser_eval("document.documentElement.innerHTML.match(/'nonce':'([a-f0-9]+)'/)") as a fallback if the object name is different in certain versions.
  3. Gutenberg vs Classic: If the site uses Gutenberg, ensure you are interacting with a "Classic Block" or that the TinyMCE initialization still triggers the admin_enqueue_scripts hook for Extend Link.
Research Findings
Static analysis — not yet PoC-verified

Summary

The Extend Link plugin for WordPress is vulnerable to an authenticated Server-Side Request Forgery (SSRF) via its link status checker feature. Users with Contributor-level permissions or higher can trigger the plugin to make web requests to arbitrary internal or external URLs, potentially allowing them to scan internal ports or access sensitive metadata services.

Vulnerable Code

// extend-link.php approx line 190
public function check_link_status() {

    if ( !isset($_POST['nonce']) || empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'extend_link_check_status_nonce') ) {
        wp_send_json_error(['message' => __('Security check failed. Please refresh the page and try again.', 'extend-link')]);
    }

    $url = isset($_POST['url']) ? sanitize_url(wp_unslash($_POST['url'])) : '';

    $url = !empty(trim($url)) ? esc_url_raw($url) : '';
    
    if (empty($url)) {
        wp_send_json_error(['message' => __('Please enter a URL first to check its status.', 'extend-link')]);
    }

    if ( !filter_var($url, FILTER_VALIDATE_URL) ) {
        wp_send_json_error(['message' => __('Please enter a valid URL.', 'extend-link')]);
    }
    
    $response = wp_remote_head($url, array(
        'timeout' => 10,
        'sslverify' => false
    ));
    
    if (is_wp_error($response)) {
        wp_send_json_error(__('There is an error. Please try again after a few seconds.', 'extend-link'));
    }
    
    $status = wp_remote_retrieve_response_code($response);

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/extend-link/2.0.0/extend-link.php /home/deploy/wp-safety.org/data/plugin-versions/extend-link/2.0.1/extend-link.php
--- /home/deploy/wp-safety.org/data/plugin-versions/extend-link/2.0.0/extend-link.php	2025-12-02 11:47:12.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/extend-link/2.0.1/extend-link.php	2026-02-02 08:38:10.000000000 +0000
@@ -187,31 +187,92 @@
             );
         }
 
+        /**
+         * Enhanced security check for URLs
+         * Validates that the URL is safe to check and not pointing to internal resources
+         * 
+         * @param string $url The URL to validate
+         * @return bool True if URL is safe, false otherwise
+         */
+        public function is_safe_url($url) {
+            $parsed = wp_parse_url($url);
+            
+            if (!$parsed || !isset($parsed['host'])) {
+                return false;
+            }
+            
+            $host = $parsed['host'];
+            
+            // Block common localhost and internal addresses
+            $blocked_hosts = array(
+                'localhost',
+                '127.0.0.1',
+                '0.0.0.0',
+                '::1',
+                '[::1]'
+            );
+            
+            if (in_array(strtolower($host), $blocked_hosts)) {
+                return false;
+            }
+            
+            // Check for private/reserved IP ranges
+            if (filter_var($host, FILTER_VALIDATE_IP)) {
+                if (!filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
+                    return false;
+                }
+            }
+            
+            // Only allow HTTP and HTTPS protocols
+            if (!in_array($parsed['scheme'], array('http', 'https'))) {
+                return false;
+            }
+            
+            return true;
+        }
+
+        /**
+         * AJAX handler to check link status
+         * Uses wp_safe_remote_head() for enhanced security against SSRF attacks
+         */
         public function check_link_status() {
+            // Verify user capabilities
+            if (!current_user_can('edit_posts')) {
+                wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'extend-link')]);
+            }
 
-            if ( !isset($_POST['nonce']) || empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'extend_link_check_status_nonce') ) {
+            // Verify nonce for CSRF protection
+            if (!isset($_POST['nonce']) || empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'extend_link_check_status_nonce')) {
                 wp_send_json_error(['message' => __('Security check failed. Please refresh the page and try again.', 'extend-link')]);
             }
 
+            // Sanitize and validate URL
             $url = isset($_POST['url']) ? sanitize_url(wp_unslash($_POST['url'])) : '';
-    
             $url = !empty(trim($url)) ? esc_url_raw($url) : '';
             
             if (empty($url)) {
                 wp_send_json_error(['message' => __('Please enter a URL first to check its status.', 'extend-link')]);
             }
 
-            if ( !filter_var($url, FILTER_VALIDATE_URL) ) {
+            if (!filter_var($url, FILTER_VALIDATE_URL)) {
                 wp_send_json_error(['message' => __('Please enter a valid URL.', 'extend-link')]);
             }
             
-            $response = wp_remote_head($url, array(
+            // Additional security check for internal addresses
+            if (!$this->is_safe_url($url)) {
+                wp_send_json_error(['message' => __('This URL is not allowed for security reasons.', 'extend-link')]);
+            }
+            
+            $response = wp_safe_remote_head($url, array(
                 'timeout' => 10,
-                'sslverify' => false
+                'sslverify' => true,
+                'redirection' => 3,
+                'user-agent' => 'WordPress/' . get_bloginfo('version') . '; ' . get_bloginfo('url'),
+                'httpversion' => '1.1'
             ));
             
             if (is_wp_error($response)) {
-                wp_send_json_error(__('There is an error. Please try again after a few seconds.', 'extend-link'));
+                wp_send_json_error(['message' => __('There is an error. Please try again after a few seconds.', 'extend-link')]);
             }
             
             $status = wp_remote_retrieve_response_code($response);

Exploit Outline

To exploit this SSRF vulnerability, an attacker must have an account with at least Contributor-level privileges. First, the attacker logs in and navigates to the post editor page to obtain a valid security nonce from the `extendLinkI18n` JavaScript object. The attacker then makes an AJAX POST request to `/wp-admin/admin-ajax.php` with the following parameters: `action` set to `extend_link_plu_check_link`, `nonce` set to the retrieved value, and `url` set to an internal target (e.g., `http://127.0.0.1:80` or AWS metadata endpoints). Since the vulnerable version uses `wp_remote_head` without validating the host, the server will attempt to connect to the provided URL and return the HTTP status code in the response, allowing the attacker to verify the presence of internal services or scan for open ports.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.