PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.14
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.14
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / app / Modules / MCP / Support / WriteGuard.php

WriteGuard.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.14, at app/Modules/MCP/Support/WriteGuard.php

387 lines 15.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Modules\MCP\Support;
4
5 defined('ABSPATH') || exit;
6
7 /**
8 * Safety rails for mutating MCP tools. Annotations are UX hints, not safety —
9 * this is where real protection lives.
10 *
11 * EVERY write routes through Mutation::runGuarded, not just the destructive
12 * ones. The reason is prompt injection: entry field values are written by
13 * anonymous members of the public and land in the agent's context (fenced by
14 * MCPHelper::untrusted(), but a fence is only a hint). The dry-run ->
15 * confirm_token round-trip is the one mechanism here that forces a second pass
16 * through the operator before anything is written, so an instruction smuggled
17 * into a form submission cannot complete a write on its own.
18 *
19 * Two mechanisms:
20 * 1. Dry-run + confirmation token bound to the entity's CURRENT state, so an
21 * agent can never act on stale data (the fingerprint must still match at
22 * execute time, else a fresh preview is forced).
23 * 2. Idempotency keys, so a retried mutation returns the cached result instead
24 * of running twice.
25 *
26 * CONTRACT: every ability that writes MUST route through Mutation::runGuarded
27 * (which calls confirm() before mutating) and merge schemaProps() into its
28 * input schema — here or in Pro via fluentform/mcp_loaded. A tool whose
29 * annotations lack `readonly` and which does not do this is a bug; the test
30 * suite asserts the pairing.
31 */
32 class WriteGuard
33 {
34 const CONFIRM_TTL = 300;
35
36 const IDEM_TTL = 86400;
37
38 /**
39 * How long one execution may hold its claim before another request may
40 * reclaim it.
41 *
42 * This is a LEASE, and the trade-off is deliberate: without an expiry a
43 * request killed mid-write (OOM, timeout, fatal) would block its entity
44 * forever. The cost is that a write which genuinely outlives the lease can
45 * be taken over while still running, so at-most-once degrades to
46 * at-least-once in that window. Sized well above any write here (the
47 * slowest is a full form save) and above the usual PHP max_execution_time,
48 * so outliving it means the request is almost certainly already dead — and
49 * Mutation::runGuarded records lease_expired_mid_write in the audit when it
50 * happens, so the case is visible rather than silent.
51 */
52 const CLAIM_TTL = 120;
53
54 /** Shared prefix so claimKey() and the sweep can never drift apart. */
55 const CLAIM_PREFIX = 'ff_mcp_claim_';
56
57 /** Marks an idempotency record whose mutation started but never reported. */
58 const IN_FLIGHT = '__ff_mcp_in_flight';
59
60 /**
61 * The three params every guarded write shares, so the confirmation contract
62 * is declared once instead of copy-pasted into each tool's input_schema.
63 * Merge into a definition's `properties`.
64 */
65 public static function schemaProps()
66 {
67 return [
68 'dry_run' => ['type' => 'boolean', 'description' => 'Preview the change without writing; returns a confirm_token.'],
69 'confirm_token' => ['type' => 'string', 'description' => 'The token from the dry_run preview, required to execute.'],
70 'idempotency_key' => ['type' => 'string', 'description' => 'Optional; a retry with the same key will not act twice.'],
71 ];
72 }
73
74 public static function preview($tool, $entityKey, $fingerprint, array $preview)
75 {
76 $token = substr(wp_hash($tool . '|' . $entityKey . '|' . $fingerprint . '|' . wp_generate_uuid4()), 0, 32);
77
78 set_transient(self::confirmKey($tool, $entityKey), [
79 'token' => $token,
80 'fingerprint' => $fingerprint,
81 ], self::CONFIRM_TTL);
82
83 return [
84 'dry_run' => true,
85 'preview' => $preview,
86 'confirm_token' => $token,
87 'expires_in_seconds' => self::CONFIRM_TTL,
88 'next_step' => 'Call this tool again with the same parameters plus confirm_token (and an idempotency_key) to execute.',
89 ];
90 }
91
92 /**
93 * Validate a confirm_token against the entity's current fingerprint.
94 *
95 * @return true|\WP_Error
96 */
97 public static function confirm($tool, $entityKey, $currentFingerprint, $token)
98 {
99 if (empty($token)) {
100 return MCPHelper::error(
101 ErrorCodes::CONFIRMATION_REQUIRED,
102 __('This action changes data. Call again with dry_run:true to preview, then pass the returned confirm_token to execute.', 'fluentform'),
103 ['next_step' => 'set dry_run:true']
104 );
105 }
106
107 $stored = get_transient(self::confirmKey($tool, $entityKey));
108
109 if (!is_array($stored) || empty($stored['token'])) {
110 return MCPHelper::error(
111 ErrorCodes::CONFIRMATION_EXPIRED,
112 __('Your confirmation has expired. Run a fresh dry_run to preview and get a new confirm_token.', 'fluentform'),
113 ['next_step' => 'set dry_run:true']
114 );
115 }
116
117 if (!hash_equals((string) $stored['token'], (string) $token)) {
118 return MCPHelper::error(
119 ErrorCodes::CONFIRMATION_INVALID,
120 __('The confirm_token does not match. Run a fresh dry_run.', 'fluentform'),
121 ['next_step' => 'set dry_run:true']
122 );
123 }
124
125 if ((string) $stored['fingerprint'] !== (string) $currentFingerprint) {
126 delete_transient(self::confirmKey($tool, $entityKey));
127 return MCPHelper::error(
128 ErrorCodes::STATE_CHANGED,
129 __('The record changed since you previewed it. Run a fresh dry_run to see the current state before executing.', 'fluentform'),
130 ['next_step' => 'set dry_run:true']
131 );
132 }
133
134 delete_transient(self::confirmKey($tool, $entityKey));
135
136 return true;
137 }
138
139 /**
140 * Cached result of an earlier execution with the same idempotency key, or
141 * null. Checked BEFORE confirm-token validation (tokens are single-use, so
142 * a lost-response retry only ever has a consumed token) and before entity
143 * resolution (the entity may no longer exist after a destructive write).
144 */
145 public static function replay($tool, $entityKey, $key)
146 {
147 if (empty($key)) {
148 return null;
149 }
150
151 $cached = get_transient(self::idemKey($tool, $entityKey, $key));
152 if (false === $cached) {
153 return null;
154 }
155
156 // An attempt was recorded but never completed — see idempotent().
157 if (is_array($cached) && !empty($cached[self::IN_FLIGHT])) {
158 return MCPHelper::error(
159 ErrorCodes::EXECUTION_UNKNOWN,
160 __('An earlier attempt with this idempotency_key started but its outcome was never recorded, so it may or may not have completed. Check the current state before acting: if the change is already there, nothing more is needed; if it is not, retry with a NEW idempotency_key.', 'fluentform'),
161 ['retryable' => false, 'next_step' => 'verify the current state, then retry with a new idempotency_key']
162 );
163 }
164
165 return is_array($cached) ? array_merge($cached, ['idempotent_replay' => true]) : $cached;
166 }
167
168 /**
169 * Run a mutation at most once per idempotency key.
170 *
171 * The claim in Mutation::runGuarded stops two callers running at the same
172 * time. This closes the other half: a crash BETWEEN the durable write and
173 * the recording of its result. Writing the result afterwards is not enough —
174 * if the process dies in that window the retry finds no record and creates a
175 * second form.
176 *
177 * So the attempt is recorded first and replaced by the result on success. A
178 * retry that finds only the marker is told the outcome is UNKNOWN rather
179 * than being allowed to duplicate the write. A mutation that fails cleanly
180 * wrote nothing, so its marker is cleared and the key stays usable; one that
181 * throws keeps the marker, because a partial write cannot be ruled out.
182 */
183 public static function idempotent($tool, $entityKey, $key, callable $fn)
184 {
185 if (empty($key)) {
186 return $fn();
187 }
188
189 $replay = self::replay($tool, $entityKey, $key);
190 if (null !== $replay) {
191 return $replay;
192 }
193
194 $cacheKey = self::idemKey($tool, $entityKey, $key);
195 set_transient($cacheKey, [self::IN_FLIGHT => true, 'started_at' => time()], self::IDEM_TTL);
196
197 $result = $fn();
198
199 if (is_wp_error($result)) {
200 delete_transient($cacheKey);
201 } else {
202 set_transient($cacheKey, $result, self::IDEM_TTL);
203 }
204
205 return $result;
206 }
207
208 /**
209 * Take the exclusive right to execute this (tool, entity) once, or fail.
210 *
211 * Neither confirm() nor idempotent() is atomic on its own: both are
212 * read-check-write against transients, so two genuinely concurrent retries
213 * carrying the same confirm_token and idempotency_key could both pass every
214 * check and both run the mutation. For a repeat delete that is harmless; for
215 * create-form or a notification create it is a duplicate durable record,
216 * which is exactly what idempotency_key promises will not happen.
217 *
218 * Atomicity comes from the UNIQUE index on wp_options.option_name: INSERT
219 * IGNORE inserts for exactly one caller and reports zero affected rows for
220 * every other. Deliberately NOT get_transient/set_transient (same
221 * read-then-write race we are closing) and not wp_cache_add (a no-op across
222 * requests without a persistent object cache).
223 *
224 * The row stores its own expiry so a request that dies mid-write cannot
225 * deadlock the key: a claim older than CLAIM_TTL is reclaimed.
226 *
227 * @return bool True when this caller may proceed.
228 */
229 public static function claim($tool, $entityKey)
230 {
231 global $wpdb;
232
233 if (!isset($wpdb) || !is_object($wpdb)) {
234 return 'no-db'; // Nothing to serialize on; behave as before.
235 }
236
237 $key = self::claimKey($tool, $entityKey);
238
239 // The receipt identifies THIS claimant. Without it, reclaim and release
240 // can only address the key, and two holders become indistinguishable.
241 $receipt = self::receipt();
242
243 if ($wpdb->query($wpdb->prepare(
244 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
245 $key,
246 $receipt
247 ))) {
248 return $receipt;
249 }
250
251 // Held. Take it over only if the holder's lease has expired, and only
252 // by compare-and-swapping against the exact value we just observed: one
253 // UPDATE, so of two reclaimers seeing the same expired value only the
254 // first matches the predicate and the second affects zero rows.
255 //
256 // The previous delete-then-insert was NOT this: each reclaimer deleted
257 // the row the other had just inserted, and both reported success.
258 $observed = $wpdb->get_var($wpdb->prepare(
259 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
260 $key
261 ));
262
263 if (null !== $observed && self::expiryOf($observed) <= time()) {
264 $won = $wpdb->query($wpdb->prepare(
265 "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
266 $receipt,
267 $key,
268 $observed
269 ));
270
271 if ($won) {
272 // We now own the contested key, so sweeping other stranded rows
273 // cannot interfere with it. Done here as well as on the lost
274 // path so cleanup does not wait for genuine contention, while
275 // staying off the uncontended fast path above.
276 self::sweepExpiredClaims($key);
277
278 return $receipt;
279 }
280 }
281
282 // The row may have been released or swept between our two statements,
283 // in which case the CAS matched nothing but nobody actually holds it.
284 // One more insert distinguishes that from a genuine loss.
285 if ($wpdb->query($wpdb->prepare(
286 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
287 $key,
288 $receipt
289 ))) {
290 return $receipt;
291 }
292
293 // Genuinely lost. This is the rare path, so it is the cheap place to
294 // hang the sweep — and it runs only after our own claim is resolved, so
295 // it can never interfere with the key being contested.
296 self::sweepExpiredClaims($key);
297
298 return false;
299 }
300
301 /**
302 * Release a claim, but only if we still own it.
303 *
304 * Conditioned on the receipt, not just the key: a holder whose lease expired
305 * mid-write and whose key was legitimately taken over would otherwise delete
306 * the REPLACEMENT owner's live claim on its way out, dropping a second
307 * request straight into the critical section.
308 *
309 * @return bool False when we no longer owned the claim — meaning our lease
310 * expired mid-write and another request may have run too.
311 */
312 public static function release($tool, $entityKey, $receipt)
313 {
314 global $wpdb;
315
316 // No DB means claim() never serialized anything, so there is no lease to
317 // have lost — report success rather than a spurious lease_expired audit.
318 if (!isset($wpdb) || !is_object($wpdb)) {
319 return true;
320 }
321
322 if (empty($receipt)) {
323 return false;
324 }
325
326 return (bool) $wpdb->query($wpdb->prepare(
327 "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
328 self::claimKey($tool, $entityKey),
329 $receipt
330 ));
331 }
332
333 /** A value unique to one claimant: random token plus this lease's expiry. */
334 private static function receipt()
335 {
336 return wp_generate_uuid4() . '|' . (time() + self::CLAIM_TTL);
337 }
338
339 private static function expiryOf($receipt)
340 {
341 $pos = strrpos((string) $receipt, '|');
342
343 return false === $pos ? 0 : (int) substr((string) $receipt, $pos + 1);
344 }
345
346 /**
347 * Drop claim rows stranded by a fatal mid-write, never touching the key the
348 * caller is contesting. Bounded per call, and non-autoloaded rows so it
349 * never costs a page load.
350 */
351 private static function sweepExpiredClaims($exceptKey)
352 {
353 global $wpdb;
354
355 // The LIKE pattern is an argument, not inlined: a literal % inside a
356 // prepare() query is a placeholder to WordPress and must be written %%
357 // or passed in. esc_like escapes the underscores in the prefix too.
358 $wpdb->query($wpdb->prepare(
359 "DELETE FROM {$wpdb->options}
360 WHERE option_name LIKE %s
361 AND option_name != %s
362 AND CAST(SUBSTRING_INDEX(option_value, '|', -1) AS UNSIGNED) < %d
363 LIMIT 50",
364 $wpdb->esc_like(self::CLAIM_PREFIX) . '%',
365 $exceptKey,
366 time()
367 ));
368 }
369
370 private static function claimKey($tool, $entityKey)
371 {
372 // Not user-scoped: two sessions of the same operator, or two workers
373 // handling one agent's retry, must contend for the same claim.
374 return self::CLAIM_PREFIX . md5($tool . '|' . $entityKey);
375 }
376
377 private static function confirmKey($tool, $entityKey)
378 {
379 return 'ff_mcp_confirm_' . get_current_user_id() . '_' . md5($tool . '|' . $entityKey);
380 }
381
382 private static function idemKey($tool, $entityKey, $key)
383 {
384 return 'ff_mcp_idem_' . get_current_user_id() . '_' . md5($tool . '|' . $entityKey . '|' . $key);
385 }
386 }
387