CVE case study

CVE-2026-66696: A Caller-Controlled Kadence Library Host Received Stored License Data

Kadence Blocks allowed a contributor to choose a library request host while the server attached its stored Kadence license key and email. Version 3.7.8.1 restricts library destinations and scopes credentials to Kadence hosts.

Severity
Medium (4.3)
Scoring
CVSS 3.1
Weakness
CWE-201
Affected
Kadence Blocks 3.7.8 and earlier
Remediation state
Upgrade to Kadence Blocks 3.7.8.1 or later
Advisory published
29 Jul 2026

Official vectorCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

Why it matters

Kadence Blocks used authenticated AJAX handlers to fetch design-library data. A contributor could obtain the editor nonce and choose the base URL used by those requests because the guard required edit_posts, not an administrative capability.

For template requests, the server appended its stored Kadence license email and key to the outbound query string. A contributor-controlled destination could therefore receive those values directly from WordPress. The response body was also returned to the caller, creating a bounded external server-fetch primitive.

The public CVE record classifies the issue as sensitive data exposure and scores it Medium at 4.3. WordPress core's safe HTTP client limits private-address and port reachability, so this study does not claim unrestricted internal-network SSRF.

How I found it

On 17 July 2026, I reviewed Kadence Blocks' design-library AJAX actions and traced the capability check, caller-controlled destination, stored license data, and outbound request as one flow. The handlers accepted contributors because their shared gate required edit_posts.

I found that the selected library URL was sanitized as text but not restricted to a Kadence host. For template requests, the remote builder added the site's stored license email and key to that destination before calling WordPress' safe HTTP client.

I validated both the credential-release path and the returned response body in a disposable WordPress lab with invented license values. Subscriber, invalid-nonce, and unauthenticated controls did not reach the request path.

The public 3.7.8.1 patch confirmed the boundary. It introduced library URL resolvers, rejects unknown destinations, and attaches license values only after a Kadence-host check.

Root cause

prebuilt_data_ajax_callback() accepted $_POST['url'], applied text sanitization, appended the Kadence library API path, and passed the result into the remote-fetch flow. Text sanitization did not establish that the destination belonged to Kadence.

get_remote_url_contents() added api_email and api_key when the request represented a template package. Host selection and credential attachment were separate decisions, so a request could retain the credential even after its destination changed.

The related connection-info callback used the same caller-controlled host and reflected parsed response data. A POST-controlled package value also influenced the cache key, allowing a fresh request rather than a cached response.

Source-to-sink trace

  1. 01
    Low-privilege entryKadence design-library AJAX handlers

    The shared nonce and edit_posts check allowed a contributor with normal editor access to reach the library callbacks.

  2. 02
    Destination sourceprebuilt_data_ajax_callback() and connection-info callback

    A request parameter selected the base URL. Text sanitization removed unsafe characters but did not establish host trust.

  3. 03
    Credential sourceget_stored_license_key() and get_stored_license_email()

    The server loaded the site's stored Kadence license values independently of the caller-selected host.

  4. 04
    Disclosure sinkget_remote_url_contents() in 3.7.8

    Template requests appended api_email and api_key to the outbound query and returned fetched library data to the caller.

  5. 05
    Fixed decisionresolve_library_url(), resolve_connection_url(), and is_kadence_api_url() in 3.7.8.1

    The patch rejects unknown library hosts and releases credentials only after the destination matches the Kadence API policy.

Safe proof of concept

Prerequisites

  • Public Kadence Blocks 3.7.8 and 3.7.8.1 source for offline comparison.
  • Python 3 for the synthetic destination-and-secret policy model.
  • No Kadence account, real license, internet-facing listener, or production WordPress installation.

Step-by-step reproduction

  1. Compare the public prebuilt-library controller between 3.7.8 and 3.7.8.1. Identify where the patch replaces direct URL assembly with dedicated resolver functions.
  2. Confirm that the fixed request builder calls the Kadence-host predicate before adding license data.
  3. Run the synthetic model below. It uses the symbolic hosts external.invalid and library.vendor.invalid and the invented value SYNTHETIC_LICENSE; it sends no network request.
  4. Observe that the affected policy couples the synthetic credential to whichever host the caller selected.
  5. Observe that the fixed policy refuses the outside host, then permits the symbolic trusted host with the synthetic credential.
  6. If an owned WordPress lab is available, repeat only with dummy values and an in-process HTTP interception hook. Do not send the request beyond the test process.

Compare the public affected and fixed controllers

LAB_DIR=$(mktemp -d "\${TMPDIR:-/tmp}/kadence-cve.XXXXXX")

curl -fsSL \
  https://plugins.svn.wordpress.org/kadence-blocks/tags/3.7.8/includes/class-kadence-blocks-prebuilt-library.php \
  -o "$LAB_DIR/affected.php"
curl -fsSL \
  https://plugins.svn.wordpress.org/kadence-blocks/tags/3.7.8.1/includes/class-kadence-blocks-prebuilt-library.php \
  -o "$LAB_DIR/fixed.php"

diff -u "$LAB_DIR/affected.php" "$LAB_DIR/fixed.php"

Synthetic host-binding policy model

from urllib.parse import urlsplit

OUTSIDE = "https://external.invalid/library"
TRUSTED = "https://library.vendor.invalid/library"
SECRET = "SYNTHETIC_LICENSE"

def affected(selected_host, secret):
    return {"host": urlsplit(selected_host).hostname, "license": secret}

def fixed(selected_host, trusted_host, secret):
    if urlsplit(selected_host).hostname != urlsplit(trusted_host).hostname:
        return {"decision": "REFUSED", "license": None}
    return {"decision": "ALLOWED", "license": secret}

print("affected:", affected(OUTSIDE, SECRET))
print("fixed outside:", fixed(OUTSIDE, TRUSTED, SECRET))
print("fixed trusted:", fixed(TRUSTED, TRUSTED, SECRET))

Expected synthetic result

affected: {'host': 'external.invalid', 'license': 'SYNTHETIC_LICENSE'}
fixed outside: {'decision': 'REFUSED', 'license': None}
fixed trusted: {'decision': 'ALLOWED', 'license': 'SYNTHETIC_LICENSE'}

Expected evidence

  • The source diff shows direct use of the request-selected URL in 3.7.8 and allowlisted resolver functions in 3.7.8.1.
  • The affected model associates the invented license value with the outside host.
  • The fixed model returns REFUSED and no credential for the outside host.
  • The trusted-host control remains allowed, proving that the repair preserves the intended library path.
Negative control

Run the model with no stored license and with the symbolic trusted host. The first confirms that secret exposure requires a stored value; the second confirms that the fixed rule permits the intended destination rather than disabling all library requests.

Fixed-version re-test

On Kadence Blocks 3.7.8.1 or later, an unknown library URL must be rejected before any fetch, and no credential-bearing request may be constructed unless the resolved destination passes the Kadence API host policy.

Impact

A contributor on an affected licensed site could cause the server to disclose its Kadence license key and email to an external host they controlled. On an unlicensed site, the same path could still fetch a permitted external URL and expose selected response data to the contributor.

The official vector records low confidentiality impact, with no integrity or availability impact. The demonstrated asset was the stored product credential, not WordPress authentication material or arbitrary application secrets.

Fix and retest

Upgrade to Kadence Blocks 3.7.8.1 or later. The patch resolves library and connection URLs through dedicated allowlist helpers and rejects an empty result before the request is made.

The fixed request builder attaches license credentials only when is_kadence_api_url() confirms the destination is an approved Kadence library host. This binds both destination selection and secret release to the same trust decision.

Retest the affected and fixed versions with a synthetic key and a loopback listener in an owned lab. The affected control should show the synthetic values reaching the selected host; the fixed version must reject that host while normal Kadence library access continues to work.

Engineering lesson

A safe HTTP transport does not decide which external organization may receive a secret. Validate the complete destination against an explicit trust policy immediately before credentials are attached.

When a security fix moves a credential from browser code to a server-side request, review who can still choose that request's host. Moving the secret is only safe when the destination boundary moves with it.

References

Follow this thread

Related research, source, and writing.

These paths share a published research boundary, project source, article series, or authorized lab context with this record.

Back to article start