Skip to content

Commit 28fe133

Browse files
authored
Merge branch 'WordPress:master' into 437-php-wpcs-fixes
2 parents 703538e + c515462 commit 28fe133

14 files changed

Lines changed: 582 additions & 210 deletions

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,37 @@
33
## What?
44
<!-- In a few words, what is the PR actually doing? -->
55

6+
<!-- Please reference the issue this PR fixes -->
7+
Fixes #
8+
69
## Why?
710
<!-- Why is this PR necessary? What problem is it solving? Reference any existing previous issue(s) or PR(s), but please add a short summary here, too. -->
811

912
## How?
1013
<!-- How is your PR addressing the issue at hand? What are the implementation details? -->
1114

15+
## Use of AI Tools
16+
<!--
17+
You are free to use artificial intelligence (AI) tooling to contribute, but you must disclose what tooling you are using and to what extent a pull request has been authored by AI. It is your responsibility to review and take responsibility for what AI generates. See the WordPress AI Guidelines: <https://make.wordpress.org/ai/handbook/ai-guidelines/>.
18+
19+
Example disclosure:
20+
21+
AI assistance: Yes
22+
Tool(s): GitHub Copilot, ChatGPT
23+
Model(s): GPT-5.1
24+
Used for: Initial code skeleton and test suggestions; final implementation and tests were reviewed and edited by me.
25+
-->
26+
1227
## Testing Instructions
1328
<!-- Please provide steps on how to test or validate that the change in this PR works as described. -->
1429

1530
## Screenshots or screencast
16-
<!-- if applicable -->
31+
<!-- If this PR includes UI changes, please provide screenshots or a screen recording for clarity. -->
32+
<!-- This section can be removed if not applicable. -->
33+
34+
| Before | After |
35+
| ------ | ----- |
36+
| | |
1737

1838
## Changelog Entry
1939
<!--
@@ -24,3 +44,4 @@ Please include a summary for this PR, noting whether this is something being Add
2444
> Removed - Feature.
2545
> Fixed - Bug fix.
2646
> Security - Vulnerability.
47+
> Development Update - Development related updates.

.github/workflows/deploy.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,18 @@ on:
88
# For readme and asset updates.
99
- master
1010

11+
# Disable permissions for all available scopes by default.
12+
# Any needed permissions should be configured at the job level.
13+
permissions: {}
14+
1115
jobs:
1216

1317
deploy:
1418
name: Deploy to WordPress.org
1519
runs-on: ubuntu-24.04
20+
timeout-minutes: 10
21+
permissions:
22+
contents: write # Required to upload assets to the GitHub release.
1623
steps:
1724
- name: Checkout
1825
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: PR Playground Preview
2+
3+
on:
4+
workflow_run:
5+
workflows: ["Test"]
6+
types:
7+
- completed
8+
9+
concurrency:
10+
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
11+
cancel-in-progress: true
12+
13+
permissions: {}
14+
15+
jobs:
16+
playground-preview:
17+
name: Add Playground preview to PR
18+
runs-on: ubuntu-24.04
19+
if: >
20+
github.event.workflow_run.event == 'pull_request' &&
21+
github.event.workflow_run.conclusion == 'success'
22+
permissions:
23+
contents: read
24+
pull-requests: write
25+
26+
steps:
27+
- name: Get PR number
28+
id: pr
29+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
30+
with:
31+
script: |
32+
const prs = await github.rest.pulls.list({
33+
owner: context.repo.owner,
34+
repo: context.repo.repo,
35+
head: `${context.payload.workflow_run.head_repository.owner.login}:${context.payload.workflow_run.head_branch}`,
36+
state: 'open',
37+
});
38+
if (!prs.data.length) throw new Error('No open PR found for this branch');
39+
core.setOutput('number', prs.data[0].number);
40+
41+
- name: Post Playground preview button
42+
uses: WordPress/action-wp-playground-pr-preview@c8607529dac8d2bf9a1e8493865fc97cd1c3c87b # v2
43+
with:
44+
mode: append-to-description
45+
plugin-path: .
46+
pr-number: ${{ steps.pr.outputs.number }}
47+
github-token: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/props-bot.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,8 @@ jobs:
5050
name: Generate a list of props
5151
runs-on: ubuntu-24.04
5252
permissions:
53-
# The action needs permission `write` permission for PRs in order to add a comment.
54-
pull-requests: write
55-
contents: read
53+
pull-requests: write # Required to post the props comment on the pull request.
54+
issues: write # Required to remove the props-bot label.
5655
timeout-minutes: 20
5756
# The job will run when pull requests are open, ready for review and:
5857
#

.github/workflows/test.yml

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@ concurrency:
99
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }}
1010
cancel-in-progress: true
1111

12+
# Disable permissions for all available scopes by default.
13+
# Any needed permissions should be configured at the job level.
14+
permissions: {}
15+
1216
jobs:
1317
lint-js-css:
1418
name: Lint JS & CSS
1519
runs-on: ubuntu-24.04
20+
timeout-minutes: 20
21+
permissions:
22+
contents: read # Required to clone the repo.
1623
steps:
1724
- name: Checkout
1825
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
@@ -35,6 +42,9 @@ jobs:
3542
lint-php-and-compatibility:
3643
name: Lint PHP & PHP Compatibility checks.
3744
runs-on: ubuntu-24.04
45+
timeout-minutes: 20
46+
permissions:
47+
contents: read # Required to clone the repo.
3848
steps:
3949
- name: Checkout
4050
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
@@ -65,6 +75,9 @@ jobs:
6575
test-php:
6676
name: Test PHP ${{ matrix.php }} ${{ matrix.wp != '' && format( ' (WP {0}) ', matrix.wp ) || '' }}
6777
runs-on: ubuntu-24.04
78+
timeout-minutes: 20
79+
permissions:
80+
contents: read # Required to clone the repo.
6881
strategy:
6982
matrix:
7083
php:
@@ -80,14 +93,17 @@ jobs:
8093
wp:
8194
- latest
8295
- trunk
83-
- '6.8'
96+
- '6.9'
8497
exclude:
8598
- php: '7.3'
8699
wp: trunk
87100
- php: '7.2'
88101
wp: trunk
89-
- php: '8.5'
90-
wp: '6.8'
102+
- php: '7.3'
103+
wp: latest
104+
- php: '7.2'
105+
wp: latest
106+
91107
env:
92108
WP_ENV_PHP_VERSION: ${{ matrix.php }}
93109
WP_ENV_CORE: ${{ matrix.wp == 'trunk' && 'WordPress/WordPress' || format( 'https://wordpress.org/wordpress-{0}.zip', matrix.wp ) }}
@@ -141,6 +157,9 @@ jobs:
141157
build:
142158
name: Build
143159
runs-on: ubuntu-24.04
160+
timeout-minutes: 20
161+
permissions:
162+
contents: read # Required to clone the repo.
144163
steps:
145164
- name: Checkout
146165
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0

class-two-factor-core.php

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ class Two_Factor_Core {
2828
*/
2929
const ENABLED_PROVIDERS_USER_META_KEY = '_two_factor_enabled_providers';
3030

31+
/**
32+
* The site-wide enabled providers option key.
33+
*
34+
* @since 0.17.0
35+
*
36+
* @type string
37+
*/
38+
const ENABLED_PROVIDERS_OPTION_KEY = 'two_factor_enabled_providers';
39+
3140
/**
3241
* The user meta nonce key.
3342
*
@@ -189,7 +198,10 @@ public static function uninstall() {
189198
self::USER_PASSWORD_WAS_RESET_KEY,
190199
);
191200

192-
$option_keys = array();
201+
// Keep this updated as plugin-level options are added or removed.
202+
$option_keys = array(
203+
self::ENABLED_PROVIDERS_OPTION_KEY,
204+
);
193205

194206
$providers = self::get_default_providers();
195207

@@ -529,7 +541,12 @@ public static function get_user_two_factor_revalidate_url( $interim = false ) {
529541
* @return boolean
530542
*/
531543
public static function is_valid_user_action( $user_id, $action ) {
532-
$request_nonce = isset( $_REQUEST[ self::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] ) ? wp_unslash( $_REQUEST[ self::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] ) : '';
544+
$request_nonce_raw = isset( $_REQUEST[ self::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] ) ? wp_unslash( $_REQUEST[ self::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Value sanitized and then only passed to wp_verify_nonce().
545+
if ( ! is_scalar( $request_nonce_raw ) ) {
546+
$request_nonce = '';
547+
} else {
548+
$request_nonce = sanitize_text_field( (string) $request_nonce_raw );
549+
}
533550

534551
if ( ! $user_id || ! $action || ! $request_nonce ) {
535552
return false;
@@ -550,8 +567,8 @@ public static function is_valid_user_action( $user_id, $action ) {
550567
*/
551568
public static function current_user_being_edited() {
552569
// Try to resolve the user ID from the request first.
553-
if ( ! empty( $_REQUEST['user_id'] ) ) {
554-
$user_id = intval( $_REQUEST['user_id'] );
570+
if ( ! empty( $_REQUEST['user_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in trigger_user_settings_action() via is_valid_user_action() before any state change.
571+
$user_id = intval( $_REQUEST['user_id'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in trigger_user_settings_action() via is_valid_user_action() before any state change.
555572

556573
if ( current_user_can( 'edit_user', $user_id ) ) {
557574
return $user_id;
@@ -570,8 +587,9 @@ public static function current_user_being_edited() {
570587
* @return void
571588
*/
572589
public static function trigger_user_settings_action() {
573-
$action = isset( $_REQUEST[ self::USER_SETTINGS_ACTION_QUERY_VAR ] ) ? wp_unslash( $_REQUEST[ self::USER_SETTINGS_ACTION_QUERY_VAR ] ) : '';
574-
$user_id = self::current_user_being_edited();
590+
$action_raw = isset( $_REQUEST[ self::USER_SETTINGS_ACTION_QUERY_VAR ] ) ? wp_unslash( $_REQUEST[ self::USER_SETTINGS_ACTION_QUERY_VAR ] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Value sanitized below; nonce verified in is_valid_user_action() before do_action.
591+
$action = ( is_scalar( $action_raw ) && (string) $action_raw !== '' ) ? sanitize_key( (string) $action_raw ) : '';
592+
$user_id = self::current_user_being_edited();
575593

576594
if ( self::is_valid_user_action( $user_id, $action ) ) {
577595
/**
@@ -984,7 +1002,7 @@ public static function show_two_factor_login( $user ) {
9841002
wp_die( esc_html__( 'Failed to create a login nonce.', 'two-factor' ) );
9851003
}
9861004

987-
$redirect_to = isset( $_REQUEST['redirect_to'] ) ? $_REQUEST['redirect_to'] : admin_url();
1005+
$redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Value only used for redirect; auth protected by 2FA login nonce later.
9881006

9891007
self::login_html( $user, $login_nonce['key'], $redirect_to );
9901008
}
@@ -1038,6 +1056,12 @@ public static function maybe_show_reset_password_notice( $errors ) {
10381056
return $errors;
10391057
}
10401058

1059+
// Verify login form nonce when present (e.g. wp-login.php); skip only when nonce is not sent (custom login forms).
1060+
if ( isset( $_POST['_wpnonce'] ) && ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'log-in' ) ) {
1061+
return $errors;
1062+
}
1063+
1064+
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above when _wpnonce present; absent for custom login forms.
10411065
$user_name = sanitize_user( wp_unslash( $_POST['log'] ) );
10421066
$attempted_user = get_user_by( 'login', $user_name );
10431067
if ( ! $attempted_user && str_contains( $user_name, '@' ) ) {
@@ -1304,7 +1328,7 @@ public static function create_login_nonce( $user_id ) {
13041328
try {
13051329
$login_nonce['key'] = bin2hex( random_bytes( 32 ) );
13061330
} catch ( Exception $ex ) {
1307-
$login_nonce['key'] = wp_hash( $user_id . wp_rand() . microtime(), 'nonce' );
1331+
return false;
13081332
}
13091333

13101334
// Store the nonce hashed to avoid leaking it via database access.
@@ -1558,11 +1582,11 @@ public static function rest_api_can_edit_user_and_update_two_factor_options( $us
15581582
* @since 0.2.0
15591583
*/
15601584
public static function login_form_validate_2fa() {
1561-
$wp_auth_id = ! empty( $_REQUEST['wp-auth-id'] ) ? absint( $_REQUEST['wp-auth-id'] ) : 0;
1562-
$nonce = ! empty( $_REQUEST['wp-auth-nonce'] ) ? wp_unslash( $_REQUEST['wp-auth-nonce'] ) : '';
1563-
$provider = ! empty( $_REQUEST['provider'] ) ? wp_unslash( $_REQUEST['provider'] ) : '';
1564-
$redirect_to = ! empty( $_REQUEST['redirect_to'] ) ? wp_unslash( $_REQUEST['redirect_to'] ) : '';
1565-
$is_post_request = ( 'POST' === strtoupper( $_SERVER['REQUEST_METHOD'] ) );
1585+
$wp_auth_id = ! empty( $_REQUEST['wp-auth-id'] ) ? absint( $_REQUEST['wp-auth-id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in _login_form_validate_2fa() via verify_login_nonce() before any use.
1586+
$nonce = ( isset( $_REQUEST['wp-auth-nonce'] ) && is_scalar( $_REQUEST['wp-auth-nonce'] ) ) ? sanitize_text_field( wp_unslash( (string) $_REQUEST['wp-auth-nonce'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in _login_form_validate_2fa() before any use.
1587+
$provider = ! empty( $_REQUEST['provider'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['provider'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in _login_form_validate_2fa() before any use.
1588+
$redirect_to = ! empty( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in _login_form_validate_2fa() before any use.
1589+
$is_post_request = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( $_SERVER['REQUEST_METHOD'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is not user input.
15661590
$user = get_user_by( 'id', $wp_auth_id );
15671591

15681592
if ( ! $wp_auth_id || ! $nonce || ! $user ) {
@@ -1624,7 +1648,7 @@ public static function _login_form_validate_2fa( $user, $nonce = '', $provider =
16241648
delete_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY );
16251649

16261650
$rememberme = false;
1627-
if ( isset( $_REQUEST['rememberme'] ) && $_REQUEST['rememberme'] ) {
1651+
if ( ! empty( $_REQUEST['rememberme'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Request read only after successful verify_login_nonce() in this request.
16281652
$rememberme = true;
16291653
}
16301654

@@ -1665,7 +1689,7 @@ public static function _login_form_validate_2fa( $user, $nonce = '', $provider =
16651689
$interim_login = isset( $_REQUEST['interim-login'] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited,WordPress.Security.NonceVerification.Recommended
16661690

16671691
if ( $interim_login ) {
1668-
$customize_login = isset( $_REQUEST['customize-login'] );
1692+
$customize_login = isset( $_REQUEST['customize-login'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Request read only after successful verify_login_nonce() in this request.
16691693
if ( $customize_login ) {
16701694
wp_enqueue_script( 'customize-base' );
16711695
wp_add_inline_script(
@@ -1699,10 +1723,10 @@ public static function _login_form_validate_2fa( $user, $nonce = '', $provider =
16991723
* @since 0.9.0
17001724
*/
17011725
public static function login_form_revalidate_2fa() {
1702-
$nonce = ! empty( $_REQUEST['wp-auth-nonce'] ) ? wp_unslash( $_REQUEST['wp-auth-nonce'] ) : '';
1703-
$provider = ! empty( $_REQUEST['provider'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['provider'] ) ) : false;
1704-
$redirect_to = ! empty( $_REQUEST['redirect_to'] ) ? wp_unslash( $_REQUEST['redirect_to'] ) : admin_url();
1705-
$is_post_request = ( 'POST' === strtoupper( $_SERVER['REQUEST_METHOD'] ) );
1726+
$nonce = ( isset( $_REQUEST['wp-auth-nonce'] ) && is_scalar( $_REQUEST['wp-auth-nonce'] ) ) ? sanitize_text_field( wp_unslash( (string) $_REQUEST['wp-auth-nonce'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in _login_form_revalidate_2fa() for POST before processing.
1727+
$provider = ! empty( $_REQUEST['provider'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['provider'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in _login_form_revalidate_2fa() for POST before processing.
1728+
$redirect_to = ! empty( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in _login_form_revalidate_2fa() for POST before processing.
1729+
$is_post_request = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( $_SERVER['REQUEST_METHOD'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is not user input.
17061730

17071731
self::_login_form_revalidate_2fa( $nonce, $provider, $redirect_to, $is_post_request );
17081732
exit;
@@ -2128,7 +2152,9 @@ public static function user_two_factor_options( $user ) {
21282152
self::add_error(
21292153
new WP_Error(
21302154
'two_factor_suggest_backup',
2131-
__( 'To prevent being locked out of your account, consider enabling a backup method like Recovery Codes in case you lose access to your primary authentication method.', 'two-factor' ),
2155+
isset( $providers['Two_Factor_Backup_Codes'] )
2156+
? __( 'To prevent being locked out of your account, consider enabling a backup method like Recovery Codes in case you lose access to your primary authentication method.', 'two-factor' )
2157+
: __( 'To prevent being locked out of your account, consider enabling an additional two-factor method in case you lose access to your primary authentication method.', 'two-factor' ),
21322158
array(
21332159
'type' => 'warning',
21342160
)
@@ -2238,7 +2264,13 @@ private static function render_user_providers_form( $user, $providers ) {
22382264

22392265
?>
22402266
<p>
2241-
<?php esc_html_e( 'Configure a primary two-factor method along with a backup method, such as Recovery Codes, to avoid being locked out if you lose access to your primary method. Methods marked as recommended are more secure and easier to use.', 'two-factor' ); ?>
2267+
<?php
2268+
echo esc_html(
2269+
isset( $providers['Two_Factor_Backup_Codes'] )
2270+
? __( 'Configure a primary two-factor method along with a backup method, such as Recovery Codes, to avoid being locked out if you lose access to your primary method. Methods marked as recommended are more secure and easier to use.', 'two-factor' )
2271+
: __( 'Configure a primary two-factor method along with an additional two-factor method to avoid being locked out if you lose access to your primary method. Methods marked as recommended are more secure and easier to use.', 'two-factor' )
2272+
);
2273+
?>
22422274
</p>
22432275

22442276
<?php if ( function_exists( 'wp_is_application_passwords_available_for_user' ) && wp_is_application_passwords_available_for_user( $user ) ) : ?>
@@ -2551,11 +2583,7 @@ public static function get_current_user_session() {
25512583
* @return boolean
25522584
*/
25532585
public static function rememberme() {
2554-
$rememberme = false;
2555-
2556-
if ( ! empty( $_REQUEST['rememberme'] ) ) {
2557-
$rememberme = true;
2558-
}
2586+
$rememberme = ! empty( $_REQUEST['rememberme'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Non-destructive display/flow flag; value normalized to bool below.
25592587

25602588
/**
25612589
* Filters whether the login session should persist between browser sessions.

0 commit comments

Comments
 (0)