diff --git a/src/js/_enqueues/admin/auth-app.js b/src/js/_enqueues/admin/auth-app.js index 99478d1824a2d..d0b68ea5fca4c 100644 --- a/src/js/_enqueues/admin/auth-app.js +++ b/src/js/_enqueues/admin/auth-app.js @@ -2,7 +2,7 @@ * @output wp-admin/js/auth-app.js */ -/* global authApp */ +/* global authApp, ClipboardJS */ ( function( $, authApp ) { var $appNameField = $( '#app_name' ), @@ -11,10 +11,26 @@ $form = $appNameField.closest( 'form' ), context = { userLogin: authApp.user_login, - successUrl: authApp.success, - rejectUrl: authApp.reject + successUrl: authApp.success }; + // If redirecting to an external site, gate the approve button behind the confirmation checkbox. + if ( authApp.successHost ) { + var $checkbox = $( 'input[name="confirm_external_redirect"]' ); + + // Start the approve button in a disabled state. + $approveBtn.prop( 'aria-disabled', true ).addClass( 'disabled' ); + + // Toggle the approve button when the checkbox state changes. + $checkbox.on( 'change', function() { + if ( $checkbox.prop( 'checked' ) ) { + $approveBtn.removeProp( 'aria-disabled' ).removeClass( 'disabled' ); + } else { + $approveBtn.prop( 'aria-disabled', true ).addClass( 'disabled' ); + } + } ); + } + $approveBtn.on( 'click', function( e ) { var name = $appNameField.val(), appId = $( 'input[name="app_id"]', $form ).val(); @@ -25,8 +41,8 @@ return; } - if ( 0 === name.length ) { - $appNameField.trigger( 'focus' ); + if ( ! $form[ 0 ].checkValidity() ) { + $form[ 0 ].reportValidity(); return; } @@ -44,12 +60,12 @@ * Filters the request data used to Authorize an Application Password request. * * @since 5.6.0 + * @since x.y.z A reject URL is no longer supported or used. * * @param {Object} request The request data. * @param {Object} context Context about the Application Password request. * @param {string} context.userLogin The user's login username. * @param {string} context.successUrl The URL the user will be redirected to after approving the request. - * @param {string} context.rejectUrl The URL the user will be redirected to after rejecting the request. */ request = wp.hooks.applyFilters( 'wp_application_passwords_approve_app_request', request, context ); @@ -89,20 +105,43 @@ /* translators: %s: Application name. */ '', '' - ) + ' '; + ); $notice = $( '
' ) .attr( 'role', 'alert' ) .attr( 'tabindex', -1 ) .addClass( 'notice notice-success notice-alt' ) .append( $( '

' ).addClass( 'application-password-display' ).html( message ) ) + .append( + $( '

' ) + .addClass( 'application-password-display' ) + .append( '' ) + .append( ' ' ) + .append( '' ) + ) .append( '

' + wp.i18n.__( 'Be sure to save this in a safe location. You will not be able to retrieve it.' ) + '

' ); // We're using .text() to write the variables to avoid any chance of XSS. $( 'strong', $notice ).text( response.name ); $( 'input', $notice ).val( response.password ); + $( '.copy-button', $notice ).attr( 'data-clipboard-text', response.password ); $form.replaceWith( $notice ); $notice.trigger( 'focus' ); + + // Initialize clipboard functionality for the copy button. + var clipboard = new ClipboardJS( '.copy-button' ); + clipboard.on( 'success', function( e ) { + var $successElement = $( '.success', $( e.trigger ).parent() ); + + e.clearSelection(); + $successElement.removeClass( 'hidden' ); + + setTimeout( function() { + $successElement.addClass( 'hidden' ); + }, 3000 ); + + wp.a11y.speak( wp.i18n.__( 'Application password has been copied to your clipboard.' ) ); + } ); } } ).fail( function( jqXHR, textStatus, errorThrown ) { var errorMessage = errorThrown, @@ -147,16 +186,22 @@ * Fires when an Authorize Application Password request has been rejected by the user. * * @since 5.6.0 + * @since x.y.z A reject URL is no longer supported or used. * * @param {Object} context Context about the Application Password request. * @param {string} context.userLogin The user's login username. * @param {string} context.successUrl The URL the user will be redirected to after approving the request. - * @param {string} context.rejectUrl The URL the user will be redirected to after rejecting the request. */ wp.hooks.doAction( 'wp_application_passwords_reject_app', context ); - // @todo: Make a better way to do this so it feels like less of a semi-open redirect. - window.location = authApp.reject; + var $notice = $( '
' ) + .attr( 'role', 'alert' ) + .attr( 'tabindex', -1 ) + .addClass( 'notice notice-info' ) + .append( $( '

' ).text( wp.i18n.__( 'You have not approved this connection. No data has been shared with the application.' ) ) ); + + $form.replaceWith( $notice ); + $notice.trigger( 'focus' ); } ); $form.on( 'submit', function( e ) { diff --git a/src/wp-admin/authorize-application.php b/src/wp-admin/authorize-application.php index 8d931f46666a2..2bad15f7f7f3c 100644 --- a/src/wp-admin/authorize-application.php +++ b/src/wp-admin/authorize-application.php @@ -17,17 +17,12 @@ check_admin_referer( 'authorize_application_password' ); $success_url = $_POST['success_url']; - $reject_url = $_POST['reject_url']; $app_name = $_POST['app_name']; $app_id = $_POST['app_id']; $redirect = ''; if ( isset( $_POST['reject'] ) ) { - if ( $reject_url ) { - $redirect = $reject_url; - } else { - $redirect = admin_url(); - } + $redirect = admin_url(); } elseif ( isset( $_POST['approve'] ) ) { $created = WP_Application_Passwords::create_new_application_password( get_current_user_id(), @@ -56,7 +51,7 @@ } if ( $redirect ) { - // Explicitly not using wp_safe_redirect b/c sends to arbitrary domain. + // Explicitly not using wp_safe_redirect b/c sends to arbitrary domain or custom scheme URL. wp_redirect( $redirect ); exit; } @@ -69,17 +64,9 @@ $app_id = ! empty( $_REQUEST['app_id'] ) ? $_REQUEST['app_id'] : ''; $success_url = ! empty( $_REQUEST['success_url'] ) ? $_REQUEST['success_url'] : null; -if ( ! empty( $_REQUEST['reject_url'] ) ) { - $reject_url = $_REQUEST['reject_url']; -} elseif ( $success_url ) { - $reject_url = add_query_arg( 'success', 'false', $success_url ); -} else { - $reject_url = null; -} - $user = wp_get_current_user(); -$request = compact( 'app_name', 'app_id', 'success_url', 'reject_url' ); +$request = compact( 'app_name', 'app_id', 'success_url' ); $is_valid = wp_is_authorize_application_password_request_valid( $request, $user ); if ( is_wp_error( $is_valid ) ) { @@ -96,7 +83,7 @@ array( 'response' => 501, 'link_text' => __( 'Go Back' ), - 'link_url' => $reject_url ? add_query_arg( 'error', 'disabled', $reject_url ) : admin_url(), + 'link_url' => admin_url(), ) ); } @@ -114,20 +101,27 @@ array( 'response' => 501, 'link_text' => __( 'Go Back' ), - 'link_url' => $reject_url ? add_query_arg( 'error', 'disabled', $reject_url ) : admin_url(), + 'link_url' => admin_url(), ) ); } wp_enqueue_script( 'auth-app' ); + +// Determine how to display the success URL target to the user. +$success_scheme = $success_url ? wp_parse_url( $success_url, PHP_URL_SCHEME ) : ''; +$success_host = $success_url ? wp_parse_url( $success_url, PHP_URL_HOST ) : ''; +$is_custom_scheme = $success_scheme && ! in_array( $success_scheme, array( 'http', 'https' ), true ); +$success_host_display = $is_custom_scheme ? $success_scheme . '://' . $success_host : $success_host; + wp_localize_script( 'auth-app', 'authApp', array( - 'site_url' => site_url(), - 'user_login' => $user->user_login, - 'success' => $success_url, - 'reject' => $reject_url ? $reject_url : admin_url(), + 'site_url' => site_url(), + 'user_login' => $user->user_login, + 'success' => $success_url, + 'successHost' => $success_host, ) ); @@ -150,56 +144,6 @@

- -

- ' . esc_html( $app_name ) . '' - ); - ?> -

- -

- - - ID, true ); - $blogs_count = count( $blogs ); - - if ( $blogs_count > 1 ) { - ?> -

- the %2$s site in this installation that you have permissions on.', - 'This will grant access to all %2$s sites in this installation that you have permissions on.', - $blogs_count - ); - - if ( is_super_admin() ) { - /* translators: 1: URL to my-sites.php, 2: Number of sites the user has. */ - $message = _n( - 'This will grant access to the %2$s site on the network as you have Super Admin rights.', - 'This will grant access to all %2$s sites on the network as you have Super Admin rights.', - $blogs_count - ); - } - - printf( - $message, - admin_url( 'my-sites.php' ), - number_format_i18n( $blogs_count ) - ); - ?> -

- ' . esc_html( $app_name ) . '' ) . ' +

+

+ +

' . __( 'Be sure to save this in a safe location. You will not be able to retrieve it.' ) . '

'; $args = array( @@ -241,13 +189,98 @@ - + + +

+ ' . esc_html( $app_name ) . '' + ); + ?> +

+ +

+ + + ID, true ); + $blogs_count = count( $blogs ); + + if ( $blogs_count > 1 ) { + ?> +

+ the %2$s site in this installation that you have permissions on.', + 'This will grant access to all %2$s sites in this installation that you have permissions on.', + $blogs_count + ); + + if ( is_super_admin() ) { + /* translators: 1: URL to my-sites.php, 2: Number of sites the user has. */ + $message = _n( + 'This will grant access to the %2$s site on the network as you have Super Admin rights.', + 'This will grant access to all %2$s sites on the network as you have Super Admin rights.', + $blogs_count + ); + } + + printf( + $message, + admin_url( 'my-sites.php' ), + number_format_i18n( $blogs_count ) + ); + ?> +

+ + + +
+

+ +

+

+

+
+
+ +

+ +

+ +
+

+
+ + 'description-approve', - ) + false ); ?> -

- ' . esc_html( - add_query_arg( - array( - 'site_url' => site_url(), - 'user_login' => $user->user_login, - 'password' => '[------]', - ), - $success_url - ) - ) . '' - ); - } else { - _e( 'You will be given a password to manually enter into the application in question.' ); - } - ?> -

'description-reject', - ) + false ); ?> -

- ' . esc_html( $reject_url ) . '' - ); - } else { - _e( 'You will be returned to the WordPress Dashboard, and no changes will be made.' ); - } - ?> -

diff --git a/src/wp-admin/css/forms.css b/src/wp-admin/css/forms.css index cc4a8e482ca42..b5c5677447f70 100644 --- a/src/wp-admin/css/forms.css +++ b/src/wp-admin/css/forms.css @@ -1064,6 +1064,32 @@ table.form-table td .updated p { max-width: 768px; } +.auth-app-warning { + margin: 16px 0; + padding: 14px 16px; + border-left: 4px solid #dba617; + background: #fcf9e8; +} + +.auth-app-warning p { + margin: 0; +} + +.auth-app-warning .auth-app-host { + font-size: 18px; + font-family: monospace; + font-weight: bold; + color: #6e4e00; +} + +.auth-app-warning .auth-app-caution { + margin-top: 8px; +} + +.auth-app-card .auth-app-checkbox { + margin-top: 16px; +} + .authorize-application-php .form-wrap p { display: block; } diff --git a/src/wp-admin/includes/user.php b/src/wp-admin/includes/user.php index 1181dcb9fd4e6..4ae738d0035ed 100644 --- a/src/wp-admin/includes/user.php +++ b/src/wp-admin/includes/user.php @@ -638,7 +638,8 @@ function admin_created_user_email( $text ) { * * @since 5.6.0 * @since 6.2.0 Allow insecure HTTP connections for the local environment. - * @since 6.3.2 Validates the success and reject URLs to prevent `javascript` pseudo protocol from being executed. + * @since 6.3.2 Validates the success URL to prevent `javascript` pseudo protocol from being executed. + * @since x.y.z A reject URL is no longer supported or used. * * @param array $request { * The array of request data. All arguments are optional and may be empty. @@ -646,7 +647,6 @@ function admin_created_user_email( $text ) { * @type string $app_name The suggested name of the application. * @type string $app_id A UUID provided by the application to uniquely identify it. * @type string $success_url The URL the user will be redirected to after approving the application. - * @type string $reject_url The URL the user will be redirected to after rejecting the application. * } * @param WP_User $user The user authorizing the application. * @return true|WP_Error True if the request is valid, a WP_Error object contains errors if not. @@ -664,16 +664,6 @@ function wp_is_authorize_application_password_request_valid( $request, $user ) { } } - if ( isset( $request['reject_url'] ) ) { - $validated_reject_url = wp_is_authorize_application_redirect_url_valid( $request['reject_url'] ); - if ( is_wp_error( $validated_reject_url ) ) { - $error->add( - $validated_reject_url->get_error_code(), - $validated_reject_url->get_error_message() - ); - } - } - if ( ! empty( $request['app_id'] ) && ! wp_is_uuid( $request['app_id'] ) ) { $error->add( 'invalid_app_id', @@ -745,6 +735,13 @@ function wp_is_authorize_application_redirect_url_valid( $url ) { ); } + if ( null !== wp_parse_url( $url, PHP_URL_USER ) ) { + return new WP_Error( + 'invalid_redirect_url_format', + __( 'Credentials are not allowed in the URL.' ) + ); + } + if ( 'http' === $scheme && ! $is_local ) { return new WP_Error( 'invalid_redirect_scheme', diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index f9ea36720baea..79bc0403bd8e0 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1243,7 +1243,7 @@ function wp_default_scripts( $scripts ) { $scripts->add( 'application-passwords', "/wp-admin/js/application-passwords$suffix.js", array( 'jquery', 'wp-util', 'wp-api-request', 'wp-date', 'wp-i18n', 'wp-hooks' ), false, 1 ); $scripts->set_translations( 'application-passwords' ); - $scripts->add( 'auth-app', "/wp-admin/js/auth-app$suffix.js", array( 'jquery', 'wp-api-request', 'wp-i18n', 'wp-hooks' ), false, 1 ); + $scripts->add( 'auth-app', "/wp-admin/js/auth-app$suffix.js", array( 'clipboard', 'jquery', 'wp-api-request', 'wp-i18n', 'wp-a11y', 'wp-hooks' ), false, 1 ); $scripts->set_translations( 'auth-app' ); $scripts->add( 'user-profile', "/wp-admin/js/user-profile$suffix.js", array( 'clipboard', 'jquery', 'password-strength-meter', 'wp-util', 'wp-a11y' ), false, 1 ); diff --git a/tests/phpunit/tests/admin/Admin_Includes_User_WpIsAuthorizeApplicationPasswordRequestValid_Test.php b/tests/phpunit/tests/admin/Admin_Includes_User_WpIsAuthorizeApplicationPasswordRequestValid_Test.php index 42bc1af3841f2..8751c21e55239 100644 --- a/tests/phpunit/tests/admin/Admin_Includes_User_WpIsAuthorizeApplicationPasswordRequestValid_Test.php +++ b/tests/phpunit/tests/admin/Admin_Includes_User_WpIsAuthorizeApplicationPasswordRequestValid_Test.php @@ -52,33 +52,27 @@ public function data_is_authorize_application_password_request_valid() { 'env' => $environment_type, ); - $datasets[ $environment_type . ' and a "https" scheme "reject_url"' ] = array( - 'request' => array( 'reject_url' => 'https://example.org' ), - 'expected_error_code' => '', - 'env' => $environment_type, - ); - $datasets[ $environment_type . ' and an app scheme "success_url"' ] = array( 'request' => array( 'success_url' => 'wordpress://example' ), 'expected_error_code' => '', 'env' => $environment_type, ); - $datasets[ $environment_type . ' and an app scheme "reject_url"' ] = array( - 'request' => array( 'reject_url' => 'wordpress://example' ), - 'expected_error_code' => '', - 'env' => $environment_type, - ); - $datasets[ $environment_type . ' and a "http" scheme "success_url"' ] = array( 'request' => array( 'success_url' => 'http://example.org' ), 'expected_error_code' => 'local' === $environment_type ? '' : 'invalid_redirect_scheme', 'env' => $environment_type, ); - $datasets[ $environment_type . ' and a "http" scheme "reject_url"' ] = array( - 'request' => array( 'reject_url' => 'http://example.org' ), - 'expected_error_code' => 'local' === $environment_type ? '' : 'invalid_redirect_scheme', + $datasets[ $environment_type . ' and a userinfo "success_url"' ] = array( + 'request' => array( 'success_url' => 'https://user:pass@evil.com/capture' ), + 'expected_error_code' => 'invalid_redirect_url_format', + 'env' => $environment_type, + ); + + $datasets[ $environment_type . ' and a userinfo-only "success_url"' ] = array( + 'request' => array( 'success_url' => 'https://google.com@evil.com/capture' ), + 'expected_error_code' => 'invalid_redirect_url_format', 'env' => $environment_type, ); } diff --git a/tests/phpunit/tests/admin/Admin_Includes_User_WpIsAuthorizeApplicationRedirectUrlValid_Test.php b/tests/phpunit/tests/admin/Admin_Includes_User_WpIsAuthorizeApplicationRedirectUrlValid_Test.php new file mode 100644 index 0000000000000..6eaeb82e0a0b7 --- /dev/null +++ b/tests/phpunit/tests/admin/Admin_Includes_User_WpIsAuthorizeApplicationRedirectUrlValid_Test.php @@ -0,0 +1,125 @@ +assertWPError( $actual, 'A WP_Error object is expected.' ); + $this->assertSame( $expected_error_code, $actual->get_error_code(), 'Unexpected error code.' ); + if ( $expected_message ) { + $this->assertSame( $expected_message, $actual->get_error_message(), 'Unexpected error message.' ); + } + } else { + $this->assertNotWPError( $actual, 'A WP_Error object is not expected.' ); + } + } + + public function data_is_authorize_application_redirect_url_valid() { + return array( + // Empty URL is valid (no redirect). + 'empty URL' => array( + 'url' => '', + 'expected_error_code' => '', + ), + + // Valid HTTPS URLs. + 'https URL' => array( + 'url' => 'https://example.org', + 'expected_error_code' => '', + ), + 'https URL with path' => array( + 'url' => 'https://example.org/callback', + 'expected_error_code' => '', + ), + 'https URL with port' => array( + 'url' => 'https://example.org:8443/callback', + 'expected_error_code' => '', + ), + 'https URL with query params' => array( + 'url' => 'https://example.org/callback?existing=param', + 'expected_error_code' => '', + ), + + // Valid app scheme URLs. + 'app scheme URL' => array( + 'url' => 'wordpress://example', + 'expected_error_code' => '', + ), + 'custom app scheme URL' => array( + 'url' => 'myapp://callback', + 'expected_error_code' => '', + ), + + // Userinfo in URL (authority confusion attack). + 'username and password in URL' => array( + 'url' => 'https://user:pass@evil.com/capture', + 'expected_error_code' => 'invalid_redirect_url_format', + 'expected_message' => 'Credentials are not allowed in the URL.', + ), + 'username only in URL' => array( + 'url' => 'https://google.com@evil.com/capture', + 'expected_error_code' => 'invalid_redirect_url_format', + 'expected_message' => 'Credentials are not allowed in the URL.', + ), + 'username with empty password in URL' => array( + 'url' => 'https://user:@evil.com/capture', + 'expected_error_code' => 'invalid_redirect_url_format', + 'expected_message' => 'Credentials are not allowed in the URL.', + ), + + // Invalid protocols. + 'javascript protocol' => array( + 'url' => 'javascript:alert(1)', + 'expected_error_code' => 'invalid_redirect_url_format', + ), + 'data protocol' => array( + 'url' => 'data:text/html,', + 'expected_error_code' => 'invalid_redirect_url_format', + ), + + // Invalid format. + 'no scheme' => array( + 'url' => 'example.org/callback', + 'expected_error_code' => 'invalid_redirect_url_format', + ), + + // HTTP scheme depends on environment. + 'http URL on production' => array( + 'url' => 'http://example.org', + 'expected_error_code' => 'invalid_redirect_scheme', + 'expected_message' => '', + 'env' => 'production', + ), + 'http URL on local' => array( + 'url' => 'http://example.org', + 'expected_error_code' => '', + 'expected_message' => '', + 'env' => 'local', + ), + ); + } +}