The capability model, the public endpoints, how secrets are stored, and the anti-spam layers — including the one known gap.
The raw-HTML editor requires both:
current_user_can( 'edit_theme_options' ) && current_user_can( 'unfiltered_html' )Raw HTML round-trips through the editor, so anyone who can use it can put
arbitrary markup on the site. unfiltered_html is the capability WordPress
already uses to mean "trusted with markup", so it is the one used rather than
inventing a new one.
On a native block theme, the Site and Post Editors keep Gutenberg's normal
entity permissions. Editing a page or post requires its edit_post
capability; templates, template parts, navigation and Global Styles require
edit_theme_options. The native SEO endpoint also checks edit_post for the
specific page or post in its URL. Responsive metadata is protected by the same
object-level check.
On multisite that capability belongs to Super Admins only by default, so ordinary site administrators cannot use the raw-HTML editor there. Native Gutenberg mode keeps WordPress's multisite rules for each entity.
Screens holding credentials or personal data sit one tier higher at
manage_options: Form Settings, SEO & Sharing, Subscribers.
Four routes are unauthenticated. Each is public because it must be, and each has a specific gate.
Serves already-published posts to anyone, which is what a blog does.
The gate: the card markup is read from the site's own stored source, never accepted from the request. The only caller-controlled inputs are a sanitised page key and a page number bounded to 2–500.
Must be anonymous; visitors are not logged in. POST /form-submit, which the
form blocks use, is the same handler behind a second route.
The gate: five layers, below.
Where a submission goes is not the submitter's to choose. One form can name
its own recipient or its own mailing list, and those values travel in the page
source, so they are signed (cve_delivery, a wp_hash over form id, recipient,
type and list) and verified in constant time on the way back. An unsigned or
altered value is not refused — a page cached before the owner changed the form
is not an attack — it falls back to the address in Form Settings and to a plain
contact form. Without this, "Send to" would be a mail relay and "List" a way to
write into the owner's contacts on request.
A converted theme that renders and delivers its own forms
(html2wp_theme_form_handle) reaches the same handler and the same check.
1.27.0 briefly exempted that path, on the reading that the theme had already
decided the recipient; it had not. What the filter receives is the raw request
body the theme forwarded, so a to in it is whatever the browser sent, and the
exemption was an unauthenticated relay waiting for one setting (Minimum fill
time above zero was the only thing suppressing it). The exemption is gone: one
signature check, every caller. The cost is that a per-form Send to on a
converted theme falls back to Form Settings until the converter signs it in the
markup it generates.
The same release fixed the other half of that path. A converted theme signs its
anti-spam timestamp as <time>.<flag>.<signature>; this plugin signs
<time>.<signature> and, until 1.27.0, refused anything that was not exactly
two parts — so on a converted theme with the default Minimum fill time every
submission was dropped, silently and before storage. verify_timestamp() now
verifies the signed prefix whatever its length, which reads both forms without
loosening anything: the signature still covers every field before it.
A link in a confirmation email, opened logged-out.
The gate: a 32-character random token, stored only as a SHA-256 hash and compared in constant time. A leaked database cannot confirm anyone.
Every outcome — valid, invalid, already used, prefetched by a mail client — lands on the same page. The endpoint is not an oracle for whether an address is on a list, and a mail client's link-prefetching cannot accidentally confirm someone.
In the order a submission meets them.
A field visitors never see. Anything that fills it gets success and is dropped — success rather than an error, so a bot learns nothing.
An HMAC over the current time window, keyed on the site's own salts, valid for a two-tick window. A token 2–14 ticks old is recognised as stale and answered with "This page has been open too long — please reload it" rather than a security error.
Why not a WordPress nonce? Because it broke for the site owner. A nonce is bound to the user, and WordPress deliberately drops a cookie-authenticated REST request with no
X-WP-Nonceheader to anonymous — so a nonce created while logged in was verified as logged out and could never match. The owner testing their own contact form always got "Security check failed" while logged-out visitors were fine. The token is now site-scoped rather than user-scoped.
A signed timestamp; anything faster than the configured minimum fill time (default 3 seconds) is dropped silently. Signed so a bot cannot forge an old timestamp to fake a human fill delay.
Weaker behind full-page caching, where every visitor gets the same stamp.
One submission per IP per 60 seconds.
The visitor's real IP is read from CF-Connecting-IP only when the request
genuinely arrived from Cloudflare, verified against their published address
ranges with a binary CIDR match. Arbitrary X-Forwarded-For is never used.
Trusting a forwarded header unconditionally is exploitable, and was measurably so: three submissions succeeded in a window that permits one before the range check existed.
Extend the trusted set with clara_ve_trusted_proxies if you sit behind a
different CDN. Anything in that list can set any visitor's apparent IP.
5. Akismet (optional)
Spam is stored and flagged, not discarded, and simply not emailed. The sender still sees success. Errors fail open.
Nothing is thrown away on a classifier's say-so — a lost enquiry you never knew about is worse than one sitting in a list marked "spam".
Five options hold credentials. Encryption is AES-256-CBC with a random IV,
the key derived from wp_salt('auth').
What it protects: a database-only exposure — a leaked backup, read-only database access, another plugin dumping the options table.
What it does not: an attacker with both filesystem and database access.
The key comes from wp-config.php; holding both means holding everything.
No key rotation, no versioning, CBC without a MAC. Stated plainly because a security claim that overreaches is worse than a modest one.
The settings UI never redisplays a stored secret. A saved one shows a placeholder, and submitting the field empty keeps what is there.
Three independent layers stop credentials leaving in a package:
- The options list is an allowlist — the writer iterates that array rather than scanning the options table, so a new setting is excluded until deliberately listed. That is the intended failure direction
- A never-export list of the five credential options, checked on the way out and on the way in
- A value-shape pattern matched against exported values, catching a key pasted into the wrong field
A match fails the whole export, loudly. A silent filter would let a mistake ship undetected for as long as nobody looked.
Personal data — submissions and subscriber emails — is separately gated and off by default in every package.
- Import path traversal — absolute paths,
..segments and Windows drive prefixes are rejected - Import cannot be tampered into overwriting — apply re-derives what is allowed from the stored plan, not from the browser's POST
- The redirect map is gated on 404 — it can only act where WordPress has already decided it has nothing, so it can never shadow a working URL
- Provider errors are not passed through verbatim for auth and billing failures, since those responses sometimes quote back part of the key
_clara_ve_keyis registeredshow_in_rest => false— internal
Open an issue at github.com/iOSDevSK/visual-edit-lite/issues. For something you would rather not post publicly, say so in the issue without details and a private channel will be arranged.