| @@ -191,8 +191,16 @@ | ||
| 191 | 191 | |
| 192 | 192 | /** |
| 193 | 193 | * Extracts the field label from the dynamic field key ( or field slug ). |
| 194 | 194 | * |
| 195 | + * ALWAYS escape the return value at the sink. The label is decoded from the | |
| 196 | + * submitted key via {@see self::decode()}, so it is submitter-controlled and | |
| 197 | + * unauthenticated — and this method is strictly more dangerous than decode() | |
| 198 | + * alone, because it additionally runs html_entity_decode(), which expands | |
| 199 | + * entities and therefore undoes any htmlspecialchars()-on-store defence. | |
| 200 | + * When a label's integrity matters, read it from the form's stored block | |
| 201 | + * definitions by block id instead of from the submitted key. | |
| 202 | + * | |
| 195 | 203 | * @param string $field_key Dynamic field key. |
| 196 | 204 | * @since 1.1.1 |
| 197 | 205 | * @return string Extracted field label. |
| 198 | 206 | */ |
| @@ -201,12 +209,14 @@ | ||
| 201 | 209 | return ''; |
| 202 | 210 | } |
| 203 | 211 | |
| 204 | 212 | $label = explode( '-lbl-', $field_key )[1]; |
| 205 | - // Getting the encrypted label. we are removing the block slug here. | |
| 213 | + // Getting the encoded label. we are removing the block slug here. | |
| 206 | 214 | $label = explode( '-', $label )[0]; |
| 207 | 215 | |
| 208 | - return $label ? html_entity_decode( self::decrypt( $label ) ) : ''; | |
| 216 | + // The result is submitter-controlled and unauthenticated — escape it for its | |
| 217 | + // context at the sink (esc_html, escape_csv_formula, ...), never trust it. | |
| 218 | + return $label ? html_entity_decode( self::decode( $label ) ) : ''; | |
| 209 | 219 | } |
| 210 | 220 | |
| 211 | 221 | /** |
| 212 | 222 | * Extracts the block ID from the dynamic field key ( or field slug ). |
| @@ -509,15 +519,34 @@ | ||
| 509 | 519 | return is_string( $output ) ? $output : ''; |
| 510 | 520 | } |
| 511 | 521 | |
| 512 | 522 | /** |
| 513 | - * Encrypt data using base64. | |
| 523 | + * Base64-encode a string for use inside a field key. | |
| 514 | 524 | * |
| 515 | - * @param string $input The input string which needs to be encrypted. | |
| 516 | - * @since 0.0.1 | |
| 517 | - * @return string The encrypted string. | |
| 525 | + * Replaces encrypt(), which is retained as a deprecated alias. The encoding itself | |
| 526 | + * is unchanged since 0.0.1 — only the name is new. | |
| 527 | + * | |
| 528 | + * NOT ENCRYPTION. This is plain, unkeyed base64 (padding stripped) used only to | |
| 529 | + * carry a label inside a field key. There is no key, no HMAC and no integrity | |
| 530 | + * protection, so a value round-tripped through decode() is fully attacker-forgeable | |
| 531 | + * and must never be treated as authentic or trusted as a security boundary. When a | |
| 532 | + * label's integrity matters, look it up from the form's stored block definitions by | |
| 533 | + * block id instead of decoding it from the submitted key. | |
| 534 | + * | |
| 535 | + * Two behaviours worth knowing before relying on this pair: | |
| 536 | + * | |
| 537 | + * - The input is run through wp_strip_all_tags(), so this is not a lossless | |
| 538 | + * round trip: decode( encode( $x ) ) !== $x whenever $x contains markup. | |
| 539 | + * That stripping happens on this trusted side only — decode() returns raw | |
| 540 | + * submitted bytes and does NOT strip anything, so every sink must escape for | |
| 541 | + * its own context (esc_html(), escape_csv_formula(), ...). | |
| 542 | + * - Falsy input (including the string '0') returns '', not base64. | |
| 543 | + * | |
| 544 | + * @param string $input The input string to encode. | |
| 545 | + * @since 2.12.3 | |
| 546 | + * @return string The base64-encoded string (padding removed). | |
| 518 | 547 | */ |
| 519 | - public static function encrypt( $input ) { | |
| 548 | + public static function encode( $input ) { | |
| 520 | 549 | // If the input is empty or not a string, then abandon ship. |
| 521 | 550 | if ( empty( $input ) || ! is_string( $input ) ) { |
| 522 | 551 | return ''; |
| 523 | 552 | } |
| @@ -524,32 +553,67 @@ | ||
| 524 | 553 | |
| 525 | 554 | // Strip HTML tags to prevent them from being included in IDs and field names. |
| 526 | 555 | $input = wp_strip_all_tags( $input ); |
| 527 | 556 | |
| 528 | - // Encrypt the input and return it. | |
| 557 | + // Base64-encode the input and return it. | |
| 529 | 558 | $base_64 = base64_encode( $input ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode |
| 530 | 559 | return rtrim( $base_64, '=' ); |
| 531 | 560 | } |
| 532 | 561 | |
| 533 | 562 | /** |
| 534 | - * Decrypt data using base64. | |
| 563 | + * Base64-encode a string. | |
| 535 | 564 | * |
| 536 | - * @param string $input The input string which needs to be decrypted. | |
| 565 | + * @deprecated 2.12.3 Use {@see self::encode()}. The name wrongly implied a security | |
| 566 | + * boundary — this is unkeyed base64, not encryption. | |
| 567 | + * | |
| 568 | + * @param string $input The input string to encode. | |
| 537 | 569 | * @since 0.0.1 |
| 538 | - * @return string The decrypted string. | |
| 570 | + * @return string The base64-encoded string. | |
| 539 | 571 | */ |
| 540 | - public static function decrypt( $input ) { | |
| 572 | + public static function encrypt( $input ) { | |
| 573 | + return self::encode( $input ); | |
| 574 | + } | |
| 575 | + | |
| 576 | + /** | |
| 577 | + * Base64-decode a string produced by encode(). | |
| 578 | + * | |
| 579 | + * Replaces decrypt(), which is retained as a deprecated alias. The decoding itself | |
| 580 | + * is unchanged since 0.0.1 — only the name is new. | |
| 581 | + * | |
| 582 | + * NOT DECRYPTION. See {@see self::encode()} — the result is unauthenticated and | |
| 583 | + * attacker-forgeable; do not trust it where integrity matters. The output is raw | |
| 584 | + * submitted bytes: no tag stripping, no sanitising. Escape it at the sink. | |
| 585 | + * | |
| 586 | + * @param string $input The input string to decode. | |
| 587 | + * @since 2.12.3 | |
| 588 | + * @return string The decoded string. | |
| 589 | + */ | |
| 590 | + public static function decode( $input ) { | |
| 541 | 591 | // If the input is empty or not a string, then abandon ship. |
| 542 | 592 | if ( empty( $input ) || ! is_string( $input ) ) { |
| 543 | 593 | return ''; |
| 544 | 594 | } |
| 545 | 595 | |
| 546 | - // Decrypt the input and return it. | |
| 596 | + // Base64-decode the input and return it. | |
| 547 | 597 | $base_64 = $input . str_repeat( '=', strlen( $input ) % 4 ); |
| 548 | 598 | return base64_decode( $base_64 ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode |
| 549 | 599 | } |
| 550 | 600 | |
| 551 | 601 | /** |
| 602 | + * Base64-decode a string. | |
| 603 | + * | |
| 604 | + * @deprecated 2.12.3 Use {@see self::decode()}. The name wrongly implied a security | |
| 605 | + * boundary — this is unkeyed base64, not decryption. | |
| 606 | + * | |
| 607 | + * @param string $input The input string to decode. | |
| 608 | + * @since 0.0.1 | |
| 609 | + * @return string The decoded string. | |
| 610 | + */ | |
| 611 | + public static function decrypt( $input ) { | |
| 612 | + return self::decode( $input ); | |
| 613 | + } | |
| 614 | + | |
| 615 | + /** | |
| 552 | 616 | * Update an option from the database. |
| 553 | 617 | * |
| 554 | 618 | * @param string $key The option key. |
| 555 | 619 | * @param mixed $value The value to update. |
| @@ -685,8 +749,43 @@ | ||
| 685 | 749 | ); |
| 686 | 750 | } |
| 687 | 751 | |
| 688 | 752 | /** |
| 753 | + * Resolve the submitting user, surviving REST's nonce-less de-authentication. | |
| 754 | + * | |
| 755 | + * The public form endpoints authenticate with the HMAC Submit_Token rather than | |
| 756 | + * a nonce, because the form markup is page-cacheable and core answers a nonce | |
| 757 | + * that fails verification with a hard 403 — a value baked into a cached page | |
| 758 | + * would break submissions once it aged out. | |
| 759 | + * | |
| 760 | + * The trade-off is that `rest_cookie_check_errors()` treats a cookie-carrying | |
| 761 | + * REST request with no nonce as anonymous and calls `wp_set_current_user( 0 )` | |
| 762 | + * before dispatch. So `get_current_user_id()` returns 0 during a submission even | |
| 763 | + * when the visitor is signed in, which silently drops entry attribution and | |
| 764 | + * blanks every `{user_*}` smart tag. | |
| 765 | + * | |
| 766 | + * `wp_validate_auth_cookie()` reads the logged-in cookie directly and is | |
| 767 | + * unaffected by that reset. It verifies the cookie's HMAC, so the identity is | |
| 768 | + * authenticated, not merely asserted — this is the same check core itself uses | |
| 769 | + * for cookie auth, and the pattern already used by the Pro login route. | |
| 770 | + * | |
| 771 | + * Returns 0 for genuinely anonymous submissions, so callers can keep treating | |
| 772 | + * falsy as "not logged in". | |
| 773 | + * | |
| 774 | + * @since 2.12.6 | |
| 775 | + * @return int User ID, or 0 when the submitter is not signed in. | |
| 776 | + */ | |
| 777 | + public static function get_submitting_user_id() { | |
| 778 | + $user_id = get_current_user_id(); | |
| 779 | + | |
| 780 | + if ( $user_id ) { | |
| 781 | + return $user_id; | |
| 782 | + } | |
| 783 | + | |
| 784 | + return absint( wp_validate_auth_cookie( '', 'logged_in' ) ); | |
| 785 | + } | |
| 786 | + | |
| 787 | + /** | |
| 689 | 788 | * Check if the current user has a given capability. |
| 690 | 789 | * |
| 691 | 790 | * @param string $capability The capability to check. |
| 692 | 791 | * @param array<mixed> $args Optional. Additional arguments to pass to the capability check. |
| @@ -1797,9 +1896,9 @@ | ||
| 1797 | 1896 | * |
| 1798 | 1897 | * @return array<mixed> |
| 1799 | 1898 | */ |
| 1800 | 1899 | public static function sureforms_get_integration() { |
| 1801 | - $suretrigger_connected = apply_filters( 'suretriggers_is_user_connected', '' ); | |
| 1900 | + $suretrigger_connected = apply_filters( 'suretriggers_is_user_connected', '' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- SureTriggers' own filter; the name must match SureTriggers exactly to integrate. | |
| 1802 | 1901 | $logo_sure_triggers = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suretriggers.svg' ); |
| 1803 | 1902 | $logo_full = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suretriggers_full.svg' ); |
| 1804 | 1903 | $logo_sure_mails = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/suremails.svg' ); |
| 1805 | 1904 | $logo_uae = file_get_contents( plugin_dir_path( SRFM_FILE ) . 'images/uae.svg' ); |
| @@ -2044,8 +2143,29 @@ | ||
| 2044 | 2143 | return defined( 'SRFM_PRO_VER' ); |
| 2045 | 2144 | } |
| 2046 | 2145 | |
| 2047 | 2146 | /** |
| 2147 | + * Whether SureForms promotional content should be hidden. | |
| 2148 | + * | |
| 2149 | + * Covers review requests, cross-sell banners and announcements. The free | |
| 2150 | + * plugin never hides them on its own; SureForms Pro's Distraction Free mode | |
| 2151 | + * turns this on through the filter. | |
| 2152 | + * | |
| 2153 | + * @since 2.12.8 | |
| 2154 | + * @return bool | |
| 2155 | + */ | |
| 2156 | + public static function hide_promotions() { | |
| 2157 | + /** | |
| 2158 | + * Filter whether SureForms hides its promotional content in wp-admin. | |
| 2159 | + * | |
| 2160 | + * @since 2.12.8 | |
| 2161 | + * | |
| 2162 | + * @param bool $hide Default false. | |
| 2163 | + */ | |
| 2164 | + return (bool) apply_filters( 'srfm_hide_promotions', false ); | |
| 2165 | + } | |
| 2166 | + | |
| 2167 | + /** | |
| 2048 | 2168 | * Verifies the request by checking the nonce and user capabilities. |
| 2049 | 2169 | * |
| 2050 | 2170 | * @param string $request_type The type of request, either 'rest' or 'ajax'. |
| 2051 | 2171 | * @param string $nonce_action The action name for the nonce. |
| @@ -2110,8 +2230,62 @@ | ||
| 2110 | 2230 | return implode( '-', array_slice( explode( '-', explode( '-lbl-', $field_name )[0] ), 0, 2 ) ); |
| 2111 | 2231 | } |
| 2112 | 2232 | |
| 2113 | 2233 | /** |
| 2234 | + * The active caching plugin, if there is one. | |
| 2235 | + * | |
| 2236 | + * Caching matters to SureForms because a cached page serves the same HTML to | |
| 2237 | + * everyone: the submission token is embedded at render time, and an | |
| 2238 | + * aggressively cached or JS-combining setup can serve a stale token or reorder | |
| 2239 | + * the scripts a form depends on. This is what surfaces that to the site owner | |
| 2240 | + * before it turns into "my form stopped working". | |
| 2241 | + * | |
| 2242 | + * @since 2.12.6 | |
| 2243 | + * @return string Human-readable plugin name, or '' when none is active. | |
| 2244 | + */ | |
| 2245 | + public static function get_active_caching_plugin() { | |
| 2246 | + $entry = self::get_active_caching_plugin_entry(); | |
| 2247 | + | |
| 2248 | + return null === $entry ? '' : $entry[0]; | |
| 2249 | + } | |
| 2250 | + | |
| 2251 | + /** | |
| 2252 | + * Setup guide for the active caching plugin. | |
| 2253 | + * | |
| 2254 | + * Six of the recognised plugins have a guide of their own; the rest, and any | |
| 2255 | + * site with none detected, get the general one. Sending someone to a page that | |
| 2256 | + * names the plugin they actually run is the difference between advice they can | |
| 2257 | + * follow and advice they have to translate. | |
| 2258 | + * | |
| 2259 | + * Falls back to the general guide rather than returning nothing, so the notice | |
| 2260 | + * always has somewhere to send them. | |
| 2261 | + * | |
| 2262 | + * @since 2.12.7 | |
| 2263 | + * @param string $medium Placement the link is rendered in, used as utm_medium. | |
| 2264 | + * Two surfaces show this guide -- the dashboard notice and | |
| 2265 | + * the onboarding step -- and a shared value would make the | |
| 2266 | + * two indistinguishable in reporting, which is the whole | |
| 2267 | + * point of the attribution. | |
| 2268 | + * @return string Absolute documentation URL. | |
| 2269 | + */ | |
| 2270 | + public static function get_caching_plugin_doc_url( $medium = 'form_checks_notice' ) { | |
| 2271 | + $entry = self::get_active_caching_plugin_entry(); | |
| 2272 | + $slug = null === $entry || '' === $entry[1] ? 'how-to-set-up-sureforms-with-caching-plugins' : $entry[1]; | |
| 2273 | + | |
| 2274 | + // Through the central builder rather than hardcoding the domain, so the | |
| 2275 | + // link carries the same UTM attribution as every other doc link and a | |
| 2276 | + // domain change is one edit. utm_content is the slug, so the caller can be | |
| 2277 | + // told which guide people actually open. | |
| 2278 | + return self::get_sureforms_website_url( | |
| 2279 | + 'docs/' . $slug . '/', | |
| 2280 | + [ | |
| 2281 | + 'utm_medium' => self::get_string_value( $medium ), | |
| 2282 | + 'utm_content' => $slug, | |
| 2283 | + ] | |
| 2284 | + ); | |
| 2285 | + } | |
| 2286 | + | |
| 2287 | + /** | |
| 2114 | 2288 | * Check if any of the top 10 popular WordPress SMTP plugins is active using array_intersect. |
| 2115 | 2289 | * |
| 2116 | 2290 | * @since 1.9.1 |
| 2117 | 2291 | * @return bool True if any SMTP plugin is active, false otherwise. |
| @@ -2162,9 +2336,9 @@ | ||
| 2162 | 2336 | return $default; |
| 2163 | 2337 | } |
| 2164 | 2338 | |
| 2165 | 2339 | // Apply the filter with additional arguments. |
| 2166 | - $filtered = apply_filters( $filter_name, $default, ...$args ); | |
| 2340 | + $filtered = apply_filters( $filter_name, $default, ...$args ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Generic dynamic-filter dispatcher; the caller supplies the (already prefixed) hook name. | |
| 2167 | 2341 | |
| 2168 | 2342 | // Return filtered result if it's a non-empty array. |
| 2169 | 2343 | return is_array( $filtered ) && ! empty( $filtered ) ? $filtered : $default; |
| 2170 | 2344 | } |
| @@ -2638,7 +2812,80 @@ | ||
| 2638 | 2812 | * @return int |
| 2639 | 2813 | */ |
| 2640 | 2814 | private static function get_geo_failure_ttl() { |
| 2641 | 2815 | return self::get_integer_value( apply_filters( 'srfm_geo_failure_ttl', 5 * MINUTE_IN_SECONDS ) ); |
| 2816 | + } | |
| 2817 | + | |
| 2818 | + /** | |
| 2819 | + * Caching plugins SureForms recognises, and the guide for each. | |
| 2820 | + * | |
| 2821 | + * `path => [ display name, doc slug ]`. An empty slug means there is no | |
| 2822 | + * plugin-specific guide and the general one applies. Name and slug live in one | |
| 2823 | + * array on purpose: keyed separately they drift, and a doc link that silently | |
| 2824 | + * degrades to the generic page is the kind of regression nobody reports. | |
| 2825 | + * | |
| 2826 | + * Order is precedence: the first active plugin in this list wins. The six with | |
| 2827 | + * their own guide are listed first on purpose, so a site running two caching | |
| 2828 | + * plugins is pointed at the specific guide rather than whichever plugin the | |
| 2829 | + * old alphabetical order happened to reach first. That flips the winner on a | |
| 2830 | + * few pairs -- WP Fastest Cache over WP Super Cache, SiteGround Optimizer and | |
| 2831 | + * Autoptimize over their partners -- and in each case the new winner is the | |
| 2832 | + * one that has something to say. Reordering this array changes which guide a | |
| 2833 | + * two-plugin site sees. | |
| 2834 | + * | |
| 2835 | + * @since 2.12.7 | |
| 2836 | + * @return array<string,array{0:string,1:string}> | |
| 2837 | + */ | |
| 2838 | + private static function get_known_caching_plugins() { | |
| 2839 | + return [ | |
| 2840 | + 'litespeed-cache/litespeed-cache.php' => [ 'LiteSpeed Cache', 'how-to-set-up-sureforms-with-litespeed-cache' ], | |
| 2841 | + 'wp-rocket/wp-rocket.php' => [ 'WP Rocket', 'how-to-set-up-sureforms-with-wp-rocket' ], | |
| 2842 | + 'w3-total-cache/w3-total-cache.php' => [ 'W3 Total Cache', 'how-to-set-up-sureforms-with-w3-total-cache' ], | |
| 2843 | + 'wp-fastest-cache/wpFastestCache.php' => [ 'WP Fastest Cache', 'how-to-set-up-sureforms-with-wp-fastest-cache' ], | |
| 2844 | + 'sg-cachepress/sg-cachepress.php' => [ 'SiteGround Optimizer', 'how-to-set-up-sureforms-with-siteground-optimizer' ], | |
| 2845 | + 'autoptimize/autoptimize.php' => [ 'Autoptimize', 'how-to-set-up-sureforms-with-autoptimize' ], | |
| 2846 | + 'wp-super-cache/wp-cache.php' => [ 'WP Super Cache', '' ], | |
| 2847 | + 'wp-optimize/wp-optimize.php' => [ 'WP-Optimize', '' ], | |
| 2848 | + 'cache-enabler/cache-enabler.php' => [ 'Cache Enabler', '' ], | |
| 2849 | + 'comet-cache/comet-cache.php' => [ 'Comet Cache', '' ], | |
| 2850 | + 'hummingbird-performance/wp-hummingbird.php' => [ 'Hummingbird', '' ], | |
| 2851 | + 'breeze/breeze.php' => [ 'Breeze', '' ], | |
| 2852 | + 'nitropack/main.php' => [ 'NitroPack', '' ], | |
| 2853 | + 'swift-performance-lite/performance.php' => [ 'Swift Performance Lite', '' ], | |
| 2854 | + 'wp-cloudflare-page-cache/wp-cloudflare-page-cache.php' => [ 'Super Page Cache', '' ], | |
| 2855 | + 'flying-press/flying-press.php' => [ 'FlyingPress', '' ], | |
| 2856 | + 'redis-cache/redis-cache.php' => [ 'Redis Object Cache', '' ], | |
| 2857 | + 'powered-cache/powered-cache.php' => [ 'Powered Cache', '' ], | |
| 2858 | + 'docket-cache/docket-cache.php' => [ 'Docket Cache', '' ], | |
| 2859 | + 'seraphinite-accelerator/plugin_root.php' => [ 'Seraphinite Accelerator', '' ], | |
| 2860 | + ]; | |
| 2861 | + } | |
| 2862 | + | |
| 2863 | + /** | |
| 2864 | + * The active caching plugin's entry, if there is one. | |
| 2865 | + * | |
| 2866 | + * Detection is by plugin path, mirroring is_any_smtp_plugin_active(), including | |
| 2867 | + * the multisite network-active merge. First match in | |
| 2868 | + * get_known_caching_plugins() wins; that array's order is the precedence. | |
| 2869 | + * | |
| 2870 | + * @since 2.12.7 | |
| 2871 | + * @return array{0:string,1:string}|null Name and doc slug, or null when none is active. | |
| 2872 | + */ | |
| 2873 | + private static function get_active_caching_plugin_entry() { | |
| 2874 | + $active_plugins = (array) get_option( 'active_plugins', [] ); | |
| 2875 | + | |
| 2876 | + // For multisite, merge sitewide active plugins. | |
| 2877 | + if ( is_multisite() ) { | |
| 2878 | + $network_plugins = (array) get_site_option( 'active_sitewide_plugins', [] ); | |
| 2879 | + $active_plugins = array_merge( $active_plugins, array_keys( $network_plugins ) ); | |
| 2880 | + } | |
| 2881 | + | |
| 2882 | + foreach ( self::get_known_caching_plugins() as $path => $entry ) { | |
| 2883 | + if ( in_array( $path, $active_plugins, true ) ) { | |
| 2884 | + return $entry; | |
| 2885 | + } | |
| 2886 | + } | |
| 2887 | + | |
| 2888 | + return null; | |
| 2642 | 2889 | } |
| 2643 | 2890 | |
| 2644 | 2891 | } |