| @@ -82,9 +82,9 @@ | ||
| 82 | 82 | /** |
| 83 | 83 | * Option holding per-category failure state, keyed by category. |
| 84 | 84 | * |
| 85 | 85 | * Shape: [ category => [ 'count' => int, 'form_id' => int, 'form_title' => string, |
| 86 | - * 'at' => int, 'acked' => int ] ]. | |
| 86 | + * 'at' => int, 'acked' => int, 'acked_at' => int ] ]. | |
| 87 | 87 | * |
| 88 | 88 | * Kept per category because the three read completely differently to a site |
| 89 | 89 | * owner: submissions failing means visitors cannot reach you, a notification |
| 90 | 90 | * failing means you are not hearing about entries that did save, and an |
| @@ -102,8 +102,20 @@ | ||
| 102 | 102 | */ |
| 103 | 103 | public const CATEGORIES = [ 'submission', 'notification', 'integration' ]; |
| 104 | 104 | |
| 105 | 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 | + /** | |
| 106 | 118 | * Whether client error logging is currently switched on. |
| 107 | 119 | * |
| 108 | 120 | * On by default, including on installs whose stored settings predate the |
| 109 | 121 | * option. The point of the log is that the evidence already exists when a |
| @@ -117,18 +129,24 @@ | ||
| 117 | 129 | * baked into cached HTML and can be a full cache TTL out of date, so every |
| 118 | 130 | * write path re-checks here. |
| 119 | 131 | * |
| 120 | 132 | * @since 2.12.6 |
| 133 | + * @since 2.12.8 Filterable through `srfm_enable_logs`. | |
| 121 | 134 | * @return bool |
| 122 | 135 | */ |
| 123 | 136 | public static function is_enabled() { |
| 124 | 137 | $general = get_option( 'srfm_general_settings_options', [] ); |
| 138 | + $enabled = ! is_array( $general ) || ! isset( $general['srfm_enable_logs'] ) || (bool) $general['srfm_enable_logs']; | |
| 125 | 139 | |
| 126 | - if ( ! is_array( $general ) || ! isset( $general['srfm_enable_logs'] ) ) { | |
| 127 | - return true; | |
| 128 | - } | |
| 129 | - | |
| 130 | - return (bool) $general['srfm_enable_logs']; | |
| 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 ); | |
| 131 | 149 | } |
| 132 | 150 | |
| 133 | 151 | /** |
| 134 | 152 | * Whether an entry means the site is broken, rather than the visitor. |
| @@ -150,15 +168,22 @@ | ||
| 150 | 168 | public static function is_fault( array $entry ) { |
| 151 | 169 | $type = $entry['type'] ?? ''; |
| 152 | 170 | |
| 153 | 171 | // Allowlist, so an unrecognised or new category is not a fault by default. |
| 154 | - // 'blocked' is deliberately absent: it is the label the browser puts on a | |
| 155 | - // stop the visitor can clear themselves. | |
| 172 | + // A visitor-correctable stop never reaches here: sanitize_entry() drops the | |
| 173 | + // browser's 'blocked' type before anything is written. | |
| 156 | 174 | if ( in_array( $type, [ 'error', 'response', 'message' ], true ) ) { |
| 157 | 175 | return true; |
| 158 | 176 | } |
| 159 | 177 | |
| 160 | - if ( 'network' !== $type ) { | |
| 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 ) ) { | |
| 161 | 186 | return false; |
| 162 | 187 | } |
| 163 | 188 | |
| 164 | 189 | $status = isset( $entry['status'] ) ? Helper::get_integer_value( $entry['status'] ) : 0; |
| @@ -180,8 +205,27 @@ | ||
| 180 | 205 | * @since 2.12.6 |
| 181 | 206 | * @return void |
| 182 | 207 | */ |
| 183 | 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 | + | |
| 184 | 228 | if ( ! in_array( $category, self::CATEGORIES, true ) ) { |
| 185 | 229 | return; |
| 186 | 230 | } |
| 187 | 231 | |
| @@ -195,8 +239,13 @@ | ||
| 195 | 239 | 'at' => time(), |
| 196 | 240 | // Preserved: a report already made still stands until this new count |
| 197 | 241 | // overtakes it, which is what get_open_failures() compares. |
| 198 | 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 ), | |
| 199 | 248 | ]; |
| 200 | 249 | |
| 201 | 250 | update_option( self::FAILURES_OPTION, $failures, false ); |
| 202 | 251 | } |
| @@ -253,8 +302,18 @@ | ||
| 253 | 302 | } |
| 254 | 303 | |
| 255 | 304 | $failures[ $category ]['acked'] = Helper::get_integer_value( $failures[ $category ]['count'] ?? 0 ); |
| 256 | 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 | + | |
| 257 | 316 | update_option( self::FAILURES_OPTION, $failures, false ); |
| 258 | 317 | } |
| 259 | 318 | |
| 260 | 319 | /** |
| @@ -313,10 +372,16 @@ | ||
| 313 | 372 | return []; |
| 314 | 373 | } |
| 315 | 374 | |
| 316 | 375 | return [ |
| 317 | - 'at' => Helper::get_integer_value( $failures['submission']['at'] ?? 0 ), | |
| 318 | - 'streak' => $acked, | |
| 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, | |
| 319 | 384 | ]; |
| 320 | 385 | } |
| 321 | 386 | |
| 322 | 387 | /** |
| @@ -346,10 +411,15 @@ | ||
| 346 | 411 | * |
| 347 | 412 | * Hooked - srfm_form_submit, which fires only on the success path. |
| 348 | 413 | * |
| 349 | 414 | * Only the submission category is cleared. A submission getting through says |
| 350 | - * nothing about whether its notification email sent or its integrations ran, | |
| 351 | - * so those clear when they next succeed or when the owner reports them. | |
| 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. | |
| 352 | 422 | * |
| 353 | 423 | * @since 2.12.6 |
| 354 | 424 | * @return void |
| 355 | 425 | */ |
| @@ -391,8 +461,11 @@ | ||
| 391 | 461 | * @since 2.12.6 |
| 392 | 462 | * @return bool True when the line was written. |
| 393 | 463 | */ |
| 394 | 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 | + | |
| 395 | 468 | // Checked here as well as at the route, so the guard sits on the function |
| 396 | 469 | // that writes rather than only on today's single caller. Without it any |
| 397 | 470 | // future caller writes to disk on a site that never switched logging on. |
| 398 | 471 | if ( ! self::is_enabled() ) { |
| @@ -407,11 +480,20 @@ | ||
| 407 | 480 | // directory must not stop the site owner being told the form is failing -- |
| 408 | 481 | // on a badly broken site those are exactly the conditions that occur. |
| 409 | 482 | // A notification or integration failure records its own category at the call |
| 410 | 483 | // site; everything else reaching here is the submission itself. |
| 411 | - if ( self::is_fault( $entry ) && 'message' !== ( $entry['type'] ?? '' ) ) { | |
| 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. | |
| 412 | 494 | self::record_failure( |
| 413 | - 'submission', | |
| 495 | + 'after_submission' === $type ? 'integration' : 'submission', | |
| 414 | 496 | Helper::get_integer_value( $entry['form_id'] ?? 0 ), |
| 415 | 497 | Helper::get_string_value( $entry['form_title'] ?? '' ) |
| 416 | 498 | ); |
| 417 | 499 | } |
| @@ -442,14 +524,17 @@ | ||
| 442 | 524 | |
| 443 | 525 | /** |
| 444 | 526 | * The most recent whole log lines, up to a character budget. |
| 445 | 527 | * |
| 446 | - * For pasting into a support email, where the transport imposes the limit: a | |
| 447 | - * mailto URL has to survive percent-encoding and every mail client's own | |
| 448 | - * length cap, so only a tail fits. Newest entries are the ones that describe | |
| 449 | - * the failure being reported, so the tail is the useful end. | |
| 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. | |
| 450 | 533 | * |
| 451 | - * Whole lines only -- half a JSON object helps nobody. | |
| 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. | |
| 452 | 537 | * |
| 453 | 538 | * @param int $max_chars Character budget for the returned text. |
| 454 | 539 | * @since 2.12.6 |
| 455 | 540 | * @return array{text:string,shown:int,total:int} |
| @@ -454,8 +539,28 @@ | ||
| 454 | 539 | * @since 2.12.6 |
| 455 | 540 | * @return array{text:string,shown:int,total:int} |
| 456 | 541 | */ |
| 457 | 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 | + | |
| 458 | 563 | $empty = [ |
| 459 | 564 | 'text' => '', |
| 460 | 565 | 'shown' => 0, |
| 461 | 566 | 'total' => 0, |
| @@ -463,8 +568,10 @@ | ||
| 463 | 568 | |
| 464 | 569 | $path = self::get_log_path( false ); |
| 465 | 570 | |
| 466 | 571 | if ( '' === $path || ! file_exists( $path ) ) { |
| 572 | + self::$tail_memo[ $memo_key ] = $empty; | |
| 573 | + | |
| 467 | 574 | return $empty; |
| 468 | 575 | } |
| 469 | 576 | |
| 470 | 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. |
| @@ -470,8 +577,10 @@ | ||
| 470 | 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. |
| 471 | 578 | $lines = file( $path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ); |
| 472 | 579 | |
| 473 | 580 | if ( ! is_array( $lines ) || empty( $lines ) ) { |
| 581 | + self::$tail_memo[ $memo_key ] = $empty; | |
| 582 | + | |
| 474 | 583 | return $empty; |
| 475 | 584 | } |
| 476 | 585 | |
| 477 | 586 | $total = count( $lines ); |
| @@ -480,23 +589,32 @@ | ||
| 480 | 589 | |
| 481 | 590 | foreach ( array_reverse( $lines ) as $line ) { |
| 482 | 591 | $length = strlen( $line ) + 1; |
| 483 | 592 | |
| 484 | - // Always keep one line, even if it alone exceeds the budget: an empty | |
| 485 | - // excerpt is worse than a long one. | |
| 486 | 593 | if ( $used + $length > $max_chars && ! empty( $kept ) ) { |
| 487 | 594 | break; |
| 488 | 595 | } |
| 489 | 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 | + | |
| 490 | 606 | array_unshift( $kept, $line ); |
| 491 | 607 | $used += $length; |
| 492 | 608 | } |
| 493 | 609 | |
| 494 | - return [ | |
| 610 | + self::$tail_memo[ $memo_key ] = [ | |
| 495 | 611 | 'text' => implode( "\n", $kept ), |
| 496 | 612 | 'shown' => count( $kept ), |
| 497 | 613 | 'total' => $total, |
| 498 | 614 | ]; |
| 615 | + | |
| 616 | + return self::$tail_memo[ $memo_key ]; | |
| 499 | 617 | } |
| 500 | 618 | |
| 501 | 619 | /** |
| 502 | 620 | * Whether the log has reached its size cap. |
| @@ -532,8 +650,10 @@ | ||
| 532 | 650 | * @since 2.12.6 |
| 533 | 651 | * @return bool |
| 534 | 652 | */ |
| 535 | 653 | public static function clear() { |
| 654 | + self::$tail_memo = []; | |
| 655 | + | |
| 536 | 656 | $path = self::get_log_path( false ); |
| 537 | 657 | |
| 538 | 658 | if ( '' === $path || ! file_exists( $path ) ) { |
| 539 | 659 | return true; |
| @@ -554,9 +674,16 @@ | ||
| 554 | 674 | * @since 2.12.6 |
| 555 | 675 | * @return array<string,mixed> Empty when nothing usable survived. |
| 556 | 676 | */ |
| 557 | 677 | public static function sanitize_entry( array $raw ) { |
| 558 | - $allowed_types = [ 'network', 'response', 'error', 'message', 'blocked' ]; | |
| 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' ]; | |
| 559 | 686 | $type = isset( $raw['type'] ) ? sanitize_key( Helper::get_string_value( $raw['type'] ) ) : ''; |
| 560 | 687 | |
| 561 | 688 | if ( ! in_array( $type, $allowed_types, true ) ) { |
| 562 | 689 | return []; |
| @@ -593,9 +720,23 @@ | ||
| 593 | 720 | // call -- and because the log stops rather than evicting, that silently |
| 594 | 721 | // disabled the feature until an admin cleared it. A real key is |
| 595 | 722 | // `srfm-input-lbl-<base64>`, far inside this bound. |
| 596 | 723 | foreach ( array_slice( $raw['field_keys'], 0, 100 ) as $field_key ) { |
| 597 | - $keys[] = mb_substr( sanitize_text_field( Helper::get_string_value( $field_key ) ), 0, self::MAX_KEY_LENGTH ); | |
| 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 | + ); | |
| 598 | 739 | } |
| 599 | 740 | |
| 600 | 741 | $entry['field_keys'] = $keys; |
| 601 | 742 | } |
| @@ -614,8 +755,21 @@ | ||
| 614 | 755 | * and a page URL routinely carries an address or a reset key in its query |
| 615 | 756 | * string. Whitespace is collapsed as a log-injection guard, matching |
| 616 | 757 | * inc/ai-form-builder/ai-helper.php. |
| 617 | 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 | + * | |
| 618 | 772 | * @param string $text Raw text. |
| 619 | 773 | * @since 2.12.6 |
| 620 | 774 | * @return string |
| 621 | 775 | */ |
| @@ -623,11 +777,76 @@ | ||
| 623 | 777 | if ( '' === $text ) { |
| 624 | 778 | return ''; |
| 625 | 779 | } |
| 626 | 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 | + | |
| 627 | 796 | // Drop query strings and fragments wholesale rather than allowlisting |
| 628 | 797 | // parameters. A token after # is just as sensitive as one after ?. |
| 629 | - $text = (string) preg_replace( '#(https?://[^\s?\#]+)[?\#]\S*#i', '$1', $text ); | |
| 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 | + ); | |
| 630 | 849 | |
| 631 | 850 | // Email addresses. |
| 632 | 851 | $text = (string) preg_replace( '/[\w.+-]+@[\w-]+\.[\w.-]+/', '[email]', $text ); |
| 633 | 852 | |