PluginProbe
JetFormBuilder — Dynamic Blocks Form Builder / 3.6.5.4
JetFormBuilder — Dynamic Blocks Form Builder v3.6.5.4
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-registry.php

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

246 lines 8.5 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 * Single, site-wide, admin-managed allowlist of custom function names that the
13 * "Server-Side callback" validation rule (`Server_Side_Rule`) is permitted to invoke
14 * via `call_user_func()`.
15 *
16 * Unlike the retired `Ssr_Callback_Allowlist`, this registry is never populated by
17 * saving a form, a reusable block, a frontend submission, or a dynamic preset result.
18 * A new custom function name is added here either by a `manage_options` admin explicitly
19 * typing it in Settings, or — once, per site — by the legacy-migration import
20 * (`Migrations\Versions\Version_3_6_5_3`) restoring names that were already in use before
21 * the update. Either way, the name must clear the same validation
22 * (`Server_Side_Rule::NOT_ALLOWED` denylist, not already a built-in, `function_exists()`)
23 * before it becomes callable. This closes the original self-service trust gap (a form
24 * editor typing an existing function name into a field never made that function callable
25 * on its own) while still letting existing sites keep working after the update without
26 * requiring a manual per-name review of every function already relied upon.
27 *
28 * @since 3.6.5.3
29 */
30 class Ssr_Callback_Registry {
31
32 const OPTION_KEY = 'jet_fb_ssr_allowed_callbacks_registry';
33
34 /**
35 * @return string[] Lowercased, de-duplicated function names currently allowed.
36 */
37 public static function get_allowed_callbacks(): array {
38 return self::read_names_option( self::OPTION_KEY );
39 }
40
41 /**
42 * @return string[]
43 */
44 private static function read_names_option( string $option_key ): array {
45 $stored = get_option( $option_key, array() );
46
47 if ( ! is_array( $stored ) ) {
48 return array();
49 }
50
51 $stored = array_map( 'strtolower', array_filter( $stored, 'is_string' ) );
52
53 return array_values( array_unique( $stored ) );
54 }
55
56 /**
57 * Validates and persists a new registry from raw admin-submitted textarea lines.
58 *
59 * Each line must, independently:
60 * - survive `Server_Side_Rule::sanitize_callback_name()` unchanged (only word
61 * characters, same format rule enforced at validation time);
62 * - not be on the `Server_Side_Rule::NOT_ALLOWED` denylist;
63 * - not already be one of the built-in callbacks (those are offered separately and
64 * never reach `call_user_func()`, so listing them here would be pure noise);
65 * - resolve to an existing PHP function.
66 *
67 * @since 3.6.5.3
68 *
69 * @param string[] $raw_lines Raw, unsanitized textarea lines (one name per line).
70 *
71 * @return array{saved: string[], rejected: array<string, string>} Accepted names and
72 * rejected lines with
73 * a human-readable
74 * reason.
75 */
76 public static function save_allowed_callbacks( array $raw_lines ): array {
77 $saved = array();
78 $rejected = array();
79
80 foreach ( $raw_lines as $raw_line ) {
81 $line = trim( (string) $raw_line );
82
83 if ( '' === $line ) {
84 continue;
85 }
86
87 $error = '';
88 $name = self::validate_single_name( $line, $error );
89
90 if ( '' === $name ) {
91 $rejected[ $line ] = $error;
92
93 continue;
94 }
95
96 $saved[ $name ] = true;
97 }
98
99 $saved = array_values( array_keys( $saved ) );
100
101 update_option( self::OPTION_KEY, $saved, false );
102
103 return array(
104 'saved' => $saved,
105 'rejected' => $rejected,
106 );
107 }
108
109 /**
110 * One-time legacy-migration entry point (`Migrations\Versions\Version_3_6_5_3`): merges
111 * a batch of static custom SSR callback names found in existing forms directly into the
112 * trusted, live registry — restoring backward compatibility for sites where those names
113 * were already relied upon before the update, without requiring a manual per-name
114 * review that many admins would never see or act on (issues-tracker #20361 follow-up).
115 *
116 * A name still only becomes trusted if it clears the exact same validation as the
117 * manual textarea path: not on the `Server_Side_Rule::NOT_ALLOWED` denylist, not
118 * already a built-in, and `function_exists()`. The denylist is therefore the primary
119 * safety barrier for this path — see docs/vulnerability/20361/not-allow-list.md for the
120 * audit that expanded it accordingly. A name that fails validation (most commonly:
121 * denylisted, or the providing plugin/theme is no longer active) is not imported and
122 * never silently retried — an admin who wants it can still add it manually in Settings.
123 *
124 * @since 3.6.5.3
125 *
126 * @param string[] $raw_lines Raw, unsanitized names (one per entry).
127 *
128 * @return array{imported: string[], rejected: array<string, string>} Names merged into
129 * the trusted list
130 * and rejected names
131 * with a reason.
132 */
133 public static function import_trusted_callbacks( array $raw_lines ): array {
134 $trusted = array();
135
136 foreach ( self::get_allowed_callbacks() as $name ) {
137 $trusted[ $name ] = true;
138 }
139
140 $imported = array();
141 $rejected = array();
142
143 foreach ( $raw_lines as $raw_line ) {
144 $line = trim( (string) $raw_line );
145
146 if ( '' === $line ) {
147 continue;
148 }
149
150 $error = '';
151 $name = self::validate_single_name( $line, $error );
152
153 if ( '' === $name ) {
154 $rejected[ $line ] = $error;
155
156 continue;
157 }
158
159 if ( isset( $trusted[ $name ] ) ) {
160 continue;
161 }
162
163 $trusted[ $name ] = true;
164 $imported[] = $name;
165 }
166
167 if ( ! empty( $imported ) ) {
168 update_option( self::OPTION_KEY, array_values( array_keys( $trusted ) ), false );
169 }
170
171 return array(
172 'imported' => $imported,
173 'rejected' => $rejected,
174 );
175 }
176
177 /**
178 * Shared per-name validation used by `save_allowed_callbacks()` and
179 * `import_trusted_callbacks()`.
180 *
181 * `Server_Side_Rule::FIXED_SAFE` names (e.g. `rest_is_boolean`) are rejected here the
182 * same as an `is_builtin_callback()` name: `Server_Side_Rule::get_allowed_callbacks()`
183 * already merges `FIXED_SAFE` into the effective allowed set unconditionally, so such a
184 * name validates and runs regardless of whether it is also present in this registry.
185 * Accepting it here would let an admin "add" it as if it were a genuinely new custom
186 * callback, and — for `import_trusted_callbacks()` specifically — let the legacy
187 * migration queue it as "restored" in its notice even though nothing about its trust
188 * status actually changed (review finding, issues-tracker #20361 follow-up).
189 *
190 * @since 3.6.5.3
191 *
192 * @param string $raw_line Raw, unsanitized function name.
193 * @param string $error By-reference human-readable rejection reason, set only when
194 * an empty string is returned.
195 *
196 * @return string Sanitized, lowercase function name, or '' if rejected.
197 */
198 private static function validate_single_name( string $raw_line, string &$error = '' ): string {
199 $name = Server_Side_Rule::sanitize_callback_name( $raw_line );
200
201 if ( '' === $name ) {
202 $error = __( 'Only letters, numbers and underscores are allowed.', 'jet-form-builder' );
203
204 return '';
205 }
206
207 if ( in_array( $name, Server_Side_Rule::NOT_ALLOWED, true ) ) {
208 $error = __( 'This function is on the built-in denylist and can never be allowed.', 'jet-form-builder' );
209
210 return '';
211 }
212
213 if ( Server_Side_Rule::is_builtin_callback( $name ) || in_array( $name, Server_Side_Rule::FIXED_SAFE, true ) ) {
214 $error = __( 'This is already a built-in callback and does not need to be added here.', 'jet-form-builder' );
215
216 return '';
217 }
218
219 if ( ! function_exists( $name ) ) {
220 $error = __( 'No such PHP function exists.', 'jet-form-builder' );
221
222 return '';
223 }
224
225 return $name;
226 }
227
228 /**
229 * Derives a stable, opaque identifier for a custom callback name, used only when
230 * rendering the public `data-validation-rules` JSON attribute
231 * (`Module::add_validation_block()`). It is not a secret and is never used by the
232 * server to select a callback — the server always resolves the callback from the
233 * clean name stored in the form itself. It exists purely so the plain function name
234 * is not exposed verbatim in rendered markup.
235 *
236 * Deterministic per name, stable across requests/page loads, but salted with
237 * `wp_salt()` so it isn't guessable/reproducible off-site.
238 *
239 * @since 3.6.5.3
240 */
241 public static function id_for_callback( string $function_name ): string {
242 return substr( hash_hmac( 'sha256', strtolower( $function_name ), wp_salt() ), 0, 12 );
243 }
244
245 }
246