OneSignal – Web Push Notifications <= 3.8.0 - Missing Authorization to Authenticated (Subscriber+) Post Meta Deletion via 'post_id'
Description
The OneSignal – Web Push Notifications plugin for WordPress is vulnerable to authorization bypass in versions up to, and including, 3.8.0. This is due to the plugin not properly verifying that a user is authorized to perform an action. This makes it possible for authenticated attackers, with subscriber-level access and above, to delete OneSignal metadata for arbitrary posts.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:NTechnical Details
<=3.8.0What Changed in the Fix
Changes introduced in v3.8.1
Source Code
WordPress.org SVN# Vulnerability Research Plan: CVE-2026-3155 ## 1. Vulnerability Summary The **OneSignal – Web Push Notifications** plugin (<= 3.8.0) contains a missing authorization vulnerability in its AJAX handling logic. Specifically, the function `has_metadata()` registered via the `wp_ajax_has_metadata` hook…
Show full research plan
Vulnerability Research Plan: CVE-2026-3155
1. Vulnerability Summary
The OneSignal – Web Push Notifications plugin (<= 3.8.0) contains a missing authorization vulnerability in its AJAX handling logic. Specifically, the function has_metadata() registered via the wp_ajax_has_metadata hook in v2/onesignal-admin.php fails to perform any capability checks (e.g., current_user_can()) or nonce verification (e.g., check_ajax_referer()).
This allows any authenticated user, including those with Subscriber permissions, to delete specific post metadata keys (status, recipients, response_body) for any arbitrary post by providing a post_id. This metadata is used to track notification delivery status.
2. Attack Vector Analysis
- Endpoint:
/wp-admin/admin-ajax.php - AJAX Action:
has_metadata - HTTP Method:
GET(The code uses$_GET['post_id']) - Vulnerable Parameter:
post_id - Authentication: Required (Subscriber or higher)
- Preconditions: The plugin must be operating in "V2" mode. Per
onesignal.php, this occurs when the plugin is not a "new install" and has not been "migrated" to V3.
3. Code Flow
- Entry Point: An authenticated user sends a GET request to
admin-ajax.php?action=has_metadata&post_id=[TARGET_ID]. - Hook Registration: In
v2/onesignal-admin.php, the hook is registered:add_action('wp_ajax_has_metadata', 'has_metadata'); - Execution: The
has_metadata()function is called:- It retrieves
$post_idfrom$_GET['post_id'](line 30). - It filters the ID using
FILTER_SANITIZE_NUMBER_INT. - It retrieves post meta for
recipients,status, andresponse_body(lines 37-49).
- It retrieves
- Vulnerable Sink: The function then unconditionally calls
delete_post_metafor these keys:// v2/onesignal-admin.php lines 52-54 delete_post_meta($post_id, 'status'); delete_post_meta($post_id, 'recipients'); delete_post_meta($post_id, 'response_body'); - Response: The function returns the deleted values in a JSON-encoded array and exits.
4. Nonce Acquisition Strategy
Based on the analysis of v2/onesignal-admin.php, the has_metadata function does not implement a nonce check.
- Line 27 begins the function definition.
- There is no call to
check_ajax_referer()orwp_verify_nonce(). - Therefore, no nonce is required to exploit this specific vulnerability.
5. Exploitation Strategy
The goal is to delete the OneSignal notification metadata for a specific post using a Subscriber-level account.
Step-by-Step Plan:
- Target Identification: Identify a
post_idof a published post. - Authentication: Log in to WordPress as a Subscriber.
- Trigger Deletion: Send a GET request to the AJAX endpoint.
- URL:
http://localhost:8080/wp-admin/admin-ajax.php?action=has_metadata&post_id=[POST_ID] - Headers: Provide the Subscriber's session cookies.
- URL:
- Verify Deletion: Check if the metadata keys are removed from the database for that
post_id.
6. Test Data Setup
To successfully test this, the environment must be forced into the vulnerable V2 code path and have existing metadata:
- Force V2 Mode:
wp option update OneSignalWPSetting '{"app_id":"12345"}' --format=json wp option update onesignal_plugin_migrated 0 - Create Target Post:
POST_ID=$(wp post create --post_title="Target Post" --post_status=publish --porcelain) - Inject Metadata: Add the specific keys targeted by the function:
wp post meta add $POST_ID status "sent" wp post meta add $POST_ID recipients 100 wp post meta add $POST_ID response_body "success" - Create Subscriber:
wp user create attacker attacker@example.com --role=subscriber --user_pass=password
7. Expected Results
- The AJAX request should return a JSON response containing the values of the metadata before they were deleted.
- Example:
{"recipients":"100","status_code":"sent","response_body":"success"}
- Example:
- The HTTP status code should be
200 OK.
8. Verification Steps
After the HTTP request, use WP-CLI to confirm the metadata is gone:
# These should all return empty strings
wp post meta get [POST_ID] status
wp post meta get [POST_ID] recipients
wp post meta get [POST_ID] response_body
9. Alternative Approaches
If the plugin automatically migrates to V3 upon admin access (preventing V2 code execution), the exploit can be attempted by:
- Directly calling the AJAX action immediately after setting the options via CLI, before any admin user navigates the site.
- Checking if
v3/onesignal-admin/onesignal-admin.php(not provided in source but part of the plugin) contains a similar unauthenticated or under-privileged meta-deletion logic. Given the CVSS and description, it is highly likely that this logic was either replicated or left reachable in the affected versions.
Summary
The OneSignal plugin for WordPress (versions <= 3.8.0) is vulnerable to an authorization bypass due to a missing capability check and nonce verification in its 'has_metadata' AJAX handler. This allows authenticated users, including those with subscriber-level permissions, to delete OneSignal notification metadata (status, recipients, and response body) for any arbitrary post by providing its ID.
Vulnerable Code
// v2/onesignal-admin.php line 26 add_action('wp_ajax_has_metadata', 'has_metadata'); function has_metadata() { $post_id = isset($_GET['post_id']) ? (filter_var($_GET['post_id'], FILTER_SANITIZE_NUMBER_INT)) : ''; if (is_null($post_id)) { $data = array('error' => 'could not get post id'); } else { $recipients = get_post_meta($post_id, 'recipients'); if ($recipients && is_array($recipients)) { $recipients = $recipients[0]; } $status = get_post_meta($post_id, 'status'); if ($status && is_array($status)) { $status = $status[0]; } $response_body = get_post_meta($post_id, 'response_body'); if ($response_body && is_array($response_body)) { $response_body = $response_body[0]; } // reset meta // v2/onesignal-admin.php lines 53-55 delete_post_meta($post_id, 'status'); delete_post_meta($post_id, 'recipients'); delete_post_meta($post_id, 'response_body'); $data = array('recipients' => $recipients, 'status_code' => $status, 'response_body' => $response_body); } echo wp_json_encode($data); exit; }
Security Fix
@@ -6,7 +6,7 @@ * Plugin Name: OneSignal Push Notifications * Plugin URI: https://onesignal.com/ * Description: Free web push notifications. - * Version: 3.8.0 + * Version: 3.8.1 * Author: OneSignal * Author URI: https://onesignal.com * License: MIT @@ -18,7 +18,7 @@ define('ONESIGNAL_URI_REVEAL_PROJECT_NUMBER', 'reveal_project_number=true'); // Plugin version - must match Version in plugin header -define('ONESIGNAL_PLUGIN_VERSION', '030800'); +define('ONESIGNAL_PLUGIN_VERSION', '030801'); // Constants for plugin versions define('ONESIGNAL_VERSION_V2', 'v2'); @@ -4,7 +4,7 @@ Tags: push notification, push notifications, desktop notifications, mobile notifications, chrome push, android, android notification, android notifications, android push, desktop notification, firefox, firefox push, mobile, mobile notification, notification, notifications, notify, onesignal, push, push messages, safari, safari push, web push, chrome Requires at least: 3.8 Tested up to: 6.9 -Stable tag: 3.8.0 +Stable tag: 3.8.1 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -64,6 +64,9 @@ == Changelog == += 3.8.1 = +- Improve AJAX handler validation + = 3.8.0 = - add HTML meta tag for improved debugging @@ -18,47 +18,45 @@ if ($post) { wp_register_script('notice_script', plugins_url('notice.js', __FILE__), array('jquery'), '1.1', true); wp_enqueue_script('notice_script'); - wp_localize_script('notice_script', 'ajax_object', array('ajax_url' => admin_url('admin-ajax.php'), 'post_id' => $post->ID)); + wp_localize_script('notice_script', 'ajax_object', array( + 'ajax_url' => admin_url('admin-ajax.php'), + 'post_id' => $post->ID, + 'nonce' => wp_create_nonce('onesignal_has_metadata'), + )); } } add_action('wp_ajax_has_metadata', 'has_metadata'); function has_metadata() { - $post_id = isset($_GET['post_id']) ? - (filter_var($_GET['post_id'], FILTER_SANITIZE_NUMBER_INT)) - : ''; - - if (is_null($post_id)) { - $data = array('error' => 'could not get post id'); - } else { - $recipients = get_post_meta($post_id, 'recipients'); - if ($recipients && is_array($recipients)) { - $recipients = $recipients[0]; - } - - $status = get_post_meta($post_id, 'status'); - if ($status && is_array($status)) { - $status = $status[0]; - } - - $response_body = get_post_meta($post_id, 'response_body'); - if ($response_body && is_array($response_body)) { - $response_body = $response_body[0]; - } - - // reset meta - delete_post_meta($post_id, 'status'); - delete_post_meta($post_id, 'recipients'); - delete_post_meta($post_id, 'response_body'); + check_ajax_referer('onesignal_has_metadata', 'nonce'); - $data = array('recipients' => $recipients, 'status_code' => $status, 'response_body' => $response_body); + $post_id = isset($_GET['post_id']) ? intval($_GET['post_id']) : 0; + if (!$post_id || !current_user_can('edit_post', $post_id)) { + wp_send_json_error(array('error' => 'Unauthorized'), 403); } - echo wp_json_encode($data); + $recipients = get_post_meta($post_id, 'recipients'); + if ($recipients && is_array($recipients)) { + $recipients = $recipients[0]; + } + + $status = get_post_meta($post_id, 'status'); + if ($status && is_array($status)) { + $status = $status[0]; + } + + $response_body = get_post_meta($post_id, 'response_body'); + if ($response_body && is_array($response_body)) { + $response_body = $response_body[0]; + } + + delete_post_meta($post_id, 'status'); + delete_post_meta($post_id, 'recipients'); + delete_post_meta($post_id, 'response_body'); - exit; + wp_send_json(array('recipients' => $recipients, 'status_code' => $status, 'response_body' => $response_body)); }
Exploit Outline
To exploit this vulnerability, an attacker needs a Subscriber-level account on the WordPress site. The attacker must then identify the ID of a post they wish to target. By sending a GET request to the `/wp-admin/admin-ajax.php` endpoint with the parameters `action=has_metadata` and `post_id=[target_id]`, the plugin's `has_metadata` function will execute without any permission or nonce checks. The server will respond with the current values of the `recipients`, `status`, and `response_body` metadata for that post, and subsequently delete those metadata records from the database.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.