This commit is contained in:
Javier Casares 2026-06-06 05:22:18 +00:00
commit 74f1f42deb
10 changed files with 125 additions and 43 deletions

View file

@ -1,5 +1,31 @@
== Changelog ==
= 1.5.1 =
_Release date: 2026-06-06_
**Security**
* CSV exports use RFC 4180 encoding — fields with commas, double-quotes, or line breaks are correctly quoted. Replaces `addslashes()`.
* GeoIP database path validated with `is_file()` and `!is_link()` at read time to block symlink traversal.
* `robotstxt_2fa_app_password_verification_window` filter return validated as a positive integer; falls back to 900 for invalid values.
**Fixed**
* Export CSV button now only rendered to users with `manage_options` capability.
**Compatibility**
* WordPress: 6.4 7.1
* PHP: 8.0 8.5
**Tests**
* PHP Coding Standards: PHP_CodeSniffer 3.13.5 / WPCS 3.3.0
* PHPStan: level 9 — 0 errors
* PHPCompatibility: 8.08.5 — 0 issues
* PHPUnit: 9.6.34 — 42 tests, 109 assertions
= 1.5.0 =
_Release date: 2026-06-05_

View file

@ -46,11 +46,13 @@ class Dashboard_Page {
private User_Settings_Repository $repository;
/**
* Users list table.
* Users list table instantiated lazily inside render_page() after admin
* includes are loaded. WP_List_Table::__construct() calls convert_to_screen()
* which is unavailable at plugins_loaded time.
*
* @var Users_List_Table
* @var Users_List_Table|null
*/
private Users_List_Table $users_table;
private ?Users_List_Table $users_table = null;
/**
* Constructor.
@ -63,7 +65,19 @@ class Dashboard_Page {
$this->attempts_log = $attempts_log;
$this->config = $config;
$this->repository = $repository;
$this->users_table = new Users_List_Table( $config, $repository );
}
/**
* Return (and lazily create) the users list table instance.
*
* @return Users_List_Table
*/
private function get_users_table(): Users_List_Table {
if ( null === $this->users_table ) {
$this->users_table = new Users_List_Table( $this->config, $this->repository );
}
return $this->users_table;
}
/**
@ -170,16 +184,19 @@ class Dashboard_Page {
<form method="get">
<input type="hidden" name="page" value="<?php echo esc_attr( self::PAGE_SLUG ); ?>" />
<?php
$this->users_table->prepare_items();
$this->users_table->display();
$this->get_users_table()->prepare_items();
$this->get_users_table()->display();
?>
</form>
<?php // @phpstan-ignore-next-line -- redundant but kept for defence in depth; render_page() already gates on manage_options.
if ( current_user_can( 'manage_options' ) ) : ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="margin-top:1em;">
<input type="hidden" name="action" value="robotstxt_2fa_export_users" />
<?php wp_nonce_field( 'robotstxt_2fa_export_users' ); ?>
<?php submit_button( __( 'Export CSV', 'robotstxt-2fa' ), 'secondary', 'submit', false ); ?>
</form>
<?php endif; ?>
<h2><?php esc_html_e( 'Recent failed attempts', 'robotstxt-2fa' ); ?></h2>
@ -300,15 +317,35 @@ class Dashboard_Page {
header( 'Content-Disposition: attachment; filename="2fa-users-' . gmdate( 'Y-m-d' ) . '.csv"' );
$headers = array( 'ID', 'Login', 'Email', 'Roles', 'Status', 'Methods', 'Last Verified', 'Unused Codes' );
echo implode( ',', array_map( 'addslashes', $headers ) ) . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV output to a file download.
echo implode( ',', array_map( array( $this, 'csv_field' ), $headers ) ) . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV file download, RFC 4180 escaped.
foreach ( $rows as $row ) {
echo implode( ',', array_map( 'addslashes', array_values( $row ) ) ) . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV output to a file download.
echo implode( ',', array_map( array( $this, 'csv_field' ), array_values( $row ) ) ) . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV file download, RFC 4180 escaped.
}
exit;
}
/**
* Encode a single value as an RFC 4180-compliant CSV field.
*
* Fields containing commas, double-quotes, or line breaks are wrapped in
* double-quotes; any embedded double-quotes are doubled.
*
* @since 1.5.1
*
* @param string $value Raw field value.
*
* @return string
*/
private function csv_field( string $value ): string {
if ( str_contains( $value, ',' ) || str_contains( $value, '"' ) || str_contains( $value, "\n" ) || str_contains( $value, "\r" ) ) {
return '"' . str_replace( '"', '""', $value ) . '"';
}
return $value;
}
/**
* Add a 2FA status column to the users list table.
*

View file

@ -668,7 +668,13 @@ class Settings_Page {
}
if ( isset( $raw_settings['geoip_database_path'] ) && is_string( $raw_settings['geoip_database_path'] ) ) {
$sanitized['geoip_database_path'] = sanitize_text_field( $raw_settings['geoip_database_path'] );
$geoip_path = sanitize_text_field( $raw_settings['geoip_database_path'] );
// Allow saving an empty path (disables GeoIP). For non-empty paths, accept
// the value as-is so admins can save a path even when the file does not
// yet exist on this server (e.g. when editing settings before uploading the DB).
// The actual existence + symlink check is performed at read time in Geo_Restrictions.
$sanitized['geoip_database_path'] = $geoip_path;
}
if ( isset( $raw_settings['geoip_country_allow'] ) && is_string( $raw_settings['geoip_country_allow'] ) ) {

View file

@ -160,7 +160,7 @@ class Geo_Restrictions {
private function get_country_for_ip(): string {
$db_path = $this->config->get_geoip_database_path();
if ( '' === $db_path || ! file_exists( $db_path ) ) {
if ( '' === $db_path || ! file_exists( $db_path ) || ! is_file( $db_path ) || is_link( $db_path ) ) {
return '';
}

View file

@ -239,6 +239,9 @@ class Plugin {
* @param int $window Seconds since last 2FA verification. Default 900 (15 minutes).
*/
$window = (int) apply_filters( 'robotstxt_2fa_app_password_verification_window', 900 );
if ( $window <= 0 ) {
$window = 900;
}
if ( $verified_at > 0 && ( time() - $verified_at ) <= $window ) {
return $result;

View file

@ -300,9 +300,9 @@ class Cli_Command extends WP_CLI_Command {
if ( 'csv' === $format ) {
$headers = array( 'ID', 'Login', 'Email', '2FA', 'Frequency' );
\WP_CLI::line( implode( ',', array_map( 'addslashes', $headers ) ) );
\WP_CLI::line( implode( ',', array_map( array( $this, 'csv_field' ), $headers ) ) );
foreach ( $rows as $row ) {
\WP_CLI::line( implode( ',', array_map( 'addslashes', array_values( $row ) ) ) );
\WP_CLI::line( implode( ',', array_map( array( $this, 'csv_field' ), array_values( $row ) ) ) );
}
return;
}
@ -451,6 +451,23 @@ class Cli_Command extends WP_CLI_Command {
\WP_CLI::warning( 'Remember to allow the bypass to expire or revoke it once access is restored.' );
}
/**
* Encode a single value as an RFC 4180-compliant CSV field.
*
* @since 1.5.1
*
* @param string $value Raw field value.
*
* @return string
*/
private function csv_field( string $value ): string {
if ( str_contains( $value, ',' ) || str_contains( $value, '"' ) || str_contains( $value, "\n" ) || str_contains( $value, "\r" ) ) {
return '"' . str_replace( '"', '""', $value ) . '"';
}
return $value;
}
/**
* Resolve a user ID or login string to a WP_User object.
*

View file

@ -4,7 +4,7 @@ Tags: security, two-factor authentication, login, otp
Requires at least: 6.4
Tested up to: 7.0
Requires PHP: 8.0
Stable tag: 1.5.0
Stable tag: 1.5.1
License: GPLv3 or later
License URI: https://www.gnu.org/licenses/gpl-3.0.html
@ -59,6 +59,20 @@ Yes. Activate the plugin at the network level. Network administrators can set an
== Changelog ==
= 1.5.1 =
_Release date: 2026-06-06_
**Security**
* CSV exports (dashboard and WP-CLI) now use RFC 4180 encoding instead of `addslashes()` — fields with commas, double-quotes, or newlines are correctly quoted.
* GeoIP database path validated at read time with `is_file()` and `! is_link()` to prevent symlink traversal.
* `robotstxt_2fa_app_password_verification_window` filter return value validated as a positive integer before use.
**Fixed**
* Export CSV button is now only rendered to users with `manage_options` capability.
= 1.5.0 =
_Release date: 2026-06-05_
@ -88,27 +102,6 @@ _Release date: 2026-06-05_
* IP deny list — IPs and CIDR ranges that are blocked from logging in altogether.
* All IP matching supports both IPv4 and IPv6 CIDR notation.
= 1.3.0 =
_Release date: 2026-06-05_
**Security**
* TOTP codes are now protected against replay attacks within the same 30-second time window. An accepted counter step is recorded in a transient for 90 seconds; a second submission of the same code is rejected.
* The login username is no longer exposed in the `robotstxt-2fa-login` URL query parameter during the verification stage. It is replaced by an opaque 32-character token that resolves to the user server-side. All redirect URLs, hidden form fields, and method-switch links use this token.
**Added**
* WP-CLI command family registered as `wp 2fa`:
* `wp 2fa status <user_id>` — show 2FA configuration for a user.
* `wp 2fa enable <user_id> [--method=<method>]` — enable 2FA, optionally specifying the active method.
* `wp 2fa disable <user_id>` — disable 2FA while preserving stored secrets and codes.
* `wp 2fa reset-recovery <user_id>` — regenerate recovery codes and display them once.
* `wp 2fa list [--role=<role>] [--without-2fa] [--format=<format>]` — list users and 2FA status.
* `wp 2fa force-setup [<user_id>] [--role=<role>] [--method=<method>]` — enforce 2FA for a user or role.
* `wp 2fa bypass <user_id> [--days=<n>]` — grant a temporary bypass for a locked-out user.
* WP-CLI commands are only loaded when the `WP_CLI` constant is defined and truthy.
= Previous versions =
For the full changelog see the [changelog.txt](https://git.robotstxt.es/ROBOTSTXT/robotstxt-2fa/raw/branch/main/changelog.txt) file.

View file

@ -3,7 +3,7 @@
* Plugin Name: 2FA (by ROBOTSTXT)
* Plugin URI: https://www.robotstxt.es/plugins/robotstxt-2fa/
* Description: Adds two-factor authentication to the WordPress login flow.
* Version: 1.5.0
* Version: 1.5.1
* Author: ROBOTSTXT
* Author URI: https://www.robotstxt.es/
* Text Domain: robotstxt-2fa
@ -23,7 +23,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
if ( ! defined( 'ROBOTSTXT_2FA_VERSION' ) ) {
define( 'ROBOTSTXT_2FA_VERSION', '1.5.0' );
define( 'ROBOTSTXT_2FA_VERSION', '1.5.1' );
}
if ( ! defined( 'ROBOTSTXT_2FA_FILE' ) ) {

View file

@ -1,20 +1,20 @@
{
"name": "2FA (by ROBOTSTXT)",
"slug": "robotstxt-2fa",
"version": "1.5.0",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-2fa/releases/download/1.5.0/robotstxt-2fa-1.5.0.zip",
"version": "1.5.1",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-2fa/releases/download/1.5.1/robotstxt-2fa-1.5.1.zip",
"requires": "6.4",
"requires_php": "8.0",
"tested": "7.1",
"last_updated": "2026-06-05",
"last_updated": "2026-06-06",
"author": "ROBOTSTXT",
"author_profile": "https://www.robotstxt.es/",
"homepage": "https://www.robotstxt.es/plugins/robotstxt-2fa/",
"description": "Adds per-role two-factor authentication to the WordPress login flow. Supports email codes, authenticator apps (TOTP), and recovery codes.",
"changelog": "<h3>1.5.0 - 2026-06-05</h3><ul><li><strong>Added:</strong> Full audit dashboard with WP_List_Table (sortable, filterable by role/status, CSV export)</li><li><strong>Added:</strong> GeoIP country allow/deny/always-challenge rules via MaxMind GeoLite2 (optional)</li><li><strong>Added:</strong> Require recent 2FA verification before creating Application Passwords</li><li><strong>Added:</strong> WP-CLI export subcommand</li><li><strong>Fixed:</strong> Email digest cron now correctly reschedules when frequency setting changes</li></ul>",
"changelog": "<h3>1.5.1 - 2026-06-06</h3><ul><li><strong>Security:</strong> CSV exports now use RFC 4180 encoding instead of addslashes()</li><li><strong>Security:</strong> GeoIP path validated with is_file() and !is_link() to block symlinks</li><li><strong>Security:</strong> App Password window filter validated as positive integer</li><li><strong>Fixed:</strong> Export CSV button only rendered for manage_options users</li></ul>",
"sections": {
"description": "Adds per-role two-factor authentication to the WordPress login flow. Supports email codes, authenticator apps (TOTP), and recovery codes.",
"changelog": "<h3>1.5.0 - 2026-06-05</h3><ul><li><strong>Added:</strong> Full audit dashboard with WP_List_Table (sortable, filterable by role/status, CSV export)</li><li><strong>Added:</strong> GeoIP country allow/deny/always-challenge rules via MaxMind GeoLite2 (optional)</li><li><strong>Added:</strong> Require recent 2FA verification before creating Application Passwords</li><li><strong>Added:</strong> WP-CLI export subcommand</li><li><strong>Fixed:</strong> Email digest cron now correctly reschedules when frequency setting changes</li></ul>"
"changelog": "<h3>1.5.1 - 2026-06-06</h3><ul><li><strong>Security:</strong> CSV exports now use RFC 4180 encoding instead of addslashes()</li><li><strong>Security:</strong> GeoIP path validated with is_file() and !is_link() to block symlinks</li><li><strong>Security:</strong> App Password window filter validated as positive integer</li><li><strong>Fixed:</strong> Export CSV button only rendered for manage_options users</li></ul>"
},
"banners": { "low": "", "high": "" },
"icons": { "1x": "", "2x": "" }

View file

@ -3,7 +3,7 @@
'name' => 'robotstxt/robotstxt-2fa',
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => '98e1f4e1f1384a32d312d01dee6bb1f2a6f85671',
'reference' => '61d65485a74e176946a79a5f6879fb8cd1f006a6',
'type' => 'wordpress-plugin',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@ -40,7 +40,7 @@
'robotstxt/robotstxt-2fa' => array(
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => '98e1f4e1f1384a32d312d01dee6bb1f2a6f85671',
'reference' => '61d65485a74e176946a79a5f6879fb8cd1f006a6',
'type' => 'wordpress-plugin',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),