prepare(); if ( '' === $path ) { return false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- WP_Filesystem cannot append, and a log is nothing but appends. return false !== file_put_contents( $path, $line . "\n", FILE_APPEND | LOCK_EX ); } /** * Returns the stored log files, newest first. * * @return array The files. */ public function files(): array { $directory = self::directory(); if ( '' === $directory || ! is_dir( $directory ) ) { return array(); } /* * Read with `scandir()` and filtered in PHP, rather than handed to * `glob()`. * * The directory path is interpolated into the pattern, and `glob()` reads * `[`, `]`, `?` and `*` in it as syntax — so a site installed under a path * containing any of them matched nothing, and `glob()` also returns false * outright on hosts where it is disabled. Either way `files()` answered * with an empty array, and because it is the single source for `path()`, * `tail()`, `delete()`, `clear()`, `purge()` and `size()`, the whole log UI * reported "nothing has been logged yet", no file could be downloaded, and * the retention sweep silently stopped running while writes carried on. */ $entries = scandir( $directory ); if ( false === $entries ) { return array(); } $files = array(); foreach ( $entries as $entry ) { /* * Every name this store writes, whatever its token - not only the * current one. The token comes from the site's salts, so rotating them * (the first thing to do after a leak) renamed every future file and * left the older ones invisible here: never purged by retention, never * removed by an uninstall told to delete the plugin's data. */ if ( 1 !== preg_match( '/^formpilot-\d{4}-\d{2}-\d{2}(?:-\d+)?-[a-f0-9]{20}\.log$/', $entry ) ) { continue; } $path = $directory . $entry; if ( ! is_file( $path ) ) { continue; } $files[] = array( 'name' => $entry, 'path' => $path, 'size' => (int) filesize( $path ), 'modified' => (int) filemtime( $path ), ); } usort( $files, static fn( array $a, array $b ): int => $b['modified'] <=> $a['modified'] ); return $files; } /** * Returns the absolute path of one stored file. * * The name is matched against the files that exist rather than joined onto * the directory, so a crafted name cannot walk out of it however it is * spelled. * * @param string $name File name as listed. * * @return string The path, or an empty string when there is no such file. */ public function path( string $name ): string { foreach ( $this->files() as $file ) { if ( $file['name'] === $name ) { return $file['path']; } } return ''; } /** * Reads the last lines of a file. * * @param string $name File name as listed. * @param int $lines Number of lines to return. * * @return array The lines, oldest first. */ public function tail( string $name, int $lines = 200 ): array { $path = $this->path( $name ); if ( '' === $path ) { return array(); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a file this plugin wrote, inside its own directory. $contents = file_get_contents( $path, false, null, max( 0, filesize( $path ) - 262144 ) ); if ( ! is_string( $contents ) ) { return array(); } $all = array_filter( explode( "\n", trim( $contents ) ) ); return array_slice( $all, -max( 1, $lines ) ); } /** * Deletes one stored file. * * @param string $name File name as listed. * * @return bool True when the file was removed. */ public function delete( string $name ): bool { $path = $this->path( $name ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.WP.AlternativeFunctions.file_system_operations_unlink -- Removing a file this plugin wrote. return '' !== $path && unlink( $path ); } /** * Deletes every stored file. */ public function clear(): int { $removed = 0; foreach ( $this->files() as $file ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.WP.AlternativeFunctions.file_system_operations_unlink -- Removing a file this plugin wrote. if ( unlink( $file['path'] ) ) { ++$removed; } } return $removed; } /** * Deletes files older than the retention window. */ public function purge(): int { $days = Settings::number( 'log_retention' ); if ( $days <= 0 ) { return 0; } $before = time() - ( $days * DAY_IN_SECONDS ); $removed = 0; foreach ( $this->files() as $file ) { if ( $file['modified'] >= $before ) { continue; } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.WP.AlternativeFunctions.file_system_operations_unlink -- Removing a file this plugin wrote. if ( unlink( $file['path'] ) ) { ++$removed; } } return $removed; } /** * Returns the total size of the stored files. */ public function size(): int { return (int) array_sum( array_column( $this->files(), 'size' ) ); } /** * Returns the file to append to, making the directory and rotating first. * * @return string The absolute path, or an empty string when it cannot be written. */ private function prepare(): string { $directory = self::directory(); if ( ! is_dir( $directory ) && ! wp_mkdir_p( $directory ) ) { return ''; } $this->protect( $directory ); $path = $directory . sprintf( 'formpilot-%1$s-%2$s.log', gmdate( 'Y-m-d' ), self::token() ); /* * A single day can fill a log on its own when something is failing in a * loop, so size rotates as well as the date. The rotated file keeps the * naming pattern, which is what makes it show up in the list and be * removed by the retention sweep like any other. */ if ( is_file( $path ) && filesize( $path ) >= self::MAX_BYTES ) { $rotated = $directory . sprintf( 'formpilot-%1$s-%2$s-%3$s.log', gmdate( 'Y-m-d' ), (string) time(), self::token() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.WP.AlternativeFunctions.file_system_operations_rename -- Rotating a file this plugin wrote. rename( $path, $rotated ); } return $path; } /** * Writes the guards that keep the directory from being served. * * @param string $directory Absolute path with a trailing slash. */ private function protect( string $directory ): void { /* * This directory sits inside the uploads tree, which is inside the webroot, * and these files are the only thing standing between a log and an * unauthenticated HTTP request for it. * * `.htaccess` covers Apache and nothing else — nginx ignores it entirely, * and no `web.config` was written, so on those servers the salt-derived * token in the filename was the whole defence. That token is not secret * enough to be one: the Tools screen used to print the full filename on * the page, so a screenshot, a cached copy or an admin-side XSS handed over * a permanent download URL for every log file the site has. * * nginx cannot be configured from here at all, so the remaining measures * are the ones that do not depend on the server: the token stays, the * filename is no longer shown (see `templates/admin/tools/general.php`), * and the values worth stealing are no longer written in the first place * (see `LogSubscriber`). A site on nginx that wants a hard guarantee needs * a `location` rule, which the documentation now gives. */ $guards = array( '.htaccess' => "Require all denied\n\nDeny from all\n\n", 'index.php' => " "\n\n\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\n", ); foreach ( $guards as $name => $contents ) { if ( is_file( $directory . $name ) ) { continue; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Writing the guard files as they are created. file_put_contents( $directory . $name, $contents, LOCK_EX ); } } }