PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
sureforms / inc / client-logger.php

client-logger.php in SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz 2.12.8, at inc/client-logger.php

915 lines 32.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Client error logging.
4 *
5 * Owns the debug log file that the "Enable Logs" setting writes to: where it
6 * lives, what may be written to it, and how large it is allowed to get. Nothing
7 * else in the plugin should open that file.
8 *
9 * The log records form-submission failures reported by the visitor's browser —
10 * the HTTP status and duration of the submit request, and the error behind a
11 * failure — so a site owner can reproduce a bug and hand the file to support
12 * instead of being talked through DevTools.
13 *
14 * @package SureForms
15 * @since 2.12.6
16 */
17
18 namespace SRFM\Inc;
19
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit;
22 }
23
24 /**
25 * Client_Logger
26 *
27 * Design notes that are load-bearing:
28 *
29 * - Every method fails silently. The uploads directory is not writable on
30 * hardened or read-only-deploy hosts, and a logger that warns or fatals on a
31 * visitor-facing request is worse than one that records nothing.
32 * - The file name is unguessable and never disclosed. `.htaccess` does nothing
33 * on nginx, so the name is the real protection; the download handler derives
34 * the path itself and takes no filename parameter.
35 * - Over the size cap the log stops accepting writes rather than trimming or
36 * rotating. Someone reproducing a bug must not have the tail of their repro
37 * evicted by newer noise from an unrelated visitor.
38 *
39 * @since 2.12.6
40 */
41 class Client_Logger {
42 /**
43 * Option holding the random component of the log file name.
44 *
45 * @since 2.12.6
46 */
47 public const FILENAME_OPTION = 'srfm_client_log_file';
48
49 /**
50 * Consecutive faults before the site owner is told something is wrong.
51 *
52 * One. A fault is already filtered down to what a visitor cannot fix by trying
53 * again -- the request never reached PHP, the response was not JSON, the server
54 * errored, an email could not be sent -- so waiting for a run of them means
55 * staying quiet through the first several lost submissions.
56 *
57 * @since 2.12.6
58 */
59 public const FAULT_THRESHOLD = 1;
60
61 /**
62 * Maximum size of the log file in bytes.
63 *
64 * @since 2.12.6
65 */
66 public const MAX_FILE_SIZE = 1048576;
67
68 /**
69 * Longest free-text value stored on a single entry, in characters.
70 *
71 * @since 2.12.6
72 */
73 public const MAX_TEXT_LENGTH = 500;
74
75 /**
76 * Longest single field key stored on an entry, in characters.
77 *
78 * @since 2.12.6
79 */
80 public const MAX_KEY_LENGTH = 100;
81
82 /**
83 * Option holding per-category failure state, keyed by category.
84 *
85 * Shape: [ category => [ 'count' => int, 'form_id' => int, 'form_title' => string,
86 * 'at' => int, 'acked' => int, 'acked_at' => int ] ].
87 *
88 * Kept per category because the three read completely differently to a site
89 * owner: submissions failing means visitors cannot reach you, a notification
90 * failing means you are not hearing about entries that did save, and an
91 * integration failing means a third party is not receiving them. Collapsing
92 * them into one warning would describe none of those accurately.
93 *
94 * @since 2.12.6
95 */
96 public const FAILURES_OPTION = 'srfm_client_log_failures';
97
98 /**
99 * Categories a failure can belong to.
100 *
101 * @since 2.12.6
102 */
103 public const CATEGORIES = [ 'submission', 'notification', 'integration' ];
104
105 /**
106 * Memoised get_tail() results for this request, keyed by character budget.
107 *
108 * A class property rather than a static inside the method so append() and
109 * clear() can invalidate it: a request that writes to the log and then reads a
110 * tail must not be handed the tail from before the write.
111 *
112 * @var array<string,array{text:string,shown:int,total:int}>
113 * @since 2.12.7
114 */
115 private static $tail_memo = [];
116
117 /**
118 * Whether client error logging is currently switched on.
119 *
120 * On by default, including on installs whose stored settings predate the
121 * option. The point of the log is that the evidence already exists when a
122 * support ticket arrives -- a default of off would mean asking the reporter to
123 * enable it and reproduce, which is the round trip this feature removes.
124 *
125 * Costs nothing on a healthy site: only failures are ever written, so a site
126 * whose forms work never creates the file at all.
127 *
128 * This is the one authority. The frontend also carries a flag, but that flag is
129 * baked into cached HTML and can be a full cache TTL out of date, so every
130 * write path re-checks here.
131 *
132 * @since 2.12.6
133 * @since 2.12.8 Filterable through `srfm_enable_logs`.
134 * @return bool
135 */
136 public static function is_enabled() {
137 $general = get_option( 'srfm_general_settings_options', [] );
138 $enabled = ! is_array( $general ) || ! isset( $general['srfm_enable_logs'] ) || (bool) $general['srfm_enable_logs'];
139
140 /**
141 * Filter whether client logging is on, without changing the stored
142 * setting. SureForms Pro's Distraction Free mode turns it off here.
143 *
144 * @since 2.12.8
145 *
146 * @param bool $enabled The stored setting (true when never saved).
147 */
148 return (bool) apply_filters( 'srfm_enable_logs', $enabled );
149 }
150
151 /**
152 * Whether an entry means the site is broken, rather than the visitor.
153 *
154 * This distinction is the whole basis of the failure notice. Most of what the
155 * log records is routine: a mistyped email, an expired captcha, a declined
156 * card, a submission the server rejected by naming the field to fix. Those are
157 * the form working correctly, and counting them would tell healthy sites to
158 * contact support -- on every install, because logging is on by default.
159 *
160 * A fault is what a visitor cannot resolve by trying again correctly: the
161 * request never reached PHP, the response was not JSON, the server returned an
162 * error status, or a notification email could not be sent.
163 *
164 * @param array<string,mixed> $entry Entry as returned by sanitize_entry().
165 * @since 2.12.6
166 * @return bool
167 */
168 public static function is_fault( array $entry ) {
169 $type = $entry['type'] ?? '';
170
171 // Allowlist, so an unrecognised or new category is not a fault by default.
172 // A visitor-correctable stop never reaches here: sanitize_entry() drops the
173 // browser's 'blocked' type before anything is written.
174 if ( in_array( $type, [ 'error', 'response', 'message' ], true ) ) {
175 return true;
176 }
177
178 // 'after_submission' runs after the entry is already saved, so it is only a
179 // fault when the server said so. A browser-side failure there -- an aborted
180 // fetch as the page unloads, which is what a redirect confirmation does and
181 // what `keepalive` exists to survive -- tells us nothing about whether the
182 // work ran: the endpoint is guarded by is_after_submission_process_triggered
183 // and usually has. Safari spells that abort "TypeError: Load failed", and
184 // alarming on it reported healthy sites as broken.
185 if ( ! in_array( $type, [ 'network', 'after_submission' ], true ) ) {
186 return false;
187 }
188
189 $status = isset( $entry['status'] ) ? Helper::get_integer_value( $entry['status'] ) : 0;
190
191 // 403 is the submit token being refused, which on a cached site means the
192 // page is serving a token the server will not accept.
193 return $status >= 500 || 403 === $status;
194 }
195
196 /**
197 * Record a failure against a category and the form it happened on.
198 *
199 * The form is carried because "a form is failing" is not actionable on a site
200 * with twenty of them -- the first thing anyone asks is which one.
201 *
202 * @param string $category One of self::CATEGORIES.
203 * @param int $form_id Form the failure happened on.
204 * @param string $form_title Form title, resolved by the caller.
205 * @since 2.12.6
206 * @return void
207 */
208 public static function record_failure( $category, $form_id = 0, $form_title = '' ) {
209 // Logging off means nothing is recorded, not merely nothing displayed.
210 //
211 // The display side was already gated -- get_action_items() skips the
212 // first-party items and has_action_item_warnings() returns false -- but the
213 // counter kept being written, from the two call sites in form-submit.php
214 // that sit beside an append() the enabled check does stop. So a site with
215 // logging switched off still accumulated failure state, and switching
216 // logging on surfaced every fault recorded while it was off, behind a View
217 // details report whose debug log is empty because nothing was written.
218 //
219 // Gated here rather than at the call sites, for the reason append() states:
220 // the guard belongs on the function that writes, not on today's callers.
221 // clear_category() and the acknowledge helpers are deliberately left
222 // ungated -- they only remove state, and must keep working so nothing is
223 // stranded by the toggle.
224 if ( ! self::is_enabled() ) {
225 return;
226 }
227
228 if ( ! in_array( $category, self::CATEGORIES, true ) ) {
229 return;
230 }
231
232 $failures = self::get_failures();
233 $existing = $failures[ $category ] ?? [];
234
235 $failures[ $category ] = [
236 'count' => Helper::get_integer_value( $existing['count'] ?? 0 ) + 1,
237 'form_id' => absint( $form_id ),
238 'form_title' => mb_substr( sanitize_text_field( $form_title ), 0, 100 ),
239 'at' => time(),
240 // Preserved: a report already made still stands until this new count
241 // overtakes it, which is what get_open_failures() compares.
242 'acked' => Helper::get_integer_value( $existing['acked'] ?? 0 ),
243 // Carried forward too. This array is rebuilt from a fixed set of keys, so
244 // anything not named here is dropped -- and "when did I last report
245 // this" quietly disappearing on the next failure is exactly the kind of
246 // loss nobody notices until support asks.
247 'acked_at' => Helper::get_integer_value( $existing['acked_at'] ?? 0 ),
248 ];
249
250 update_option( self::FAILURES_OPTION, $failures, false );
251 }
252
253 /**
254 * All recorded failure state.
255 *
256 * @since 2.12.6
257 * @return array<string,array<string,mixed>>
258 */
259 public static function get_failures() {
260 return Helper::get_array_value( get_option( self::FAILURES_OPTION, [] ) );
261 }
262
263 /**
264 * Categories with failures the site owner has not already reported.
265 *
266 * @since 2.12.6
267 * @return array<string,array<string,mixed>>
268 */
269 public static function get_open_failures() {
270 $open = [];
271
272 foreach ( self::get_failures() as $category => $failure ) {
273 if ( ! in_array( $category, self::CATEGORIES, true ) ) {
274 continue;
275 }
276
277 $count = Helper::get_integer_value( $failure['count'] ?? 0 );
278
279 // Compared on the count, not the clock: both are written to the second,
280 // so a failure landing in the same second as the report would look
281 // not-newer and be hidden.
282 if ( $count > 0 && $count > Helper::get_integer_value( $failure['acked'] ?? 0 ) ) {
283 $open[ $category ] = $failure;
284 }
285 }
286
287 return $open;
288 }
289
290 /**
291 * Mark one category as reported.
292 *
293 * @param string $category One of self::CATEGORIES.
294 * @since 2.12.6
295 * @return void
296 */
297 public static function acknowledge_category( $category ) {
298 $failures = self::get_failures();
299
300 if ( ! isset( $failures[ $category ] ) ) {
301 return;
302 }
303
304 $failures[ $category ]['acked'] = Helper::get_integer_value( $failures[ $category ]['count'] ?? 0 );
305
306 // Recorded for the report -- "you told us at 14:12" is worth having when
307 // support reads the ticket -- but deliberately not what decides whether the
308 // notice comes back. The count does that.
309 //
310 // A timestamp cannot: it is written to the second, so a failure recorded in
311 // the same second as the acknowledgement compares equal and gets swallowed.
312 // That is the one moment it matters most, because a fault arriving as
313 // someone reports the last one is a fault still happening.
314 $failures[ $category ]['acked_at'] = time();
315
316 update_option( self::FAILURES_OPTION, $failures, false );
317 }
318
319 /**
320 * Forget one category's failures entirely.
321 *
322 * @param string $category One of self::CATEGORIES.
323 * @since 2.12.6
324 * @return void
325 */
326 public static function clear_category( $category ) {
327 $failures = self::get_failures();
328
329 if ( ! isset( $failures[ $category ] ) ) {
330 return;
331 }
332
333 unset( $failures[ $category ] );
334
335 update_option( self::FAILURES_OPTION, $failures, false );
336 }
337
338 /**
339 * How many submission faults have been recorded.
340 *
341 * @since 2.12.6
342 * @return int
343 */
344 public static function get_fault_streak() {
345 $failures = self::get_failures();
346
347 return Helper::get_integer_value( $failures['submission']['count'] ?? 0 );
348 }
349
350 /**
351 * Whether submissions are failing and it has not already been reported.
352 *
353 * @since 2.12.6
354 * @return bool
355 */
356 public static function has_persistent_failures() {
357 return self::get_fault_streak() >= self::FAULT_THRESHOLD
358 && isset( self::get_open_failures()['submission'] );
359 }
360
361 /**
362 * The recorded acknowledgement for submission failures, if there is one.
363 *
364 * @since 2.12.6
365 * @return array<string,mixed> Empty when nothing has been acknowledged.
366 */
367 public static function get_acknowledgement() {
368 $failures = self::get_failures();
369 $acked = Helper::get_integer_value( $failures['submission']['acked'] ?? 0 );
370
371 if ( $acked < 1 ) {
372 return [];
373 }
374
375 return [
376 // The last fault time, not the acknowledgement time. Kept under this
377 // key because callers already read it as "when the thing happened".
378 'at' => Helper::get_integer_value( $failures['submission']['at'] ?? 0 ),
379 // When the owner reported it. Separate, because the two answer
380 // different questions and are usually seconds apart on a fresh fault
381 // and days apart on an old one.
382 'acked_at' => Helper::get_integer_value( $failures['submission']['acked_at'] ?? 0 ),
383 'streak' => $acked,
384 ];
385 }
386
387 /**
388 * When the most recent submission fault happened.
389 *
390 * @since 2.12.6
391 * @return int Unix timestamp, or 0 when nothing has failed.
392 */
393 public static function get_last_fault_time() {
394 $failures = self::get_failures();
395
396 return Helper::get_integer_value( $failures['submission']['at'] ?? 0 );
397 }
398
399 /**
400 * Record that the site owner has reported the current submission failures.
401 *
402 * @since 2.12.6
403 * @return void
404 */
405 public static function acknowledge_failures() {
406 self::acknowledge_category( 'submission' );
407 }
408
409 /**
410 * Forget the submission failures after one gets through.
411 *
412 * Hooked - srfm_form_submit, which fires only on the success path.
413 *
414 * Only the submission category is cleared. A submission getting through says
415 * nothing about whether its notification email sent or its integrations ran.
416 * Notification clears on its own path, in Form_Submit::send_email(), once every
417 * recipient for a submission has sent. Integration has no success signal to
418 * clear on yet -- the failures are recorded by pro through
419 * Form_Submit::log_integration_failure() and there is no matching
420 * "it worked" call -- so that category still clears only when the owner
421 * reports it.
422 *
423 * @since 2.12.6
424 * @return void
425 */
426 public static function reset_fault_streak() {
427 self::clear_category( 'submission' );
428 }
429
430 /**
431 * Absolute path to the log file, creating its directory if needed.
432 *
433 * @param bool $create Whether to create the directory when it is absent.
434 * @since 2.12.6
435 * @return string Absolute path, or '' when the location is unusable.
436 */
437 public static function get_log_path( $create = true ) {
438 $uploads = wp_upload_dir();
439
440 if ( ! empty( $uploads['error'] ) || empty( $uploads['basedir'] ) ) {
441 return '';
442 }
443
444 $dir = trailingslashit( $uploads['basedir'] ) . 'sureforms/logs/';
445
446 if ( ! is_dir( $dir ) ) {
447 if ( ! $create || ! wp_mkdir_p( $dir ) ) {
448 return '';
449 }
450
451 self::protect_directory( $dir );
452 }
453
454 return $dir . 'srfm-debug-' . self::get_filename_hash() . '.log';
455 }
456
457 /**
458 * Append one validated entry to the log.
459 *
460 * @param array<string,mixed> $entry Entry as returned by sanitize_entry().
461 * @since 2.12.6
462 * @return bool True when the line was written.
463 */
464 public static function append( array $entry ) {
465 // Any write invalidates a memoised tail, whether or not this one lands.
466 self::$tail_memo = [];
467
468 // Checked here as well as at the route, so the guard sits on the function
469 // that writes rather than only on today's single caller. Without it any
470 // future caller writes to disk on a site that never switched logging on.
471 if ( ! self::is_enabled() ) {
472 return false;
473 }
474
475 if ( empty( $entry ) ) {
476 return false;
477 }
478
479 // Counted before the file is touched. A full log or an unwritable uploads
480 // directory must not stop the site owner being told the form is failing --
481 // on a badly broken site those are exactly the conditions that occur.
482 // A notification or integration failure records its own category at the call
483 // site; everything else reaching here is the submission itself.
484 $type = Helper::get_string_value( $entry['type'] ?? '' );
485
486 if ( self::is_fault( $entry ) && 'message' !== $type ) {
487 // The after-submission step runs on an entry that is already saved and
488 // fires srfm_after_submission_process, which is where integrations and
489 // webhooks hook in. Calling that a submission failure told the site owner
490 // "their entries were not saved" about entries that were -- the wrong
491 // message on the one notice that cannot be dismissed. The category is
492 // derived here rather than taken from the entry: the client names what
493 // happened, the server decides what it means.
494 self::record_failure(
495 'after_submission' === $type ? 'integration' : 'submission',
496 Helper::get_integer_value( $entry['form_id'] ?? 0 ),
497 Helper::get_string_value( $entry['form_title'] ?? '' )
498 );
499 }
500
501 $path = self::get_log_path();
502
503 if ( '' === $path ) {
504 return false;
505 }
506
507 // Cap and stop. Deliberately not a trim or a rotate: the person who
508 // reproduced the bug is the one whose lines would be discarded.
509 if ( self::is_full() ) {
510 return false;
511 }
512
513 $entry['time'] = gmdate( 'Y-m-d H:i:s' );
514
515 $line = wp_json_encode( $entry );
516
517 if ( ! is_string( $line ) ) {
518 return false;
519 }
520
521 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents -- WP_Filesystem can prompt for credentials and is not initialised on a public REST request; this mirrors the raw-handle pattern already used in inc/entries.php. LOCK_EX is insurance for NFS/Windows -- appends of this size are already atomic on POSIX.
522 return false !== file_put_contents( $path, $line . "\n", FILE_APPEND | LOCK_EX );
523 }
524
525 /**
526 * The most recent whole log lines, up to a character budget.
527 *
528 * An excerpt for a support email. The budget is the caller's, and it is a
529 * ceiling: the excerpt goes into a mailto: URL, which a mail client drops
530 * whole when it runs past its length limit. Newest entries are the ones that
531 * describe the failure being reported, so the tail is the useful end and the
532 * oldest are what a smaller budget drops.
533 *
534 * Whole lines only -- half a JSON object helps nobody -- except when the
535 * newest line alone is over the budget. That one is cut and marked, because
536 * an empty excerpt helps nobody either.
537 *
538 * @param int $max_chars Character budget for the returned text.
539 * @since 2.12.6
540 * @return array{text:string,shown:int,total:int}
541 */
542 public static function get_tail( $max_chars = 1200 ) {
543 // Memoised per request and per budget. get_action_items() asks once per
544 // open failure category and runs twice per admin request -- building the
545 // localisation payload and again in the classic renderer -- so a site with
546 // three open failures was reading a file capped at 1 MB six times to render
547 // one page.
548 $max_chars = (int) $max_chars;
549
550 // Keyed by blog as well as budget: get_log_path() hashes the blog id into
551 // the filename, so after a switch_to_blog() the same budget is a different
552 // file. Unreachable today; nothing switches blogs on this path.
553 //
554 // Its own variable, not $max_chars reused -- that key is a string, and the
555 // byte-budget comparison below coerces "1:1200" to 1, which silently
556 // reduces every excerpt to a single line.
557 $memo_key = get_current_blog_id() . ':' . $max_chars;
558
559 if ( isset( self::$tail_memo[ $memo_key ] ) ) {
560 return self::$tail_memo[ $memo_key ];
561 }
562
563 $empty = [
564 'text' => '',
565 'shown' => 0,
566 'total' => 0,
567 ];
568
569 $path = self::get_log_path( false );
570
571 if ( '' === $path || ! file_exists( $path ) ) {
572 self::$tail_memo[ $memo_key ] = $empty;
573
574 return $empty;
575 }
576
577 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file, WordPress.WP.AlternativeFunctions.file_system_read_file -- Reading a file this class owns; WP_Filesystem would prompt for credentials and is unavailable here.
578 $lines = file( $path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
579
580 if ( ! is_array( $lines ) || empty( $lines ) ) {
581 self::$tail_memo[ $memo_key ] = $empty;
582
583 return $empty;
584 }
585
586 $total = count( $lines );
587 $kept = [];
588 $used = 0;
589
590 foreach ( array_reverse( $lines ) as $line ) {
591 $length = strlen( $line ) + 1;
592
593 if ( $used + $length > $max_chars && ! empty( $kept ) ) {
594 break;
595 }
596
597 // Always keep one line, since an empty excerpt is worse than a long one --
598 // but never past the budget, which is a ceiling the caller relies on. The
599 // start is kept because it names what failed. Lines are wp_json_encode()d,
600 // so they are ASCII and a byte cut cannot split a character.
601 if ( $length > $max_chars ) {
602 $marker = ' [truncated]';
603 $line = substr( $line, 0, max( 0, $max_chars - strlen( $marker ) ) ) . $marker;
604 }
605
606 array_unshift( $kept, $line );
607 $used += $length;
608 }
609
610 self::$tail_memo[ $memo_key ] = [
611 'text' => implode( "\n", $kept ),
612 'shown' => count( $kept ),
613 'total' => $total,
614 ];
615
616 return self::$tail_memo[ $memo_key ];
617 }
618
619 /**
620 * Whether the log has reached its size cap.
621 *
622 * @since 2.12.6
623 * @return bool
624 */
625 public static function is_full() {
626 return self::get_file_size() >= self::MAX_FILE_SIZE;
627 }
628
629 /**
630 * Current size of the log file in bytes.
631 *
632 * @since 2.12.6
633 * @return int
634 */
635 public static function get_file_size() {
636 $path = self::get_log_path( false );
637
638 if ( '' === $path || ! file_exists( $path ) ) {
639 return 0;
640 }
641
642 $size = filesize( $path );
643
644 return is_int( $size ) ? $size : 0;
645 }
646
647 /**
648 * Delete the log file.
649 *
650 * @since 2.12.6
651 * @return bool
652 */
653 public static function clear() {
654 self::$tail_memo = [];
655
656 $path = self::get_log_path( false );
657
658 if ( '' === $path || ! file_exists( $path ) ) {
659 return true;
660 }
661
662 return wp_delete_file_from_directory( $path, dirname( $path ) );
663 }
664
665 /**
666 * Reduce a caller-supplied payload to the fixed shape the log accepts.
667 *
668 * The endpoint never appends caller text directly. Redaction governs values;
669 * this governs shape. Without it, "we redact the field values" would still
670 * leave an anonymous caller writing arbitrary content into a file an
671 * administrator later opens.
672 *
673 * @param array<string,mixed> $raw Decoded request payload.
674 * @since 2.12.6
675 * @return array<string,mixed> Empty when nothing usable survived.
676 */
677 public static function sanitize_entry( array $raw ) {
678 // 'blocked' is deliberately absent. It is the browser's label for a stop the
679 // visitor can clear themselves -- a required field left empty, an expired
680 // captcha, a declined card, a rejection naming the field to fix -- and those
681 // were the most common lines in a real log. Every one of them was pasted into
682 // support reports about some other failure and consumed budget the log does
683 // not give back, because it stops at its cap rather than rotating. Dropped at
684 // the shape gate so no caller, present or future, can write one.
685 $allowed_types = [ 'network', 'response', 'error', 'message', 'after_submission' ];
686 $type = isset( $raw['type'] ) ? sanitize_key( Helper::get_string_value( $raw['type'] ) ) : '';
687
688 if ( ! in_array( $type, $allowed_types, true ) ) {
689 return [];
690 }
691
692 $entry = [
693 'type' => $type,
694 'form_id' => isset( $raw['form_id'] ) ? absint( Helper::get_integer_value( $raw['form_id'] ) ) : 0,
695 ];
696
697 foreach ( [ 'message', 'source', 'body', 'form_title' ] as $key ) {
698 if ( ! isset( $raw[ $key ] ) ) {
699 continue;
700 }
701
702 $text = self::scrub_text( Helper::get_string_value( $raw[ $key ] ) );
703
704 if ( '' !== $text ) {
705 $entry[ $key ] = $text;
706 }
707 }
708
709 foreach ( [ 'status', 'duration_ms', 'line' ] as $key ) {
710 if ( isset( $raw[ $key ] ) ) {
711 $entry[ $key ] = absint( Helper::get_integer_value( $raw[ $key ] ) );
712 }
713 }
714
715 if ( isset( $raw['field_keys'] ) && is_array( $raw['field_keys'] ) ) {
716 $keys = [];
717
718 // Both the count and each key's length. Capping only the count left one
719 // request able to write ~1MB of field keys and fill the log in a single
720 // call -- and because the log stops rather than evicting, that silently
721 // disabled the feature until an admin cleared it. A real key is
722 // `srfm-input-lbl-<base64>`, far inside this bound.
723 foreach ( array_slice( $raw['field_keys'], 0, 100 ) as $field_key ) {
724 // Through scrub_text() like every other free-text value. A key is
725 // supposed to be `srfm-input-lbl-<base64>`, but the array arrives
726 // from the browser and nothing server-side guarantees that, so a
727 // caller is free to put an address or a token in one.
728 //
729 // wp_check_invalid_utf8() is kept because scrub_text() is not a
730 // drop-in for sanitize_text_field(): invalid UTF-8 reaching
731 // wp_json_encode() in append() makes it return false and drop the
732 // whole line -- after record_failure() has already incremented the
733 // counter, leaving a banner with no log line behind it.
734 $keys[] = mb_substr(
735 self::scrub_text( wp_check_invalid_utf8( Helper::get_string_value( $field_key ) ) ),
736 0,
737 self::MAX_KEY_LENGTH
738 );
739 }
740
741 $entry['field_keys'] = $keys;
742 }
743
744 // A type alone says nothing. Require at least one substantive value.
745 $has_detail = isset( $entry['message'] ) || isset( $entry['body'] ) || isset( $entry['status'] );
746
747 return $has_detail ? $entry : [];
748 }
749
750 /**
751 * Strip identifying detail out of free text and clamp its length.
752 *
753 * Redacting submitted field values is not sufficient on its own: error text
754 * interpolates user input constantly ("Invalid email: someone@example.com"),
755 * and a page URL routinely carries an address or a reset key in its query
756 * string. Whitespace is collapsed as a log-injection guard, matching
757 * inc/ai-form-builder/ai-helper.php.
758 *
759 * Removes, in order: JSON slash-escaping, so the rules below can see URLs at
760 * all; credentials in a URL's userinfo; query strings and fragments; a foreign
761 * URL's path past its first segment, keeping same-origin paths intact because
762 * those are stack frames and the path is the diagnosis; the value following a
763 * name that identifies a credential; email addresses; and long digit runs.
764 *
765 * A single-segment foreign path is truncated whole rather than kept, which is
766 * the safe direction.
767 *
768 * What it cannot remove is a name, a street address or a free-text message
769 * body -- those have no shape to match, so the log excerpt this produces
770 * should still be treated as personal data.
771 *
772 * @param string $text Raw text.
773 * @since 2.12.6
774 * @return string
775 */
776 public static function scrub_text( $text ) {
777 if ( '' === $text ) {
778 return '';
779 }
780
781 // Clamped before the rules run, not after. Without it every pattern below
782 // is applied to whatever the caller sent, however long that is.
783 $text = mb_substr( $text, 0, self::MAX_TEXT_LENGTH * 4 );
784
785 // A WP REST error body arrives slash-escaped -- wp_json_encode() escapes
786 // "/" and WP_REST_Server::serve_request() does not pass
787 // JSON_UNESCAPED_SLASHES -- and two of the four body sinks log the raw
788 // response text rather than the decoded object. Without this every URL
789 // rule below misses every URL in the largest sink, including the webhook
790 // tokens they exist for.
791 $text = str_replace( '\\/', '/', $text );
792
793 // Credentials in the userinfo position, before the host rules see them.
794 $text = (string) preg_replace( '#(https?://)[^\s/@]+@#i', '$1[credentials]@', $text );
795
796 // Drop query strings and fragments wholesale rather than allowlisting
797 // parameters. A token after # is just as sensitive as one after ?.
798 $text = (string) preg_replace( '#(https?://[^\s?\#]+)[?\#][^\s"\'<>,;)\]}]*#i', '$1', $text );
799
800 $site_host = Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) );
801
802 // Keep the origin and the first path segment of a foreign URL, drop the
803 // rest: a webhook credential sits in the path as often as in the query,
804 // and Slack and Discord both put theirs there.
805 //
806 // Same-origin URLs are exempt. `source` is a stack frame, not a page
807 // address -- assets/js/unminified/form-submit.js takes
808 // error.stack.split( "\n" )[1] -- so truncating our own paths deletes the
809 // filename, the line and column, and which plugin threw, which is the
810 // whole diagnosis. A third-party credential is never same-origin.
811 //
812 // The port is stripped before comparing. The pattern captures the whole
813 // authority, so a site served on a non-default port produced frames reading
814 // `example.test:8443`, while $site_host is PHP_URL_HOST and never carries a
815 // port -- every own frame failed the check and was truncated to /[path],
816 // which is the case this exemption exists for. Same host, different port is
817 // treated as ours: on a WordPress install that is the same site behind a dev
818 // server or a proxy, and the alternative is deleting the diagnosis.
819 $text = (string) preg_replace_callback(
820 '#(https?://)([^\s/]+)((?:/[^\s/]*)?)/[^\s"\'<>,;)\]}]+#i',
821 static function ( $matches ) use ( $site_host ) {
822 // Trailing :digits only, so an IPv6 literal keeps its brackets and
823 // its own colons -- [::1]:8080 becomes [::1], which is the form
824 // wp_parse_url() returns for one.
825 $host = (string) preg_replace( '/:\d+$/', '', $matches[2] );
826
827 if ( '' !== $site_host && 0 === strcasecmp( $host, $site_host ) ) {
828 return $matches[0];
829 }
830
831 return $matches[1] . $matches[2] . $matches[3] . '/[path]';
832 },
833 $text
834 );
835
836 // Credentials named in the text itself. The name is matched as a whole
837 // identifier, so a keyword with a prefix or suffix is still caught --
838 // AWS_SECRET_ACCESS_KEY, stripe_secret_key, X-Hub-Signature. And a
839 // separator is required, so ordinary prose survives: "Invalid token
840 // provided" and "password protected" are the most common things support
841 // reads out of this log, and an earlier version redacted both. `bearer`
842 // and `basic` are the exception, because those carry the value after a
843 // space with no separator at all.
844 $text = (string) preg_replace(
845 '/(\b[\w.-]*(?:api[_-]?key|key|secret|token|password|passwd|pwd|auth|credential|signature)[\w.-]*["\']?\s*[:=]\s*["\']?|\b(?:bearer|basic)\s+)[^\s"\',;&]{8,}/i',
846 '$1[redacted]',
847 $text
848 );
849
850 // Email addresses.
851 $text = (string) preg_replace( '/[\w.+-]+@[\w-]+\.[\w.-]+/', '[email]', $text );
852
853 // Long digit runs: card numbers, phone numbers, ids. Separators are matched
854 // too, because a real phone number is written 555-123-4567 or (555) 123-4567
855 // and a contiguous-digits rule never sees it.
856 $text = (string) preg_replace( '/\+?\d[\d\s().-]{5,}\d/', '[number]', $text );
857
858 $text = (string) preg_replace( '/\s+/', ' ', $text );
859
860 return mb_substr( trim( wp_strip_all_tags( $text ) ), 0, self::MAX_TEXT_LENGTH );
861 }
862
863 /**
864 * Random component of the log file name, generated once and reused.
865 *
866 * The blog id is part of the input because wp_salt() is network-wide: a
867 * salt-only hash would be identical on every site of a multisite network, and
868 * older subdirectory installs can share one uploads directory.
869 *
870 * @since 2.12.6
871 * @return string
872 */
873 private static function get_filename_hash() {
874 $hash = get_option( self::FILENAME_OPTION, '' );
875
876 if ( is_string( $hash ) && 32 === strlen( $hash ) && ctype_xdigit( $hash ) ) {
877 return $hash;
878 }
879
880 $hash = hash_hmac( 'md5', 'srfm-client-log|' . get_current_blog_id(), wp_salt( 'auth' ) );
881
882 update_option( self::FILENAME_OPTION, $hash, false );
883
884 return $hash;
885 }
886
887 /**
888 * Write the directory guards, best effort.
889 *
890 * An index.html rather than index.php: the nginx failure mode is `autoindex on`
891 * producing a listing, and an index.html suppresses that. .htaccess covers
892 * Apache and is inert on nginx, which is why the unguessable file name — not
893 * these files — is what actually protects the log.
894 *
895 * @param string $dir Directory to guard.
896 * @since 2.12.6
897 * @return void
898 */
899 private static function protect_directory( $dir ) {
900 $guards = [
901 '.htaccess' => "# Apache 2.4\n<IfModule mod_authz_core.c>\nRequire all denied\n</IfModule>\n# Apache 2.2\n<IfModule !mod_authz_core.c>\nOrder deny,allow\nDeny from all\n</IfModule>\n",
902 'index.html' => '',
903 ];
904
905 foreach ( $guards as $name => $contents ) {
906 if ( file_exists( $dir . $name ) ) {
907 continue;
908 }
909
910 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents -- Best-effort directory guard written at creation time; WP_Filesystem may prompt for credentials and is unavailable on the public request that first creates this directory.
911 file_put_contents( $dir . $name, $contents );
912 }
913 }
914 }
915