| 1 |
<?php |
| 2 |
|
| 3 |
|
| 4 |
namespace JFB_Modules\Validation\Advanced_Rules; |
| 5 |
|
| 6 |
// If this file is called directly, abort. |
| 7 |
if ( ! defined( 'WPINC' ) ) { |
| 8 |
die; |
| 9 |
} |
| 10 |
|
| 11 |
/** |
| 12 |
* Retired. This class used to build a per-form allowlist automatically from whatever |
| 13 |
* SSR callback name an editor typed into a saved form/reusable block. That self-service |
| 14 |
* model did not close the actual security gap (issues-tracker #20361): a form editor |
| 15 |
* saving a form was, in effect, the same act as approving a function for execution. |
| 16 |
* |
| 17 |
* Runtime validation (`Server_Side_Rule`) now sources its allowlist exclusively from |
| 18 |
* `Ssr_Callback_Registry`, a single site-wide list only a `manage_options` admin can |
| 19 |
* change via Settings. No hooks, AJAX endpoints, or lazy/autosave re-collection remain. |
| 20 |
* |
| 21 |
* This shell is kept only because `Migrations\Versions\Version_3_6_5_2` (a permanent, |
| 22 |
* already-shipped historical migration — including its `down()` path used by the |
| 23 |
* `wp jet-form-builder db-downgrade` WP-CLI command) still references these constants |
| 24 |
* and `rebuild_from_all_forms()`. Do not add new hooks or collection behavior here; new |
| 25 |
* code must use `Ssr_Callback_Registry` instead. |
| 26 |
* |
| 27 |
* @since 3.6.5.2 |
| 28 |
* @since 3.6.5.3 Storage moved from a single global option to per-form post meta, then |
| 29 |
* retired in favor of `Ssr_Callback_Registry` in the same release. Kept |
| 30 |
* only for `Version_3_6_5_2` migration compatibility. |
| 31 |
*/ |
| 32 |
class Ssr_Callback_Allowlist { |
| 33 |
|
| 34 |
const OPTION_KEY = 'jet_fb_ssr_allowed_callbacks'; |
| 35 |
const META_KEY = '_jfb_ssr_allowed_callbacks'; |
| 36 |
|
| 37 |
const REBUILD_PROGRESS_OPTION = 'jet_fb_ssr_allowlist_rebuild_progress'; |
| 38 |
const REBUILD_LOCK_TRANSIENT = 'jet_fb_ssr_allowlist_rebuild_lock'; |
| 39 |
const REBUILD_BATCH_SIZE = 50; |
| 40 |
const REBUILD_AJAX_ACTION = 'jet_form_builder_ssr_allowlist_rebuild'; |
| 41 |
const REBUILD_TIME_BUDGET_SECONDS = 15; |
| 42 |
|
| 43 |
/** |
| 44 |
* Parses the given form `post_content`, including referenced reusable blocks, and |
| 45 |
* returns every function name configured as the "value" of an SSR ('ssr') validation |
| 46 |
* rule, excluding names that: |
| 47 |
* - resolve to one of the built-in callbacks (those never reach `call_user_func()`); |
| 48 |
* - are on the `NOT_ALLOWED` denylist; |
| 49 |
* - do not resolve to an existing function at collection time. |
| 50 |
* |
| 51 |
* @since 3.6.5.2 |
| 52 |
* |
| 53 |
* @param string $post_content |
| 54 |
* |
| 55 |
* @return string[] Lowercased, de-duplicated function names. |
| 56 |
*/ |
| 57 |
public static function collect_from_content( string $post_content ): array { |
| 58 |
if ( '' === trim( $post_content ) ) { |
| 59 |
return array(); |
| 60 |
} |
| 61 |
|
| 62 |
$found = array(); |
| 63 |
$blocks = \Jet_Form_Builder\Blocks\Block_Helper::get_blocks_from_content( $post_content ); |
| 64 |
|
| 65 |
foreach ( \Jet_Form_Builder\Blocks\Block_Helper::generate_blocks_in_space( $blocks ) as $block ) { |
| 66 |
$rules = $block['attrs']['validation']['rules'] ?? array(); |
| 67 |
|
| 68 |
foreach ( $rules as $rule ) { |
| 69 |
if ( 'ssr' !== ( $rule['type'] ?? '' ) ) { |
| 70 |
continue; |
| 71 |
} |
| 72 |
|
| 73 |
$name = Server_Side_Rule::sanitize_callback_name( (string) ( $rule['value'] ?? '' ) ); |
| 74 |
|
| 75 |
if ( |
| 76 |
'' === $name || |
| 77 |
in_array( $name, Server_Side_Rule::NOT_ALLOWED, true ) || |
| 78 |
Server_Side_Rule::is_builtin_callback( $name ) || |
| 79 |
! function_exists( $name ) |
| 80 |
) { |
| 81 |
continue; |
| 82 |
} |
| 83 |
|
| 84 |
$found[ $name ] = true; |
| 85 |
} |
| 86 |
} |
| 87 |
|
| 88 |
return array_keys( $found ); |
| 89 |
} |
| 90 |
|
| 91 |
/** |
| 92 |
* Historical one-time backfill entry point used by `Version_3_6_5_2::up()`. Writes |
| 93 |
* per-form meta and the legacy global option so a fresh/updated install lands in the |
| 94 |
* same state it always has; this data is no longer read by runtime validation. |
| 95 |
* |
| 96 |
* Bounded to `REBUILD_TIME_BUDGET_SECONDS` per call so a large forms table cannot run |
| 97 |
* past the request's `max_execution_time` inside `Auto_Migrator`'s DB transaction. When |
| 98 |
* the budget is exceeded before the table is fully scanned, progress (cursor + partial |
| 99 |
* callback accumulator) is persisted to `REBUILD_PROGRESS_OPTION` and this method |
| 100 |
* throws so `Base_Migration::install()` never marks the migration installed; the next |
| 101 |
* capable admin's `admin_init` request resumes from the persisted cursor. |
| 102 |
* |
| 103 |
* @since 3.6.5.2 |
| 104 |
* |
| 105 |
* @throws \Jet_Form_Builder\Migrations\Migration_Incomplete_Exception When the time |
| 106 |
* budget is exceeded before the scan completes. |
| 107 |
*/ |
| 108 |
public static function rebuild_from_all_forms() { |
| 109 |
global $wpdb; |
| 110 |
|
| 111 |
$progress = get_option( self::REBUILD_PROGRESS_OPTION, array() ); |
| 112 |
$callbacks = self::normalize_progress_callbacks( $progress ); |
| 113 |
$last_id = is_array( $progress ) && isset( $progress['last_id'] ) ? (int) $progress['last_id'] : 0; |
| 114 |
|
| 115 |
$started_at = microtime( true ); |
| 116 |
|
| 117 |
while ( true ) { |
| 118 |
$form_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 119 |
$wpdb->prepare( |
| 120 |
"SELECT ID FROM {$wpdb->posts} |
| 121 |
WHERE post_type = %s |
| 122 |
AND post_status IN ('publish', 'draft', 'pending', 'private', 'future') |
| 123 |
AND ID > %d |
| 124 |
ORDER BY ID ASC |
| 125 |
LIMIT %d", |
| 126 |
'jet-form-builder', |
| 127 |
$last_id, |
| 128 |
self::REBUILD_BATCH_SIZE |
| 129 |
) |
| 130 |
); |
| 131 |
|
| 132 |
if ( empty( $form_ids ) ) { |
| 133 |
break; |
| 134 |
} |
| 135 |
|
| 136 |
$is_last_page = count( $form_ids ) < self::REBUILD_BATCH_SIZE; |
| 137 |
|
| 138 |
foreach ( $form_ids as $form_id ) { |
| 139 |
$form_id = (int) $form_id; |
| 140 |
$last_id = max( $last_id, $form_id ); |
| 141 |
$post = get_post( $form_id ); |
| 142 |
|
| 143 |
if ( ! $post instanceof \WP_Post ) { |
| 144 |
continue; |
| 145 |
} |
| 146 |
|
| 147 |
$found = self::collect_from_content( $post->post_content ); |
| 148 |
|
| 149 |
update_post_meta( $form_id, self::META_KEY, $found ); |
| 150 |
|
| 151 |
foreach ( $found as $name ) { |
| 152 |
$callbacks[ $name ] = true; |
| 153 |
} |
| 154 |
|
| 155 |
// Checked per form (not only once per batch) so even the last, short page — |
| 156 |
// which never hits the `$is_last_page` break below since there is no further |
| 157 |
// page to fetch — is still time-boxed. A page short enough to end the scan can |
| 158 |
// still contain forms with very large post_content, and without this the last |
| 159 |
// page ran fully unbounded regardless of size. |
| 160 |
if ( self::rebuild_time_budget_exceeded( $started_at ) ) { |
| 161 |
self::persist_progress_and_yield( $last_id, $callbacks ); |
| 162 |
} |
| 163 |
} |
| 164 |
|
| 165 |
if ( $is_last_page ) { |
| 166 |
break; |
| 167 |
} |
| 168 |
|
| 169 |
if ( self::rebuild_time_budget_exceeded( $started_at ) ) { |
| 170 |
self::persist_progress_and_yield( $last_id, $callbacks ); |
| 171 |
} |
| 172 |
} |
| 173 |
|
| 174 |
delete_option( self::REBUILD_PROGRESS_OPTION ); |
| 175 |
|
| 176 |
update_option( self::OPTION_KEY, array_values( array_keys( $callbacks ) ), false ); |
| 177 |
} |
| 178 |
|
| 179 |
/** |
| 180 |
* Whether the rebuild has been running long enough that it should persist progress and |
| 181 |
* yield the request instead of continuing. The budget is filterable so tests can force |
| 182 |
* an early "incomplete" result without waiting on wall-clock time. |
| 183 |
* |
| 184 |
* @param float $started_at `microtime( true )` value captured when the rebuild started. |
| 185 |
*/ |
| 186 |
protected static function rebuild_time_budget_exceeded( float $started_at ): bool { |
| 187 |
$budget = (float) apply_filters( |
| 188 |
'jet-form-builder/ssr-callback-allowlist/rebuild-time-budget', |
| 189 |
self::REBUILD_TIME_BUDGET_SECONDS |
| 190 |
); |
| 191 |
|
| 192 |
return ( microtime( true ) - $started_at ) >= $budget; |
| 193 |
} |
| 194 |
|
| 195 |
/** |
| 196 |
* Writes the resume cursor and throws to yield the request. Write the cursor FIRST, then |
| 197 |
* commit: with `autocommit = 0`, an explicit `COMMIT` ends the current transaction and |
| 198 |
* the next write opens a new implicit one. Committing before writing the cursor would |
| 199 |
* leave the cursor itself in that fresh, uncommitted transaction — exactly what |
| 200 |
* `Auto_Migrator::run()`'s catch-and-rollback of the exception below would then discard, |
| 201 |
* defeating the whole point of committing early. |
| 202 |
* |
| 203 |
* @since 3.6.5.3 |
| 204 |
* |
| 205 |
* @param int $last_id |
| 206 |
* @param array<string, bool> $callbacks |
| 207 |
* |
| 208 |
* @throws \Jet_Form_Builder\Migrations\Migration_Incomplete_Exception Always. |
| 209 |
*/ |
| 210 |
/** |
| 211 |
* Normalizes `$progress['callbacks']` into the `[ name => true ]` associative-set shape |
| 212 |
* this class has always used in memory, regardless of which on-disk shape produced it. |
| 213 |
* |
| 214 |
* The already-released 3.6.5.2 build persisted `callbacks` as a plain list of names |
| 215 |
* (`array( 'is_email', 'is_numeric' )`) and converted it back via |
| 216 |
* `array_fill_keys( $progress['callbacks'] ?? array(), true )` on load. A later revision |
| 217 |
* of this method (still on this branch, never released) started reading |
| 218 |
* `$progress['callbacks']` directly as if it were already the associative-set shape — |
| 219 |
* for a real 3.6.5.2 site whose rebuild was interrupted mid-scan (a resume cursor is |
| 220 |
* only persisted when the forms table doesn't fit in one time budget) and then updates |
| 221 |
* straight to this version, the persisted list's names become integer array keys, and |
| 222 |
* `array_values( array_keys( $callbacks ) )` at the end of `rebuild_from_all_forms()` |
| 223 |
* would silently store those integers as the "restored callback names" instead of the |
| 224 |
* actual names (review finding, issues-tracker #20361 follow-up). Accepting both shapes |
| 225 |
* here keeps that upgrade path correct without depending on which build a given site's |
| 226 |
* in-progress rebuild happened to start under. |
| 227 |
* |
| 228 |
* @since 3.6.5.3 |
| 229 |
* |
| 230 |
* @param mixed $progress The raw `get_option( self::REBUILD_PROGRESS_OPTION )` result. |
| 231 |
* |
| 232 |
* @return array<string, bool> |
| 233 |
*/ |
| 234 |
private static function normalize_progress_callbacks( $progress ): array { |
| 235 |
if ( ! is_array( $progress ) || ! isset( $progress['callbacks'] ) || ! is_array( $progress['callbacks'] ) ) { |
| 236 |
return array(); |
| 237 |
} |
| 238 |
|
| 239 |
$raw = $progress['callbacks']; |
| 240 |
|
| 241 |
// Associative-set shape (current/new format): every key is already a string name. |
| 242 |
// A plain list (legacy 3.6.5.2 format) has only integer keys, so this check tells |
| 243 |
// the two apart without needing to inspect a version flag that was never recorded. |
| 244 |
if ( array_keys( $raw ) === array_keys( array_keys( $raw ) ) ) { |
| 245 |
return array_fill_keys( |
| 246 |
array_filter( $raw, 'is_string' ), |
| 247 |
true |
| 248 |
); |
| 249 |
} |
| 250 |
|
| 251 |
return array_filter( |
| 252 |
$raw, |
| 253 |
static function ( $key ) { |
| 254 |
return is_string( $key ); |
| 255 |
}, |
| 256 |
ARRAY_FILTER_USE_KEY |
| 257 |
); |
| 258 |
} |
| 259 |
|
| 260 |
private static function persist_progress_and_yield( int $last_id, array $callbacks ) { |
| 261 |
update_option( |
| 262 |
self::REBUILD_PROGRESS_OPTION, |
| 263 |
array( |
| 264 |
'last_id' => $last_id, |
| 265 |
'callbacks' => $callbacks, |
| 266 |
), |
| 267 |
false |
| 268 |
); |
| 269 |
|
| 270 |
\Jet_Form_Builder\Db_Queries\Execution_Builder::instance()->transaction_commit(); |
| 271 |
|
| 272 |
throw new \Jet_Form_Builder\Migrations\Migration_Incomplete_Exception( 'SSR allowlist rebuild exceeded its time budget; will resume on the next request.' ); |
| 273 |
} |
| 274 |
|
| 275 |
} |
| 276 |
|