CVE-2026-97307 The scrub list that only knew about payments in Cost Calculator Builder

Intrudify's autonomous pentesting engine discovered an unauthenticated information exposure in Cost Calculator Builder, affecting all versions up to and including 4.0.17. Every public page that renders a calculator inlines the plugin's configuration into the page source. A function exists to remove the secrets from that payload, added after an earlier report, and it names the Stripe, PayPal and Razorpay keys. The reCAPTCHA secret keys and the outbound webhook signing secret sit in the same arrays and are not on the list.

Discovered by
Intrudify autonomous engine
Validated & reported by
Disclosure
2 October 2026
Severity
High · CVSS 7.5
Vulnerability class
Sensitive data exposure, CWE-200
Affected
Cost Calculator Builder ≤ 4.0.17
Fixed in
4.0.18
Active installs
20,000+
Attacker privilege
None, unauthenticated
User interaction
None, one GET of a public page
Outcome
Webhook signing secret and reCAPTCHA secret keys disclosed

Summary

Cost Calculator Builder renders pricing calculators from a shortcode, a Gutenberg block, an Elementor widget or a WPBakery element. Whichever route is used, the front-end script needs the calculator's configuration, and the plugin supplies it by serialising the settings into two JavaScript variables with wp_localize_script(). That configuration is the same array the administrator fills in on the settings screens, so it holds credentials alongside layout options.

The vendor already knew this. Version 4.0.12 introduced ccb_strip_frontend_payment_secrets() in response to a payment-gateway secret leak reported against 4.0.11. Intrudify's autonomous testing engine found that the function removes six specific keys, all of them belonging to payment gateways, and that the arrays it is handed carry several more secrets of other kinds.

The result is that an anonymous visitor who loads a page with a calculator on it receives, in the HTML, the site's reCAPTCHA v2 and v3 secret keys and the signing secret for the owner's outbound webhooks, together with the URL those webhooks are sent to.

Technical detail

A list of six

// includes/functions.php:683, byte-identical in every release from
// 4.0.12 through 4.0.17

function ccb_strip_frontend_payment_secrets( array $settings ): array {
    unset( $settings['stripe']['secretKey'] );
    unset( $settings['paypal']['paypal_email'] );
    unset( $settings['paypal']['client_secret'] );
    unset( $settings['payment_gateway']['cards']['card_payments']['stripe']['secretKey'] );
    unset( $settings['payment_gateway']['cards']['card_payments']['razorpay']['secretKey'] );
    unset( $settings['payment_gateway']['paypal']['client_secret'] );

    return $settings;
}

// Stripe. PayPal. Razorpay. No recaptcha, no webhooks, no geolocation.

The six entries are Stripe's secret key twice, PayPal's client secret twice, PayPal's merchant e-mail, and Razorpay's secret key. That is a complete answer to the question the 4.0.11 report asked. It is the only question the function has ever been asked.

How the reCAPTCHA secret gets into a calculator's own settings

The reCAPTCHA keys are configured once, globally. They reach the per-calculator array because the plugin copies them there.

// includes/functions.php:740, inside ccb_sync_settings_from_general_settings()

if ( ! empty( $general_settings['recaptcha'] ) ) {
    $enable = ! isset( $settings['recaptcha']['enable'] ) ? false : $settings['recaptcha']['enable'];
    $settings['recaptcha'] = $general_settings['recaptcha'];   // the whole node
    $settings['recaptcha']['enable'] = $enable;
}

// ... 28 lines later, line 768

if ( $render ) {
    $settings = ccb_strip_frontend_payment_secrets( $settings );
}

// The copy carries secretKey into the per-calculator array. The scrub that
// runs next has no entry for recaptcha, so the key stays.

Line 742 assigns the entire recaptcha node, secretKey included, and the scrub that runs twenty-eight lines later has no entry for it. The copy and the cleanup live in the same function, close enough to read in one screen.

Both arrays go to the browser

// includes/classes/CCBFrontController.php:346, the only thing removed

if ( isset( $general_settings['ai'] ) ) {
    unset( $general_settings['ai'] );            // the OpenAI key, and nothing else
}

wp_localize_script(
    'calc-builder-main-js',
    'ajax_window',
    array(
        'ajax_url'         => admin_url( 'admin-ajax.php' ),
        'general_settings' => $general_settings,
    )
);

// includes/templates/frontend/render.php:279
$data['settings'] = ccb_strip_frontend_payment_secrets( $data['settings'] );
wp_localize_script( 'calc-builder-main-js', 'calc_data_' . $calc_id, $data );

ajax_window.general_settings is published after removing exactly one thing, the ai node holding the OpenAI key. Everything else in the global settings goes out as it is: the reCAPTCHA secrets, the merchant PayPal address, the Google Maps key and the address that receives order notifications. calc_data_N.settings is published after the six-entry scrub, carrying the reCAPTCHA secrets and the whole webhooks node.

The webhook node is the one worth pausing on. secret_key_send_form is the shared secret the plugin uses to sign the requests it sends to the owner's automation endpoints, and send_form_url is where those requests go. Both are in the same object. An attacker reading one page gets the destination and the key that authenticates deliveries to it, which is everything needed to post forged traffic into whatever the owner has wired up downstream.

What a visitor receives

// Unauthenticated GET of any page carrying a calculator

curl -s 'https://victim.example/quote/' \
  | grep -oE '"secretKey":"[^"]*"|"secret_key_send_form":"[^"]*"'

// In the page source, inside the inline script printed for
// calc-builder-main-js:

var ajax_window = { "general_settings": { "recaptcha": {
    "v2": { "siteKey": "...", "secretKey": "6LcSECRETv2..." } } } };

var calc_data_7 = { "settings": { "webhooks": {
    "send_form_url": "https://hooks.zapier.com/hooks/catch/9421783/abcd12ef",
    "secret_key_send_form": "whsec_9f3a..." } } };

No cookie, no nonce, no account. The values are in the response body of a public page, which also means they reach anything that caches or archives that page.

Verification

The chain was confirmed by unauthenticated GET against a lab install with the calculator published on an ordinary page, reading the values out of the response body.

The scrub behaviour was then measured directly, by lifting ccb_strip_frontend_payment_secrets() unchanged out of 4.0.17 and the replacement functions unchanged out of 4.0.18, and running both over one identical settings array.

// The vendor's own scrub functions, lifted unchanged out of each release
// and run over one identical settings array. PHP 8.3.

                                     4.0.17        4.0.18
stripe secretKey      (control)      removed       removed
recaptcha v2 secretKey               PUBLISHED     removed
recaptcha v3 secretKey               PUBLISHED     removed
webhooks.secret_key_send_form        PUBLISHED     removed
webhooks.send_form_url               PUBLISHED     removed
paypal_email                         PUBLISHED     placeholder
form_fields.adminEmailAddress        PUBLISHED     removed
geolocation.public_key               PUBLISHED     kept, scoped

// The stripe row is the control. It is removed in both columns, which is
// what shows the 4.0.17 function ran at all. Without it, eight lines
// reading PUBLISHED would be equally consistent with a scrub that was
// never called.

The Stripe row is the control, and it is the row that makes the rest of the table mean anything. A scrub that silently failed to run would produce the same eight PUBLISHED lines as a scrub that ran and had no entry for those keys. Stripe coming back removed in the 4.0.17 column proves the function executed, so every other line in that column is a statement about its contents.

The same control was run in the lab: with a Stripe secret key configured alongside the reCAPTCHA and webhook secrets, the Stripe key is absent from the page source while the others are present in the same response.

The scrub list is byte-identical in every release from 4.0.12 through 4.0.17, which is why the advisory covers a range wider than the build that was tested.

Impact

The webhook signing secret is the serious one. It exists so that the owner's automation endpoint can tell a genuine delivery from a forged one, and publishing it next to the destination URL removes the distinction. Anything the owner built behind that webhook, a Zapier or Make scenario or a custom receiver, will now accept traffic an anonymous attacker composed.

The reCAPTCHA v2 and v3 secret keys are Google API credentials that are meant to stay on the server. Disclosure lets a third party make verification calls against the owner's reCAPTCHA account and forces a key-pair rotation, which means touching every place the pair is configured.

The remaining values are less severe and still unwanted: the merchant PayPal address, the address that receives order notifications, and the Google Maps key, which can be spent against the owner's billing account if it is not restricted by referrer.

Reach is as wide as it gets for a plugin of this kind. The exposure is on the public page a calculator is published on, which is the page the site owner is actively driving traffic to.

Remediation

Update Cost Calculator Builder to 4.0.18 or later.

Rotate anything that was in the payload while an affected version was live. The webhook signing secret and both reCAPTCHA key pairs should be treated as disclosed, because the pages that carried them are public and may be cached or archived. The update stops the leak and does not retract what was already served.

The fix goes further than the report asked for, in two ways worth describing.

// 4.0.17  ->  4.0.18, includes/functions.php

+ $paths = array(
+     array( 'stripe', 'secretKey' ),
+     array( 'payment_gateway', 'paypal', 'client_secret' ),
+     // Webhook URLs and signing secrets are used only server-side.
+     array( 'webhooks' ),
+     array( 'recaptcha', 'v2', 'secretKey' ),
+     array( 'recaptcha', 'v3', 'secretKey' ),
+     array( 'formFields', 'adminEmailAddress' ),
+     array( 'geolocation', 'secret_key' ),
+ );

// and in includes/classes/CCBFrontController.php

+ // General settings are not printed: the widget reads only ajax_url from
+ // ajax_window and gets its settings from the scrubbed calc_data payload
- 'general_settings' => $general_settings,

First, the scrub became a list of paths applied by a helper, covering the whole webhooks node, both reCAPTCHA secrets, the order-notification address and the geolocation server key, with a second function for the global settings array. Second, and more to the point, general_settings is no longer printed into ajax_window at all. The vendor's comment explains that the widget only ever needed ajax_url from that object and takes its settings from the per-calculator payload.

Two values were kept deliberately, and both decisions are right. The merchant PayPal address is replaced with the placeholder __ccb_paypal_configured__, because the front-end script tests whether PayPal is configured and only needed a yes. The Google Maps key stays, because a browser key has to be in the browser for the map to load, but 4.0.18 publishes it only on calculators that actually render a geolocation field.

The 4.0.18 changelog has three entries and none of them mentions security. A site owner reading it sees a decimals bug, a translation bug and a PDF download bug, and has no reason to treat the update as urgent.

The wider lesson

This is a deny list guarding a channel that keeps growing. The function enumerates the secrets that must not be published, so it is correct only for the set of settings that existed on the day it was written. Every feature added afterwards, reCAPTCHA, webhooks, geolocation, arrives already published, and nothing in the process of adding it asks whether a new entry is owed. The list did not become wrong. The array around it grew.

The inverted arrangement holds up on its own. Decide what the front end needs and send only that, so a new setting is private until someone makes a case for it. 4.0.18 does exactly this for the global array by not sending it, and that one change closes more than the list ever could, including the settings nobody has added yet.

There is a second point about what counts as a secret. The scrub was written in response to a payment-gateway report, and it answers that report completely. The category it quietly fixed was not "credentials" but "the credentials someone complained about". A webhook signing secret does not look like a payment credential, which is exactly why it survived a review that was thinking about payments. Worth asking of any outbound payload: not whether the known-bad keys are gone, but which keys in here would still be sensitive if the feature that owns them did not exist yet.

Last, the copy at line 742 is the kind of line that defeats a reviewer. Assigning a whole configuration node from one array into another is ordinary, and it quietly moves a credential into a structure with different publication rules. Wherever settings are merged between a private array and one that gets serialised to a client, the merge is the place the boundary gets crossed.

Disclosure timeline

  • 2026-09-19 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
  • 2026-09-29 Fixed release 4.0.18 built by Stylemix
  • 2026-10-02 Public disclosure · CVE-2026-97307 assigned

Questions

What is CVE-2026-97307?

CVE-2026-97307 is an unauthenticated sensitive information exposure in the Cost Calculator Builder WordPress plugin, versions 4.0.17 and earlier. It was discovered autonomously by the Intrudify AI penetration testing engine. Every public page that renders a calculator inlines the plugin configuration into the page source, and the function that removes secrets from that payload covers only the Stripe, PayPal and Razorpay keys, leaving the reCAPTCHA secret keys and the outbound webhook signing secret in place.

What can an attacker achieve with CVE-2026-97307?

An attacker with no account loads a public page and reads the site's reCAPTCHA v2 and v3 secret keys and the webhook signing secret, along with the webhook destination URL. The signing secret and the URL together allow forged, authenticated webhook deliveries into whatever automation the site owner has connected. The reCAPTCHA secrets allow verification calls against the owner's Google account and force a key rotation.

How do I fix CVE-2026-97307?

Update Cost Calculator Builder to version 4.0.18 or later, then rotate the webhook signing secret and both reCAPTCHA key pairs, since the pages that carried them are public and may be cached. The fix replaces the six-entry scrub with a path list that covers the webhooks node, both reCAPTCHA secrets, the order-notification address and the geolocation server key, and stops printing the global settings object into the page entirely.

References

More discovered CVEs from Intrudify

See what a pentest
finds in your app.