PluginProbe
JetFormBuilder — Dynamic Blocks Form Builder / 3.6.6
JetFormBuilder — Dynamic Blocks Form Builder v3.6.6
3.6.6.1 3.6.6 3.6.5.4 3.6.5.3 3.6.5.2 3.6.5.1 3.6.5 3.6.4.2 3.6.4.1 3.6.4 3.6.3.1 3.6.3 3.6.2.2 3.6.2.1 3.6.2 3.6.1.1 3.6.1 3.6.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 All 134 releases
jetformbuilder / modules / validation / advanced-rules / ssr-callback-allowlist.php

ssr-callback-allowlist.php in JetFormBuilder — Dynamic Blocks Form Builder 3.6.6, at modules/validation/advanced-rules/ssr-callback-allowlist.php

276 lines 10.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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