CVE case study
CVE-2026-65523: An Entry ID Exposed Formidable's Private Signature Invite URL
The Formidable Forms signature add-on resolved a request-supplied entry ID to its private signing URL without authentication or an ownership check. Version 2.0.2 derives the entry from signer session state instead.
- Severity
- High (7.5)
- Scoring
- CVSS 3.1
- Weakness
- CWE-639
- Affected
- Formidable Forms Signature Online Contract Automation 2.0.1 and earlier
- Remediation state
- Upgrade to version 2.0.2 or later
- Advisory published
- 28 Jul 2026
Official vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
Why it matters
The add-on linked a Formidable entry to an e-signature document and its private invite URL. That URL is sensitive because it is the return path issued to the intended signer, not a public document index.
In version 2.0.1, an unauthenticated request could provide frm_esig_agreement as a numeric entry ID. The plugin resolved that ID globally and redirected the caller to the associated invite URL without checking identity, ownership, or signer context.
The official assessment is High at 7.5 for confidentiality. The reproduced boundary was the invite-URL disclosure; this article does not claim that a signature was forged or that every exposed URL remained active.
How I found it
On 17 July 2026, I reviewed the Formidable signature add-on's frontend initialization hooks and searched for request parameters that selected stored agreements. frm_esig_agreement was read during WordPress init on every public request.
I traced that numeric entry ID into getInviteUrl(). The helper mapped the entry to an e-signature document and invitation hashes without checking authentication, session ownership, or signer identity before redirecting.
A disposable WordPress rig with synthetic document metadata returned the private lab invitation URL to an unauthenticated request. I stopped at the redirect and did not view or sign a real document.
Version 2.0.2 removed the entry ID from the browser-controlled return URL and recovered it from the signer's temporary session state, confirming the intended ownership boundary.
Root cause
The plugin instantiated its filter class on every request and attached frm_init() to WordPress init. The handler read the query parameter before any authenticated workflow context was established.
getInviteUrl($entryId) looked up document metadata by the supplied Formidable entry ID, obtained the invite and document hashes, and constructed the private signing URL. The lookup was keyed only by the object identifier.
The intended PayPal return flow already maintained a temporary entry ID in signer session state. Passing the same ID through the URL created an unnecessary second source that an outside caller could control.
Source-to-sink trace
- 01Unauthenticated source
frm_init() on the WordPress init hookAny request could supply frm_esig_agreement because the filter class was loaded outside an administrative or authenticated boundary.
- 02Object selector
esig_formidable_get('frm_esig_agreement')The query parameter became the Formidable entry ID without being tied to the current signer session.
- 03Sensitive lookup
ESIG_FORMIDABLEFORM_SETTING::getInviteUrl($entryId)The helper resolved document and invitation hashes by the global entry identifier.
- 04Disclosure sink
wp_redirect($inviteUrl)The private invitation URL was returned in the response Location header.
- 05Fixed state source
getTempEntryId() in 2.0.2The patched handler ignores a direct entry-ID parameter and resolves only the entry stored by the legitimate signer return flow.
Safe proof of concept
Prerequisites
- Public plugin source for versions 2.0.1 and 2.0.2.
- Python 3 for a local object-ownership model.
- No production contract, signer identity, live invite hash, or external request.
Step-by-step reproduction
- Compare
frm_init()and the PayPal return filter between 2.0.1 and 2.0.2. - Confirm that 2.0.1 reads an entry ID directly from the request, while 2.0.2 accepts only an auth marker and calls
getTempEntryId(). - Run the synthetic model below with two invented entries. The caller supplies entry 202 while its own session contains entry 101.
- Observe that the affected policy returns the other entry's dummy invite path. The fixed policy ignores that request ID and returns only the session-bound path.
- Delete the session entry and confirm that the fixed policy returns no redirect.
Compare the public return handlers
LAB_DIR=$(mktemp -d "\${TMPDIR:-/tmp}/formidable-signature-cve.XXXXXX")
curl -fsSL \
https://plugins.svn.wordpress.org/forms-signature-formidable-online-contract-automation/tags/2.0.1/admin/esig-formidableform-filters.php \
-o "$LAB_DIR/affected.php"
curl -fsSL \
https://plugins.svn.wordpress.org/forms-signature-formidable-online-contract-automation/tags/2.0.2/admin/esig-formidableform-filters.php \
-o "$LAB_DIR/fixed.php"
diff -u "$LAB_DIR/affected.php" "$LAB_DIR/fixed.php"Synthetic entry-to-invite ownership model
invites = {
101: "/lab/invite/OWN_SESSION_ENTRY",
202: "/lab/invite/OTHER_SYNTHETIC_ENTRY",
}
def affected(request_entry):
return invites.get(request_entry)
def fixed(session_entry):
return invites.get(session_entry) if session_entry else None
print("affected request 202:", affected(202))
print("fixed session 101: ", fixed(101))
print("fixed no session: ", fixed(None))Expected evidence
- The affected handler accepts the request-controlled entry ID and reaches the other synthetic invite.
- The fixed handler returns the invite associated with the established session rather than the request ID.
- With no session-bound entry, the fixed handler produces no redirect.
Use the request ID that matches the synthetic session entry. Both policies resolve the same dummy path, confirming that the repair preserves the intended signer return while eliminating cross-entry selection.
On version 2.0.2 or later, a direct frm_esig_agreement request must be ignored. Only a legitimate return flow with a current temporary entry in that same session may receive its own invite redirect.
Impact
An unauthenticated caller who guessed or enumerated an existing entry ID could receive an HTTP redirect containing the corresponding private invite URL. The record treats the resulting contract and signer-data exposure as High confidentiality impact.
Availability was unaffected and the public CVE does not claim modification. A valid test therefore stops after recording the redirect destination for a synthetic document in an owned lab.
Fix and retest
Upgrade to version 2.0.2 or later. The fixed frm_init() accepts only the auth return marker, loads the entry ID from the signer's temporary session state, and deletes that state after use.
The PayPal return-URL filter now transports only auth=1; it no longer exposes or trusts the Formidable entry ID in the URL. The server chooses the object from the established workflow context.
Retest with a synthetic agreement. A direct request carrying the old entry-ID parameter must no longer redirect, while the legitimate same-session return flow should still reach its own invite URL exactly once.
Engineering lesson
An object identifier is not proof that the requester may resolve the object. Private links should be derived from authenticated or signer-bound state, not from a globally enumerable request key.
When a multi-step integration already stores server-side state, keep the object identity there. A boolean return marker is safer than reflecting the sensitive identifier through a browser-controlled URL.