diff --git a/CHANGELOG.md b/CHANGELOG.md index c62cef9..eea7c72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,27 @@ merge now runs in a scratch worktree and a failed run can be retried. the site's real `Host` header so the rendered page is unchanged and the internal address never reaches the output. The stack sets it by default. +### The login address can move + +- **Sirius Press → Sign-in** now has a *Login address* field. Set it and the + sign-in screen answers at `https://yoursite/`; `wp-login.php` answers + 404, rendering the theme's own 404 page rather than redirecting, because a + redirect would hand the new address to the first scanner that asked for the + old one. Everything that builds a login URL follows — `wp_login_url()`, + logout, lost password, registration, and the redirect `auth_redirect()` + sends a logged-out visitor to `wp-admin` with. +- Not about brute force: there is no password to guess. It is about + `wp-login.php` being the most-requested path on the internet, and a wallet + login screen answering it telling every scanner what runs here. +- **It does not rename `wp-admin`, and will not.** WordPress has no supported + way to do that — the path is built into `ABSPATH . 'wp-admin/'`, into core's + own asset loaders, and into a great deal of plugin JavaScript. A proxy plus + a filtered `admin_url()` gets perhaps nine tenths of the way and then breaks + plugins silently, which is the one thing this fork promises not to do. +- Locked out? `wp option delete sirius_press_login_slug` puts it back, and + `SIRIUS_PRESS_LOGIN_SLUG` in `wp-config.php` overrides the option for anyone + who can still edit a file but not reach a browser. + ### Fixed while building a real site on it - **Turning passwords off made the login screen fail on arrival.** diff --git a/plugins/sirius-press-auth/includes/class-spa-login-url.php b/plugins/sirius-press-auth/includes/class-spa-login-url.php new file mode 100644 index 0000000..dab1356 --- /dev/null +++ b/plugins/sirius-press-auth/includes/class-spa-login-url.php @@ -0,0 +1,317 @@ +set_404(); + } + + status_header( 404 ); + nocache_headers(); + + require_once ABSPATH . WPINC . '/template-loader.php'; + exit; + } + + /** + * Rewrite every URL core builds for wp-login.php. + * + * @param string $url + * @param string $path + * @param string $scheme + * @return string + */ + public static function filter_site_url( $url, $path, $scheme ) { + if ( ! is_string( $path ) || 0 !== strpos( ltrim( $path, '/' ), 'wp-login.php' ) ) { + return $url; + } + + // The caller may have appended a query — "wp-login.php?action=logout". + $query = ''; + $mark = strpos( $url, '?' ); + if ( false !== $mark ) { + $query = substr( $url, $mark ); + } + + return self::url( $scheme ) . $query; + } + + /** + * Catch redirects built by hand rather than through site_url(). + */ + public static function filter_redirect( $location ) { + if ( ! is_string( $location ) || false === strpos( $location, 'wp-login.php' ) ) { + return $location; + } + + return str_replace( site_url( 'wp-login.php', 'login' ), self::url(), $location ); + } + + // ------------------------------------------------------------------ paths + + /** The request path, trimmed of its trailing slash, or null. */ + private static function request_path() { + if ( empty( $_SERVER['REQUEST_URI'] ) ) { + return null; + } + + $uri = rawurldecode( wp_unslash( $_SERVER['REQUEST_URI'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + $path = wp_parse_url( $uri, PHP_URL_PATH ); + + return is_string( $path ) ? untrailingslashit( $path ) : null; + } + + /** The path WordPress is served from, '' at the document root. */ + private static function home_path() { + return untrailingslashit( (string) wp_parse_url( home_url(), PHP_URL_PATH ) ); + } + + private static function site_path() { + return untrailingslashit( (string) wp_parse_url( site_url(), PHP_URL_PATH ) ); + } + + // --------------------------------------------------------------- warnings + + /** + * Reasons a slug would not do what the operator expects, for the settings + * screen to show before it is saved rather than after. + * + * @return string[] + */ + public static function problems( $slug ) { + $problems = array(); + + if ( '' === self::sanitize( $slug ) && '' !== trim( (string) $slug ) ) { + $problems[] = __( 'A login address is one path segment of lowercase letters, digits and hyphens — and not a name WordPress already uses.', 'sirius-press' ); + return $problems; + } + + $slug = self::sanitize( $slug ); + if ( '' === $slug ) { + return $problems; + } + + if ( is_multisite() ) { + $problems[] = __( 'This site is part of a network, and a network resolves wp-login.php itself. Moving it here would only half work, so it is left alone.', 'sirius-press' ); + } + + $clash = get_page_by_path( $slug, OBJECT, array( 'page', 'post' ) ); + if ( $clash ) { + $problems[] = sprintf( + /* translators: %s: the conflicting post title. */ + __( 'There is already a page at that address — “%s”. The login screen would hide it.', 'sirius-press' ), + get_the_title( $clash ) + ); + } + + if ( defined( 'SIRIUS_PRESS_LOGIN_SLUG' ) ) { + $problems[] = sprintf( + /* translators: %s: the slug set in wp-config.php. */ + __( 'wp-config.php pins the login address to “%s”, which overrides whatever is saved here.', 'sirius-press' ), + esc_html( self::sanitize( SIRIUS_PRESS_LOGIN_SLUG ) ) + ); + } + + return $problems; + } +} diff --git a/plugins/sirius-press-auth/includes/class-spa-settings.php b/plugins/sirius-press-auth/includes/class-spa-settings.php index 17d8c00..d392715 100644 --- a/plugins/sirius-press-auth/includes/class-spa-settings.php +++ b/plugins/sirius-press-auth/includes/class-spa-settings.php @@ -56,6 +56,26 @@ final class SPA_Settings { } update_option( SP_Settings::OPT_OPEN_REG, ! empty( $_POST['sirius_open_registration'] ) ); + + $raw = isset( $_POST['sirius_login_slug'] ) ? sanitize_text_field( wp_unslash( $_POST['sirius_login_slug'] ) ) : ''; + $slug = SPA_Login_URL::sanitize( $raw ); + $slug_issues = SPA_Login_URL::problems( $raw ); + + if ( $slug_issues ) { + $errors = array_merge( $errors, $slug_issues ); + } elseif ( $slug !== SPA_Login_URL::slug() ) { + if ( '' === $slug ) { + delete_option( SPA_Login_URL::OPT_SLUG ); + $notes[] = __( 'The login screen is back at wp-login.php.', 'sirius-press' ); + } else { + update_option( SPA_Login_URL::OPT_SLUG, $slug ); + $notes[] = sprintf( + /* translators: %s: the new login URL. */ + __( 'The login screen has moved to %s. Save it somewhere — wp-login.php now answers 404.', 'sirius-press' ), + home_url( '/' . $slug ) + ); + } + } } $stranded = self::accounts_without_wallets(); @@ -91,6 +111,31 @@ final class SPA_Settings { ); echo ''; + echo '' . esc_html__( 'Login address', 'sirius-press' ) . ''; + printf( + '%s ', + esc_html( trailingslashit( home_url( '/' ) ) ), + esc_attr( SPA_Login_URL::slug() ), + esc_attr__( 'wp-login.php', 'sirius-press' ) + ); + echo '

'; + esc_html_e( 'Move the sign-in screen somewhere other than wp-login.php. Leave it empty to keep the usual address.', 'sirius-press' ); + echo ' '; + esc_html_e( 'This is not about brute force — there is no password to guess — but wp-login.php is the most-requested path on the internet, and answering it tells every scanner what is running here. Note that it does not move wp-admin: WordPress has no supported way to rename that, and pretending otherwise breaks plugins quietly.', 'sirius-press' ); + echo '

'; + if ( SPA_Login_URL::slug() ) { + echo '

'; + printf( + /* translators: %s: the current login URL. */ + esc_html__( 'Signing in now happens at %s.', 'sirius-press' ), + '' . esc_html( home_url( '/' . SPA_Login_URL::slug() ) ) . '' + ); + echo ' '; + esc_html_e( 'If you lose it, `wp option get sirius_press_login_slug` on the server will tell you, and `wp option delete sirius_press_login_slug` will put the login back.', 'sirius-press' ); + echo '

'; + } + echo ''; + echo ''; submit_button(); echo ''; diff --git a/plugins/sirius-press-auth/sirius-press-auth.php b/plugins/sirius-press-auth/sirius-press-auth.php index 385f928..47c4c05 100644 --- a/plugins/sirius-press-auth/sirius-press-auth.php +++ b/plugins/sirius-press-auth/sirius-press-auth.php @@ -18,10 +18,19 @@ defined( 'ABSPATH' ) || exit; -define( 'SIRIUS_PRESS_AUTH_VERSION', '0.1.1' ); +define( 'SIRIUS_PRESS_AUTH_VERSION', '0.2.0' ); define( 'SIRIUS_PRESS_AUTH_DIR', plugin_dir_path( __FILE__ ) ); define( 'SIRIUS_PRESS_AUTH_URL', plugin_dir_url( __FILE__ ) ); +/* + * The login address is decided before everything else, and outside the + * `plugins_loaded` callback below, because it has to hook `plugins_loaded` + * itself at a priority earlier than that callback runs. It depends on nothing + * but WordPress, so there is nothing to wait for. + */ +require_once SIRIUS_PRESS_AUTH_DIR . 'includes/class-spa-login-url.php'; +SPA_Login_URL::hooks(); + add_action( 'plugins_loaded', function () {