Security in Havenlytics is not an afterthought bolted onto a few handlers — it is a set of consistent conventions applied across every admin AJAX endpoint, REST route, form, and email header in the plugin. This guide documents those conventions exactly as they exist in Havenlytics 3.3.1 so that any code you write to extend Havenlytics — a custom AJAX action, a REST controller, an admin action link — matches the same bar. Every nonce action, capability string, sanitizer, and token below is real and verbatim from the plugin source.
Security: The three rules that hold everywhere in Havenlytics: verify a nonce before trusting a request, check a capability before performing the action, and sanitize on input, escape on output. Skipping any one of them is the usual root cause of a vulnerability. Treat this page as the checklist.
Nonce actions inventory
Havenlytics uses WordPress nonces on every state-changing request. Admin AJAX endpoints use custom action nonces; REST routes rely on the standard wp_rest nonce plus a permission_callback. The table below lists the nonce actions the plugin registers, so you know which token guards which surface.
| Nonce action(s) | Subsystem | Notes |
|---|---|---|
hvnly_import_nonce + hvnly_import_csrf | Property Import Wizard | Double nonce (see below). Both must pass. |
hvnly_ws_auth_login, hvnly_ws_auth_register, hvnly_ws_auth_logout, hvnly_ws_auth_lostpassword, hvnly_ws_auth_resetpassword, hvnly_ws_auth_verify_email, hvnly_ws_auth_resend_verification | Workspace auth | SessionAuthController NONCE_* constants, verified via verify_nonce(). |
hvnly_ws_auth_nonces | Workspace auth | Bundle nonce exposed to the SPA. |
hvnly_mgmt_export | Agent management | Guards the agents CSV bulk export redirect. |
hvnly_agent_identity_scan | Agent identity admin | Identity/verification admin scan action. |
hvnly_cache_nonce | Cache admin | Clear-cache admin AJAX. |
hvnly_ajax_request / hvnly_ajax_nonce | Frontend AJAX | Shared frontend AJAX nonces. Contact Agent endpoints use hvnly_ajax_request. |
hvnly_property_agents_nonce | Property/agent metabox | Save assigned agents. |
hvnly_agent_profile_nonce | Agent profile | Save agent profile fields. |
hvnly_agency_term_save / hvnly_agency_term_save_nonce | Agency taxonomy | Save agency term fields. |
hvnly_featured_property_save / hvnly_featured_property_save_nonce | Property meta | Registered in Havenlytics_Type.php. |
hvnly_term_advanced_img_nonce, hvnly_advanced_icon_nonce, hvnly_advanced_icon_picker_nonce | Taxonomy fields | Term image upload and Font Awesome icon picker traits. |
hvnly_inquiry_action_{id} / _hvnly_inquiry_nonce | Contact Agent inquiries | Per-inquiry action nonce (the {id} binds the token to one object). |
wp_rest | All REST routes | Standard WordPress REST nonce, paired with a permission_callback. |
Note: Per-object nonces like
hvnly_inquiry_action_{id}are stronger than a single shared action, because a token minted for one inquiry cannot be replayed against another. When you add an action that operates on a specific object, bind the object ID into the nonce action the same way.
Capability strings and their contexts
Havenlytics never gates on user roles — it gates on WordPress capabilities, which is the correct, future-proof approach. Different surfaces require different capabilities depending on the sensitivity of the action. Use the same capability the plugin uses for an equivalent surface; do not blanket everything with manage_options.
| Capability | Where it gates |
|---|---|
manage_options | The dominant admin gate: the import engine, React onboarding, all settings APIs, CacheAdmin, WorkspacePage, migration notices, email-verification admin, agent management and export, and ownership transfer. |
edit_users / create_users | Agent provisioning and the agent identity bridge (creating or linking WordPress users to agents). |
edit_post / edit_posts / edit_others_posts | Per-object property and workflow permission checks. edit_posts also gates the Price-on-Call API. |
publish_posts | Publishing a property in the workflow transition. |
upload_files | The media controller and profile photo upload. |
delete_post | Media and profile deletion. |
activate_plugins | Elementor integration checks, the ConflictChecker, and the ErrorHandler. |
PortalCapabilities (Workspace) | The Agent Workspace SPA resolves portal-scoped permissions through its own capability layer rather than raw WordPress caps, so an agent’s access to Workspace features is decided in one place. |
Common mistake: Using
current_user_can('administrator'). That checks for a role named “administrator” as if it were a capability — it is almost always wrong and silently fails on multisite and custom roles. Always check a capability such asmanage_options.
Sanitize, escape, and validate conventions
Havenlytics follows the WordPress rule of sanitize on input, validate where a value has a known shape, and escape on output. These are the exact functions used throughout the plugin — reach for the same ones.
Input sanitizers
sanitize_text_field( wp_unslash( $_POST['field'] ) )— the standard for scalar text input. Alwayswp_unslash()first, because WordPress slashes superglobals.sanitize_key()— for keys, slugs used as array keys, and option-name fragments.absint()orfilter_input( INPUT_POST, 'id', FILTER_VALIDATE_INT )— for IDs and integers.sanitize_email()paired withis_email()— sanitize, then validate the result before using it.esc_url_raw()— for URLs being stored or passed to HTTP calls (the import engine uses this before validating the host).rest_sanitize_boolean()— for booleans arriving through REST.floatval()— for coordinates;sanitize_file_name()for filenames;sanitize_title()for slugs.
The WorkspaceSecurity centralized helper
The Agent Workspace routes all sanitizing and escaping through one final class, includes/Workspace/Security/WorkspaceSecurity.php. It exposes typed entry points — text, textarea, html (via wp_kses_post), url, email, phone, number, currency, taxonomy, and array_values — plus escape and escape_attr for output. Any Workspace form or API payload is passed through it, so the sanitizing rules live in exactly one place.
Tip: If you extend the Workspace SPA or its REST controllers, route your input and output through
WorkspaceSecurityrather than callingsanitize_*ad hoc. That keeps the Workspace’s sanitizing behavior centralized and consistent, which is the entire point of the class.
Output escaping
Escape at the point of output, never at the point of storage: esc_html() for text nodes, esc_attr() for attribute values, esc_url() for links. Email values are html_entity_decode()-ed once and then re-escaped so entities are not double-encoded in the rendered message.
Email header injection
Email subjects strip \r and \n, and display phrases (from-names, reply-to names) go through EmailHeaders::sanitize_phrase(), which strips CRLF, tabs, and the header-significant characters , " < > ; :. This prevents an attacker from smuggling extra headers through a name field. See the Email System guide for the full header pipeline.
The import wizard double-nonce
The demo-import engine in includes/Admin/PropertyImportWizard.php is the most privileged writer in the plugin, so it uses a double nonce. Every import AJAX request must carry both hvnly_import_nonce and a separate CSRF token verified against hvnly_import_csrf, and the user must hold manage_options. The React onboarding UI issues both tokens (importNonce and importCsrf) when it renders.
<?php
// Pattern used by PropertyImportWizard::ajax_import_properties()
// 1) Primary AJAX nonce
if ( ! check_ajax_referer( 'hvnly_import_nonce', 'nonce', false ) ) {
wp_send_json_error( array( 'message' => 'Invalid request.' ), 403 );
}
// 2) Second, independent CSRF token
if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['csrf_token'] ) ), 'hvnly_import_csrf' ) ) {
wp_send_json_error( array( 'message' => 'Invalid request.' ), 403 );
}
// 3) Capability
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Insufficient permissions.' ), 403 );
}
// ...proceed with the batched, resumable import.
The same triple gate (nonce + CSRF + manage_options) protects hvnly_cancel_import, and the wizard’s other AJAX actions (hvnly_save_google_api_key, hvnly_save_map_provider) run through the shared verify_import_wizard_ajax_request() checker. See Import & Export for the full engine flow.
Rate limiting: VerificationRateLimiter
Public, unauthenticated endpoints — email verification and resend — are rate limited by VerificationRateLimiter. It is transient-backed with no custom table, and it is careful about what it stores as a key.
- Hashed keys. Identifiers (IP, account) are run through
HMAC-SHA256keyed withwp_salt('auth')and truncated to 20 characters before being used as a transient name. A raw IP address is never stored as an option name. - REMOTE_ADDR only.
ip()reads$_SERVER['REMOTE_ADDR']and nothing else — proxy headers such asX-Forwarded-Forare not trusted (they are trivially spoofed). Override the resolved IP with thehvnly_verification_client_ipfilter only if you sit behind a trusted proxy. - Two window types.
exceeded()enforces a fixed-window count;in_cooldown()enforces a minimum gap between attempts.
The limits applied in SessionAuthController are: on verify — verify_ip 20 per 10 minutes and verify_token 10 per hour; on resend — resend_ip 10 per hour, a 60-second per-account cooldown, resend_acct 5 per hour, and resend_daily 10 per day. Because these endpoints are reachable by unauthenticated users, failures return a deliberately generic message via generic_verify_fail() to avoid account enumeration.
Security: Never trust
X-Forwarded-Forfor rate limiting or IP logging unless you control the proxy in front of WordPress. Havenlytics defaults toREMOTE_ADDRfor exactly this reason. If you must honor a proxy header, validate the proxy chain, then supply the result throughhvnly_verification_client_ip.
The HMAC token scheme: EmailVerificationFactor
Email verification tokens use a selector + verifier design, which is the same pattern used by well-designed “remember me” and password-reset systems. It lets the server look a token up by an index without ever storing anything that could be replayed if the database leaked.
- Selector —
bin2hex(random_bytes(8)), stored in plaintext in the meta_hvnly_email_verification_selector. It is only a lookup index, not a secret. - Verifier —
bin2hex(random_bytes(32)). Onlyhash_hmac('sha256', $verifier, wp_salt('auth'))is persisted, in the meta_hvnly_email_verification_token. The raw verifier is never stored or logged. - Token — handed to the user as
selector.verifier(the two joined by a dot) in the verification link.
Verification (verify()) strips any non-hex characters from the input, looks the row up by selector, recomputes the HMAC over the supplied verifier, and compares it with hash_equals() — a constant-time comparison that does not leak information through timing. It then enforces expiry (meta _hvnly_email_verification_expires) and single use (the token is deleted on success or expiry). Any failure fires hvnly_email_verification_failed and returns user_id = 0 when the token cannot be resolved, so the endpoint never confirms whether a given selector exists.
The token TTL comes from ttl(), filterable via hvnly_email_verification_ttl (default DAY_IN_SECONDS) and clamped between one hour and 48 hours. The whole factor is pluggable through the IdentityVerificationService and the hvnly_identity_factors filter.
Security: The four properties that make this scheme safe are worth memorizing for any token you design: store a hash, never the raw secret; compare with
hash_equals()(never==or===on secrets); enforce expiry and single use; and return generic failures on public endpoints. Havenlytics does all four.
How to do it right in Havenlytics
Use these patterns verbatim when you extend the plugin. They mirror exactly what the core code does.
1. A secure admin AJAX handler
<?php
add_action( 'wp_ajax_my_plugin_do_thing', function () {
// Nonce first — false = return, do not die, so we can send JSON.
if ( ! check_ajax_referer( 'my_plugin_do_thing_nonce', 'nonce', false ) ) {
wp_send_json_error( array( 'message' => 'Invalid request.' ), 403 );
}
// Capability second.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Insufficient permissions.' ), 403 );
}
// Sanitize third.
$value = isset( $_POST['value'] )
? sanitize_text_field( wp_unslash( $_POST['value'] ) )
: '';
// ...do the work...
wp_send_json_success( array( 'saved' => $value ) );
} );
2. A secure REST permission_callback
<?php
register_rest_route( 'my/v1', '/thing', array(
'methods' => 'POST',
'callback' => 'my_plugin_rest_thing',
// Check a capability appropriate to the action, not blanket manage_options.
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'args' => array(
'id' => array(
'sanitize_callback' => 'absint',
'required' => true,
),
),
) );
// The client sends the wp_rest nonce header: wp_create_nonce( 'wp_rest' ).
3. A secure admin GET action link
<?php
// Build the link with a per-object nonce.
$url = wp_nonce_url(
admin_url( 'admin.php?page=my-page&action=archive&id=' . $item_id ),
'my_plugin_archive_' . $item_id,
'_wpnonce'
);
// Handle it on admin_init.
add_action( 'admin_init', function () {
if ( empty( $_GET['action'] ) || 'archive' !== $_GET['action'] ) {
return;
}
$id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
check_admin_referer( 'my_plugin_archive_' . $id, '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Insufficient permissions.', 403 );
}
// ...perform the action on $id...
} );
4. Sanitize and escape
<?php
// Input: sanitize + unslash; IDs via absint; Workspace data via WorkspaceSecurity.
$name = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );
$id = absint( $_POST['id'] ?? 0 );
$email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
if ( $email && ! is_email( $email ) ) {
$email = ''; // validate after sanitizing
}
// Output: escape at the boundary.
printf(
'<a href="%s">%s</a>',
esc_url( $link ),
esc_html( $name )
);
5. Token handling
<?php
// Never store a raw secret. HMAC it with an auth salt, compare in constant time.
$verifier = bin2hex( random_bytes( 32 ) );
$stored = hash_hmac( 'sha256', $verifier, wp_salt( 'auth' ) );
update_post_meta( $user_id, '_my_token', $stored );
update_post_meta( $user_id, '_my_token_expires', time() + HOUR_IN_SECONDS );
// On verify:
$candidate = hash_hmac( 'sha256', $incoming_verifier, wp_salt( 'auth' ) );
$ok = hash_equals( $stored, $candidate ) // constant-time
&& (int) get_post_meta( $user_id, '_my_token_expires', true ) > time(); // expiry
if ( $ok ) {
delete_post_meta( $user_id, '_my_token' ); // single use
}
// On any public endpoint, return a generic failure message either way.
Best practices
- Verify a nonce and check a capability on every state-changing request — AJAX, REST, and admin GET links alike.
- Match the capability to the action’s sensitivity. Reserve
manage_optionsfor site-wide settings; use per-object caps (edit_post,publish_posts) for object operations. - Sanitize on input with
wp_unslash()first; validate values with a known shape (is_email(),FILTER_VALIDATE_INT); escape on output. - Route all Agent Workspace input and output through
WorkspaceSecurity. - For tokens, store only an HMAC, compare with
hash_equals(), and enforce expiry plus single use. - Rate limit public endpoints and return generic failures to prevent enumeration.
Common mistakes
- Forgetting
wp_unslash()before sanitizing, which stores backslash-escaped data. - Checking a role name (
administrator) instead of a capability (manage_options). - Comparing secret tokens with
==or===instead ofhash_equals(), leaking timing information. - Trusting
X-Forwarded-Forfor rate limiting or IP logging. - Escaping at storage time instead of at output, leading to double-escaping.
- Storing a raw token or IP address as a transient/option name instead of a hash.