` only — not enough to * keep a single entry on a single physical line. A newline or * carriage return in `$w3tc_message` would close the current entry * and let the remainder of the value appear as a fabricated * additional line (different timestamp, different "user"), * which would confuse incident response. * * Sanitization rules: * * Replace CR / LF with a space so a single entry stays a * single physical line. * * Replace TAB with a space so column-aligned readers don't * get tricked. * * Drop NUL bytes (some terminal pagers truncate on NUL). * * Keep the existing `<>` → `.` swap so logs are HTML-safe if * rendered in a browser-based viewer. * * @since 2.10.0 * * @param unknown $w3tc_module Module. * @param string $w3tc_message Message. * * @return int|false Bytes written, or false on failure. */ public static function log( $w3tc_module, $w3tc_message ) { // Redact while structure (JSON, print_r, newlines) is intact, // then flatten control characters for a single physical line. $w3tc_message = self::sanitize_log_message( self::redact( (string) $w3tc_message ) ); $filename = self::log_filename( $w3tc_module ); return @file_put_contents( $filename, '[' . gmdate( 'r' ) . '] ' . $w3tc_message . "\n", FILE_APPEND ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents } /** * Sanitize a log-line message for safe append to a debug log file. * * Exposed as a separate static helper so the unit test suite can * exercise the exact production sanitiser without writing to disk * — calling `Util_Debug::log()` directly inside a test would * require figuring out the per-module log file path and asserting * against its trailing line. Tests now invoke this helper * directly so a regression that re-introduces CR/LF/TAB/NUL into * the sanitiser is caught by the existing assertions. * * The strip set: * - `<` / `>` → `.` so logs are HTML-safe when rendered in a * browser-based viewer (some operator dashboards * display debug-log lines verbatim). * - `\r` / `\n` → space so a single submission stays a single * physical line (closes log forging — a * newline in a user-supplied value would * otherwise close the current entry and let * the remainder appear as a fabricated * additional line). * - `\t` → space so column-aligned readers can't be tricked. * - `\0` → '' since some terminal pagers truncate on NUL. * * @since 2.10.0 * * @param mixed $w3tc_message Anything stringifiable. * * @return string Sanitised single-line message. */ public static function sanitize_log_message( $w3tc_message ) { return strtr( (string) $w3tc_message, array( '<' => '.', '>' => '.', "\r" => ' ', "\n" => ' ', "\t" => ' ', "\0" => '', ) ); } /** * Log cache purge event * * @param unknown $w3tc_module Module. * @param string $w3tc_message Message. * @param array $parameters Parameters. * @param array $explicit_postfix Explicit postfix. * * @return bool */ public static function log_purge( $w3tc_module, $w3tc_message, $parameters = null, $explicit_postfix = null ) { $backtrace = debug_backtrace( 0 ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace $backtrace_count = count( $backtrace ); $backtrace_lines = array(); $pos = 0; for ( $n = 2; $n < $backtrace_count; $n++ ) { if ( ! self::log_purge_should_print_item( $backtrace, $n ) ) { continue; } $w3tc_i = $backtrace[ $n ]; $filename = isset( $w3tc_i['file'] ) ? $w3tc_i['file'] : ''; $filename = str_replace( ABSPATH, '', $filename ); $w3tc_line = isset( $w3tc_i['line'] ) ? $w3tc_i['line'] : ''; $method = ( ! empty( $w3tc_i['class'] ) ? $w3tc_i['class'] . '--' : '' ) . $w3tc_i['function']; $args = ' ' . self::encode_params( $w3tc_i['args'] ); $backtrace_lines[] = "\t#" . ( $pos ) . ' ' . $filename . '(' . $w3tc_line . '): ' . $method . $args; ++$pos; } $w3tc_message = $w3tc_message; if ( ! is_null( $parameters ) ) { $w3tc_message .= self::encode_params( $parameters ); } $user = function_exists( 'wp_get_current_user' ) ? wp_get_current_user() : null; $username = ( empty( $user ) ? 'anonymous' : $user->user_login ); $w3tc_message .= "\n\tusername:$username"; /** * RT9-56: Enrich the purge audit-log line with the source IP, * user-agent, request method+URI, the user's primary role, and * a per-request correlation ID so coordinated flush attacks or * unauthorised purges can be traced to attacker tooling. * * Every field is one-line-flattened via {@see self::log_purge_field_clean()} * — bytes that would break the single-record format (newlines, * tabs, `<` / `>` for markup-like content) are scrubbed, and * the UA / URI are length-capped so an attacker-controlled * 8KB User-Agent string cannot blow up the log file. * * `purge_role` is the user's *first* role only — that's enough * for forensic triage ("subscriber initiated a flush, escalate") * without leaking the full role array. Anonymous purges record * the literal `anonymous`. * * `purge_id` is a 16-hex correlation ID, derived from * `wp_generate_password()` with `false` for the special-char * argument so the value is alphanum-only and safe to grep for. * When `wp_generate_password()` isn't available (early purge * before WordPress fully loads) we fall back to `uniqid()`, * also alphanum. */ $ip = Util_Environment::get_client_ip(); $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Debug log context only. $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Debug log context only. $method = isset( $_SERVER['REQUEST_METHOD'] ) ? (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Debug log context only. $role = ( ! empty( $user ) && ! empty( $user->roles ) && \is_array( $user->roles ) ) ? (string) $user->roles[0] : 'anonymous'; $corr_id = ''; if ( \function_exists( 'wp_generate_password' ) ) { $corr_id = wp_generate_password( 16, false, false ); } else { $corr_id = \uniqid( '', true ); $corr_id = \preg_replace( '/[^A-Za-z0-9]/', '', $corr_id ); } $w3tc_message .= "\n\tip:" . self::log_purge_field_clean( $ip, 45 ); $w3tc_message .= "\n\tua:" . self::log_purge_field_clean( $ua, 256 ); $w3tc_message .= "\n\tmethod:" . self::log_purge_field_clean( $method, 16 ); $w3tc_message .= "\n\turi:" . self::log_purge_field_clean( $uri, 256 ); $w3tc_message .= "\n\trole:" . self::log_purge_field_clean( $role, 32 ); $w3tc_message .= "\n\tpurge_id:" . self::log_purge_field_clean( $corr_id, 32 ); if ( is_array( $explicit_postfix ) ) { $w3tc_message .= "\n\t" . implode( "\n\t", $explicit_postfix ); } $w3tc_message .= "\n" . implode( "\n", $backtrace_lines ); return self::log( $w3tc_module . '-purge', $w3tc_message ); } /** * Single-line-flatten + length-cap a value before it lands in the * purge audit log. Strips bytes that would break the per-record * format (CR / LF / tab / null) and replaces markup-significant * `<` / `>` with `.` so an attacker-controlled User-Agent or URI * containing `