diff --git a/wp-content/plugins/esp-instanda-integration/esp-instanda-integration.php b/wp-content/plugins/esp-instanda-integration/esp-instanda-integration.php index 59a005e7..fcf14912 100644 --- a/wp-content/plugins/esp-instanda-integration/esp-instanda-integration.php +++ b/wp-content/plugins/esp-instanda-integration/esp-instanda-integration.php @@ -3,7 +3,7 @@ * Plugin Name: ESP INSTANDA Start Quote Integration * Plugin URI: https://getfused.com * Description: Pushes Gravity Forms "Start a Quote" submissions into INSTANDA's Package API with write-ahead transaction logging, native entry recording, admin resubmit, and a test-mode lock. Form-agnostic - attach the feed to any form. - * Version: 1.0.7 + * Version: 1.1.0 * Requires at least: 7.0 * Requires PHP: 8.5 * Author: Getfused @@ -75,25 +75,36 @@ function platform_blockers(): array { } \add_action('admin_init', static function (): void { + if (!empty(platform_blockers())) { + return; + } // Keep the schema current if the plugin was updated without re-activation. - if (empty(platform_blockers()) && Transaction_Store::needs_migration()) { + if (Transaction_Store::needs_migration()) { Transaction_Store::install_table(); } + // Self-heal the retention cron if its scheduled event was ever lost (the schema + // self-heals above; the cron must too, or retention silently stops forever). + if (!\wp_next_scheduled(CLEANUP_HOOK)) { + \wp_schedule_event(time(), 'daily', CLEANUP_HOOK); + } +}); + +// Requirements notice registered UNCONDITIONALLY: gform_loaded never fires when Gravity +// Forms is absent, so a notice registered only inside it would be unreachable in exactly +// the case (GF missing) an admin most needs to see it. +\add_action('admin_notices', static function (): void { + $errors = platform_blockers(); + if ($errors) { + echo '

ESP INSTANDA Integration is inactive: ' + . esc_html(implode(' ', $errors)) . '

'; + } }); /* ------------------------------------------------ Gravity Forms Add-On registration */ \add_action('gform_loaded', Guard::wrap(static function (): void { if (!empty(platform_blockers()) || !method_exists('GFForms', 'include_feed_addon_framework')) { - \add_action('admin_notices', static function (): void { - $errors = platform_blockers(); - if (!$errors) { - return; - } - echo '

ESP INSTANDA Integration is inactive: ' - . esc_html(implode(' ', $errors)) . '

'; - }); - return; + return; // the requirements notice is registered unconditionally above } \GFForms::include_feed_addon_framework(); diff --git a/wp-content/plugins/esp-instanda-integration/includes/class-field-mapper.php b/wp-content/plugins/esp-instanda-integration/includes/class-field-mapper.php index d7cb3112..60bbc515 100644 --- a/wp-content/plugins/esp-instanda-integration/includes/class-field-mapper.php +++ b/wp-content/plugins/esp-instanda-integration/includes/class-field-mapper.php @@ -121,6 +121,37 @@ final class Field_Mapper { return $errors; } + /** + * Sanity-check a stored StartQuote wire body before re-sending it (resubmit trust + * boundary): the DB is treated as untrusted input. Returns error codes (empty = ok). + * Pure - uses a fixed UTC zone since only Y-m-d well-formedness is being checked. + * + * @param array $body A body previously built by to_payload(). + * @return list + */ + public function validate_payload(array $body): array { + $errors = []; + foreach (['Role', 'ActivityType', 'PQ_LocationState_CHOICE'] as $key) { + if (trim((string) ($body[$key] ?? '')) === '') { + $errors[] = $key . '_missing'; + } + } + if (!$this->is_positive_integer($body['PQ_MaximumDailyAttendance_NUM'] ?? null)) { + $errors[] = 'attendance_invalid'; + } + $email = trim((string) ($body['PQ_CustomerEmailAddress_TXT'] ?? '')); + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $errors[] = 'email_invalid'; + } + $tz = new \DateTimeZone('UTC'); + foreach (['PQ_EventStart_DATE', 'PQ_EventEnd_DATE'] as $key) { + if ($this->parse_date((string) ($body[$key] ?? ''), $tz) === null) { + $errors[] = $key . '_invalid'; + } + } + return $errors; + } + /** D8 - inclusive consecutive-day count: (end - start) + 1; a single-day event is 1. */ private function consecutive_days(\DateTimeImmutable $start, \DateTimeImmutable $end): int { return (int) $start->diff($end)->days + 1; diff --git a/wp-content/plugins/esp-instanda-integration/includes/class-instanda-addon.php b/wp-content/plugins/esp-instanda-integration/includes/class-instanda-addon.php index 4d03cff4..dba2a614 100644 --- a/wp-content/plugins/esp-instanda-integration/includes/class-instanda-addon.php +++ b/wp-content/plugins/esp-instanda-integration/includes/class-instanda-addon.php @@ -18,7 +18,7 @@ namespace ESP\Instanda; final class Instanda_AddOn extends \GFFeedAddOn { - protected $_version = '1.0.7'; + protected $_version = '1.1.0'; protected $_min_gravityforms_version = '2.10.3'; protected $_slug = 'espinstanda'; protected $_path = 'esp-instanda-integration/esp-instanda-integration.php'; diff --git a/wp-content/plugins/esp-instanda-integration/includes/class-transactions-table.php b/wp-content/plugins/esp-instanda-integration/includes/class-transactions-table.php index d7902118..a193e7be 100644 --- a/wp-content/plugins/esp-instanda-integration/includes/class-transactions-table.php +++ b/wp-content/plugins/esp-instanda-integration/includes/class-transactions-table.php @@ -159,14 +159,24 @@ final class Resubmit_Handler { $store->append_event($tx_id, 'Resubmit aborted - stored payload unreadable.', 'error'); return; } + // Trust boundary: the stored body is untrusted input on the way back out to the + // insurer - re-validate it before re-sending (guards against DB tampering). + $payload_errors = (new Field_Mapper())->validate_payload($body); + if ($payload_errors) { + $store->append_event($tx_id, 'Resubmit aborted - stored payload failed re-validation: ' . implode(', ', $payload_errors), 'error'); + return; + } + + $store->increment_retry($tx_id); // this attempt is a retry - keep the count truthful $result = (new Api_Client())->start_quote($body, $tx->dedupe_key); - if ($result->ok && $result->quote_ref !== null) { + if ($result->ok && $result->quote_ref !== null && $result->quote_ref !== '') { $store->mark_success($tx_id, $result->quote_ref, $result->url_single_use, $result->http_status); Failure_Surface::bump(); if ($tx->entry_id) { Entry_Integration::record($tx->entry_id, $result->quote_ref, (string) $result->url_single_use, 'delivered', $tx->environment); + self::notify_resubmit_success((int) $tx->entry_id); } else { // Stranded lead (no entry) - mint a fresh link and email the customer (option B). $fresh = (string) $result->url_single_use; @@ -182,4 +192,19 @@ final class Resubmit_Handler { Failure_Surface::bump(); } } + + /** Dispatch the registered espinstanda_resubmit_success notification event for an entry. */ + private static function notify_resubmit_success(int $entry_id): void { + if (!class_exists('\GFAPI')) { + return; + } + $entry = \GFAPI::get_entry($entry_id); + if (is_wp_error($entry)) { + return; + } + $form = \GFAPI::get_form((int) $entry['form_id']); + if ($form) { + \GFAPI::send_notifications($form, $entry, 'espinstanda_resubmit_success'); + } + } } diff --git a/wp-content/plugins/esp-instanda-integration/readme.txt b/wp-content/plugins/esp-instanda-integration/readme.txt new file mode 100644 index 00000000..ba4be9ad --- /dev/null +++ b/wp-content/plugins/esp-instanda-integration/readme.txt @@ -0,0 +1,67 @@ +=== ESP INSTANDA Start Quote Integration === +Contributors: getfused +Requires at least: 7.0 +Requires PHP: 8.5 +Stable tag: 1.1.0 + +Pushes Gravity Forms "Start a Quote" submissions into INSTANDA's Package API with +write-ahead transaction logging, native entry recording, admin resubmit, and a test-mode lock. + +== Changelog == + += 1.1.0 = +Remediation of the 2026-07 security/reliability/PII audit (see docs/audit-esp-instanda-2026-07.md). + +No duplicate policies / no lost quotes: +* Dedupe key is now content-addressed (form id + mapped field values) instead of a + per-request token, so an identical re-POST or a retry-after-error reuses the existing + quote instead of creating a second one at INSTANDA (F-01). Adds a 30-minute + short-circuit window and a content-key race lock. +* Refuse to resubmit an already-delivered transaction (F-12); scope the resubmit + self-recovery guard to the content dedupe key so a customer's *different* event is not + skipped (F-18). +* A 2xx response with a missing/empty quoteRef is now recorded as ambiguous + (possible_duplicate = 1), not a hard failure (F-10, F-17). +* Aborts the StartQuote when the write-ahead insert fails, so no unaudited call is made (F-14). + +Go-live safety: +* switch_to_live() now also sets the environment to Live; previously the go-live button + left real quotes hitting the Test endpoint (F-03). +* Go-live advisory flags when more than one active INSTANDA feed exists (F-02). + +Security & PII: +* Corrected the at-rest-secret docblock: the "DB dump never exposes a secret" guarantee + only holds when SPG_INSTANDA_KEY is set as a constant (F-04). +* Retention now scrubs PII from stale failed/pending rows (previously kept forever) and + exempts stranded delivered leads from the purge (F-05, F-13); wired WordPress's Erase + Personal Data tool to delete a customer's transactions (F-05). +* Uninstall now removes ALL plugin options and transients, including the encrypted + password and its key (F-15). +* Added per-IP (20/h) and per-email (5/h) rate limiting on the public create (F-06). + +Reliability & correctness: +* Timestamps are written in UTC to match the retention/dedupe cutoffs and displayed in + site-local time (F-07). NOTE: rows created before 1.1.0 were stored in site-local time; + clear the transaction table (test data) at deploy to avoid a mixed-timezone history. +* Fail closed on an unexpected error in the create path (no quote-less entry) and alert + admins, instead of silently passing (F-11); log when async recording cannot resolve a + transaction (F-09). +* The "Gravity Forms required" admin notice is now shown even when Gravity Forms is + inactive (F-16); the daily retention cron self-heals on admin_init (F-22). +* Resubmit re-validates the stored payload before re-sending (F-19); the Retries count is + now incremented and the resubmit-success notification event is dispatched (F-20). + +Theme: +* The Form-5 date-range inline JS is emitted only on pages that embed a Gravity Form and + its MutationObserver now disconnects instead of watching the DOM forever (F-23). + +Operations required alongside this release (see the audit report, Ops checklist): +* Set SPG_INSTANDA_KEY + SPG_INSTANDA_USERNAME_LIVE/_PASSWORD_LIVE + SPG_INSTANDA_ENV in + production wp-config.php (blocking) - OPS-1 / F-04. +* Deactivate the INSTANDA feed on the test Form 6 in production (blocking) - OPS-3 / F-02. +* Configure espinstanda_alert_emails and the quote-success notification (blocking) - OPS-4. +* Rotate the SendGrid API key out of committed wp-config.php - OPS-2 / F-08. +* Add reCAPTCHA/Akismet to the canonical quote form - OPS-5 / F-06. + += 1.0.7 = +* Initial audited release. diff --git a/wp-content/plugins/esp-instanda-integration/uninstall.php b/wp-content/plugins/esp-instanda-integration/uninstall.php index 1fe0995d..f840d7a9 100644 --- a/wp-content/plugins/esp-instanda-integration/uninstall.php +++ b/wp-content/plugins/esp-instanda-integration/uninstall.php @@ -20,10 +20,19 @@ try { // Table name is a trusted internal constant, not user input. $wpdb->query("DROP TABLE IF EXISTS {$table}"); - delete_option('espinstanda_db_version'); - delete_option('espinstanda_operating_mode_audit'); + // Remove EVERY plugin option and transient - including the encrypted Test password + // and its self-provisioned key - so no recoverable secret survives uninstall. + $opt = $wpdb->esc_like('espinstanda_') . '%'; + $tr = '_transient_' . $wpdb->esc_like('espinstanda_') . '%'; + $trt = '_transient_timeout_' . $wpdb->esc_like('espinstanda_') . '%'; + $wpdb->query($wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s", + $opt, + $tr, + $trt + )); - // Clear any scheduled cleanup that may still be queued. + // Clear any scheduled events that may still be queued. wp_clear_scheduled_hook('esp_instanda_cleanup'); } catch (\Throwable $e) { if (function_exists('error_log')) { diff --git a/wp-content/themes/GFeneratePress/functions.php b/wp-content/themes/GFeneratePress/functions.php index 5a967235..9541533f 100644 --- a/wp-content/themes/GFeneratePress/functions.php +++ b/wp-content/themes/GFeneratePress/functions.php @@ -189,7 +189,17 @@ function gf_validate_date_range( $validation_result ) { add_action( 'wp_footer', 'gf_date_range_scripts' ); function gf_date_range_scripts() { - if ( ! class_exists( 'GFForms' ) ) { + if ( ! class_exists( 'GFForms' ) || is_admin() ) { + return; + } + // Only emit this inline script on requests whose content embeds a Gravity Form - + // keeps ~220 lines of JS off every other page. Server-side validation is unaffected. + // If the post cannot be inspected, fall back to emitting (safe default). + $post = get_post(); + if ( $post instanceof WP_Post + && ! has_shortcode( (string) $post->post_content, 'gravityform' ) + && ! has_block( 'gravityforms/form', $post ) + && false === strpos( (string) $post->post_content, 'gform' ) ) { return; } ?> @@ -410,10 +420,14 @@ function gf_date_range_scripts() { } if ( ! attachListeners() ) { + var tries = 0; var observer = new MutationObserver( function () { - if ( attachListeners() ) observer.disconnect(); + // Stop once the fields are wired, or after a bounded number of tries, so we + // never observe the whole document subtree indefinitely on non-form pages. + if ( attachListeners() || ++tries > 20 ) observer.disconnect(); } ); observer.observe( document.body, { childList: true, subtree: true } ); + setTimeout( function () { observer.disconnect(); }, 10000 ); } } )();