feature = $feature; } /** * Handle a request that carries the recovery argument. Every other request returns at once. * * @return void */ public function handle() { // phpcs:disable WordPress.Security.NonceVerification -- the token is the credential; see the class comment. if ( ! isset( $_GET[ RecoveryLink::QUERY_ARG ] ) && ! isset( $_POST[ RecoveryLink::QUERY_ARG ] ) ) { return; } $posting = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD']; // A POST is believed only for the token in its own body: a link with the token in its // address, sent with POST by something that prefetches, must not count as the button. // Raw on purpose: the value is matched by hash and never stored; only a matched token, // which is 64 hex characters, is printed. $source = $posting ? $_POST : $_GET; $token = isset( $source[ RecoveryLink::QUERY_ARG ] ) ? wp_unslash( $source[ RecoveryLink::QUERY_ARG ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized // phpcs:enable WordPress.Security.NonceVerification $this->send_headers(); // Page-cache plugins read this constant; headers alone do not stop all of them from // storing a page that carries the token in its form. if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- the name the cache plugins agreed on. } if ( ! RecoveryLink::matches( $token ) ) { $this->page( __( 'This link is no longer valid', 'adminkeep' ), $this->invalid_page(), 403 ); return; } if ( ! $posting ) { $this->page( __( 'Turn off Two-Factor Login?', 'adminkeep' ), $this->confirm_page( $token ), 200 ); return; } // Through the writer, so this is the same change as `wp adminkeep feature disable // two_factor`: only the switch moves, the roles and the other settings stay. The writer // tells the feature it went off, and the feature forgets the link; revoked here as well so // the link is single use even if that ever changes. ( new SettingsWriter( Plugin::instance()->registry() ) )->set_enabled( $this->feature, false ); RecoveryLink::revoke(); RecoveryLink::notify_administrators( $this->request_ip() ); $this->page( __( 'Two-Factor Login is off', 'adminkeep' ), $this->done_page(), 200 ); } /** * Headers for every answer to the link. * * The address carries the token, so the answer must not be cached, indexed, or named as the * referrer when the visitor follows a link away from the page. * * @return void */ private function send_headers() { // Headers are already out when PHPUnit drives this, and header() would only warn. if ( headers_sent() ) { return; } nocache_headers(); header( 'X-Robots-Tag: noindex, nofollow' ); header( 'Referrer-Policy: no-referrer' ); } /** * Print one standalone page and stop. * * @param string $title Page title. * @param string $html Body, already escaped. * @param int $response HTTP status. * @return void */ private function page( $title, $html, $response ) { // On admin-ajax.php wp_die() prints bare text for scripts to read. This is a page for a // person, so it gets the same handler any other request would. add_filter( 'wp_die_ajax_handler', static function () { return '_default_wp_die_handler'; } ); wp_die( $html, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts by the three methods below. esc_html( $title ), array( 'response' => (int) $response ) ); } /** * The page a GET with the right token gets: what the button does, and the button. * * @param string $token The matched token. * @return string */ private function confirm_page( $token ) { $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); return '

' . esc_html__( 'Turn off Two-Factor Login?', 'adminkeep' ) . '

' . '

' . esc_html( sprintf( /* translators: %s: site name. */ __( 'This turns off Two-Factor Login on %s. After that, nobody is asked for a login code, and a password alone signs in. The roles you chose are kept for when you switch it back on.', 'adminkeep' ), $site ) ) . '

' . '

' . esc_html__( 'The administrators of this site are told by email that this link was used. The link works once.', 'adminkeep' ) . '

' . '
' . '' . '

' . '
'; } /** * The page after the button: it is off, and where to log in. * * @return string */ private function done_page() { return '

' . esc_html__( 'Two-Factor Login is off', 'adminkeep' ) . '

' . '

' . esc_html__( 'Nobody is asked for a login code now. Log in with your password, then fix what kept you out before you switch Two-Factor Login back on.', 'adminkeep' ) . '

' . '

' . esc_html__( 'The administrators of this site have been emailed that this link was used. It does not work again.', 'adminkeep' ) . '

' . '

' . esc_html__( 'Log in', 'adminkeep' ) . '

'; } /** * The one page for a wrong, used, replaced or missing token, on GET and POST alike. * * @return string */ private function invalid_page() { return '

' . esc_html__( 'This link is no longer valid', 'adminkeep' ) . '

' . '

' . esc_html__( 'A recovery link works once, and a new one replaces it each time Two-Factor Login is switched on. If you are locked out, there are two other ways back in:', 'adminkeep' ) . '

' . ''; } /** * The address the request came from, for the email to administrators. * * @return string What to print after "IP address:", or '' when the server gave none. */ private function request_ip() { return self::describe_ip( $_SERVER ); } /** * Name the visitor's address as well as the server can know it. * * Behind Cloudflare, a load balancer or a container proxy, REMOTE_ADDR is the proxy, which * tells an administrator nothing; the visitor's address is in a header the proxy adds. That * header is also whatever a direct sender typed, and this plugin cannot know which proxies a * site trusts. So the email gives both: the address the header names, and the connection it * arrived on, worded so neither is taken for more than it is. * * @param array $server The request's server variables. * @return string */ public static function describe_ip( array $server ) { $direct = self::valid_ip( isset( $server['REMOTE_ADDR'] ) ? $server['REMOTE_ADDR'] : '' ); $forwarded = ''; // The CDNs' own headers first (Cloudflare, then Akamai and Cloudflare Enterprise), then the // two that nginx, Apache and most load balancers set. foreach ( array( 'HTTP_CF_CONNECTING_IP', 'HTTP_TRUE_CLIENT_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR' ) as $header ) { if ( empty( $server[ $header ] ) || ! is_string( $server[ $header ] ) ) { continue; } // X-Forwarded-For is a list, the visitor first and each proxy after. $parts = explode( ',', $server[ $header ] ); $forwarded = self::valid_ip( $parts[0] ); if ( '' !== $forwarded ) { break; } } if ( '' === $forwarded || $forwarded === $direct ) { return $direct; } if ( '' === $direct ) { return $forwarded; } return sprintf( /* translators: 1: the visitor's IP address as a proxy reported it, 2: the proxy's IP address. */ __( '%1$s (as reported by the proxy at %2$s)', 'adminkeep' ), $forwarded, $direct ); } /** * One IP address, or nothing. * * @param mixed $value A header or server value. * @return string */ private static function valid_ip( $value ) { $ip = is_string( $value ) ? trim( wp_unslash( $value ) ) : ''; return false !== filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : ''; } }