v1.5.1
This commit is contained in:
parent
054060374e
commit
74f1f42deb
10 changed files with 125 additions and 43 deletions
|
|
@ -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.0–8.5 — 0 issues
|
||||
* PHPUnit: 9.6.34 — 42 tests, 109 assertions
|
||||
|
||||
= 1.5.0 =
|
||||
|
||||
_Release date: 2026-06-05_
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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'] ) ) {
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
37
readme.txt
37
readme.txt
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' ) ) {
|
||||
|
|
|
|||
10
update.json
10
update.json
|
|
@ -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": "" }
|
||||
|
|
|
|||
4
vendor/composer/installed.php
vendored
4
vendor/composer/installed.php
vendored
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue