sirius-press: let the sign-in screen move off wp-login.php
wp-login.php is the most-requested path on the internet. There is no password here to guess, so this is not about brute force — it is that a wallet login screen answering that path tells every scanner that goes past exactly what is running, and serves a few hundred bots a day a page they cannot use. Setting a login address moves the screen and makes wp-login.php answer 404 through the theme's own template. A 404 and not a redirect: a redirect would hand the new address to the first scanner that asked for the old one. Everything that builds a login URL follows it, because they all go through site_url() or network_site_url() with a wp-login.php path, and there is a wp_redirect backstop for code that builds the URL by hand. Refusing wp-login.php has to happen at wp_loaded and has to end the request there. The web server sends that path to the file directly rather than through index.php, so by the time anything of ours runs we are already inside its require of wp-load.php — rewriting $pagenow changes what WordPress thinks the request is, but it does not stop the script that is already running. What this does not do is rename wp-admin. WordPress has no supported way to, and faking it with a proxy and a filtered admin_url() gets most of the way before breaking plugins quietly. Breaking plugins is the one thing this fork promises not to do, so the setting says as much on the screen rather than implying more than it delivers.
This commit is contained in:
parent
582cc81ffe
commit
c403a42904
4 changed files with 393 additions and 1 deletions
21
CHANGELOG.md
21
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/<slug>`; `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.**
|
||||
|
|
|
|||
317
plugins/sirius-press-auth/includes/class-spa-login-url.php
Normal file
317
plugins/sirius-press-auth/includes/class-spa-login-url.php
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
<?php
|
||||
/**
|
||||
* Move the login screen off wp-login.php.
|
||||
*
|
||||
* Why this is worth having on a site with no passwords: not brute force —
|
||||
* there is nothing to guess — but noise and fingerprinting. `wp-login.php`
|
||||
* is the single most-requested path on the internet, and a wallet login
|
||||
* screen answering it serves a few hundred bots a day a page they cannot
|
||||
* use, in exchange for announcing what software is running here.
|
||||
*
|
||||
* What this deliberately does NOT do is rename `wp-admin`. 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 —
|
||||
* and faking it with a proxy and a filtered `admin_url()` gets perhaps nine
|
||||
* tenths of the way before breaking plugins silently. Breaking plugins is the
|
||||
* one thing this fork promises not to do. `wp-login.php` is different: it is
|
||||
* a single entry point, so moving it is honest work rather than a disguise.
|
||||
*
|
||||
* Locked out? The slug is an option, so it can be read or cleared from the
|
||||
* command line without a browser:
|
||||
*
|
||||
* wp option get sirius_press_login_slug
|
||||
* wp option delete sirius_press_login_slug
|
||||
*
|
||||
* Or pin it in wp-config.php, where it overrides the option entirely:
|
||||
*
|
||||
* define( 'SIRIUS_PRESS_LOGIN_SLUG', 'admin' );
|
||||
*
|
||||
* @package SiriusPress
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
class SPA_Login_URL {
|
||||
|
||||
const OPT_SLUG = 'sirius_press_login_slug';
|
||||
|
||||
/** This request came in on the renamed login path. */
|
||||
private static $is_login = false;
|
||||
|
||||
/** This request asked for wp-login.php, which no longer answers. */
|
||||
private static $blocked = false;
|
||||
|
||||
public static function hooks() {
|
||||
// The settings screen has to be able to show and change the slug even
|
||||
// when the feature is off, so those hooks are registered either way.
|
||||
add_filter( 'sirius_press_login_slug', array( __CLASS__, 'slug' ) );
|
||||
|
||||
if ( '' === self::slug() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( is_multisite() ) {
|
||||
// Multisite resolves wp-login.php through the network, and
|
||||
// half-moving it is worse than not moving it.
|
||||
return;
|
||||
}
|
||||
|
||||
add_action( 'plugins_loaded', array( __CLASS__, 'route' ), 1 );
|
||||
add_action( 'wp_loaded', array( __CLASS__, 'serve' ) );
|
||||
|
||||
// Everything that builds a login URL goes through one of these two,
|
||||
// including wp_login_url(), wp_logout_url(), wp_lostpassword_url(),
|
||||
// wp_registration_url() and auth_redirect().
|
||||
add_filter( 'site_url', array( __CLASS__, 'filter_site_url' ), 10, 3 );
|
||||
add_filter( 'network_site_url', array( __CLASS__, 'filter_site_url' ), 10, 3 );
|
||||
|
||||
// A backstop for code that builds the URL itself instead of asking.
|
||||
add_filter( 'wp_redirect', array( __CLASS__, 'filter_redirect' ), 10, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured slug, or '' when the login has not been moved.
|
||||
*
|
||||
* A constant wins over the option, so an operator who has locked
|
||||
* themselves out of the admin can fix it in a file they can still edit.
|
||||
*/
|
||||
public static function slug() {
|
||||
if ( defined( 'SIRIUS_PRESS_LOGIN_SLUG' ) ) {
|
||||
return self::sanitize( SIRIUS_PRESS_LOGIN_SLUG );
|
||||
}
|
||||
|
||||
return self::sanitize( get_option( self::OPT_SLUG, '' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* A slug is one path segment of lowercase letters, digits and hyphens.
|
||||
*
|
||||
* Anything else returns '', which turns the feature off rather than
|
||||
* producing a login URL nobody can reach.
|
||||
*/
|
||||
public static function sanitize( $value ) {
|
||||
$value = strtolower( trim( (string) $value ) );
|
||||
$value = trim( $value, "/ \t\n\r\0" );
|
||||
|
||||
if ( ! preg_match( '/^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/', $value ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Paths WordPress owns. Taking one of these would shadow something
|
||||
// that has to keep working.
|
||||
$reserved = array( 'wp-admin', 'wp-login', 'wp-login-php', 'wp-content', 'wp-includes', 'wp-json', 'feed', 'index-php' );
|
||||
if ( in_array( $value, $reserved, true ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** The login URL this site actually uses. */
|
||||
public static function url( $scheme = 'login' ) {
|
||||
$slug = self::slug();
|
||||
|
||||
if ( '' === $slug ) {
|
||||
return site_url( 'wp-login.php', $scheme );
|
||||
}
|
||||
|
||||
return set_url_scheme( home_url( '/' ) . $slug, $scheme );
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this request on the moved login path, and is wp-login.php being
|
||||
* refused?
|
||||
*
|
||||
* Decided on `plugins_loaded` because `$pagenow` has to be right before
|
||||
* anything that branches on it runs — which includes the cookie path
|
||||
* logic and every `is_login()` check in core and in plugins.
|
||||
*/
|
||||
public static function route() {
|
||||
global $pagenow;
|
||||
|
||||
$path = self::request_path();
|
||||
if ( null === $path ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $path === self::home_path() . '/' . self::slug() ) {
|
||||
self::$is_login = true;
|
||||
$pagenow = 'wp-login.php';
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $path === self::site_path() . '/wp-login.php' ) {
|
||||
self::$blocked = true;
|
||||
|
||||
/*
|
||||
* Point the request at something that cannot exist, so core
|
||||
* reaches its own 404 by the normal route. Answering 404 rather
|
||||
* than redirecting matters: a redirect to the new address would
|
||||
* hand the slug to the first scanner that asked.
|
||||
*/
|
||||
$_SERVER['REQUEST_URI'] = self::home_path() . '/' . str_repeat( '-/', 10 );
|
||||
$pagenow = 'index.php';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the login screen for the moved path.
|
||||
*
|
||||
* On `wp_loaded`, which is after plugins and the theme are up but before
|
||||
* the main query runs — the same point in the request wp-login.php would
|
||||
* have reached on its own. Requiring it is safe: everything it pulls in
|
||||
* is `require_once`, so the bootstrap does not run twice.
|
||||
*/
|
||||
public static function serve() {
|
||||
if ( self::$blocked ) {
|
||||
self::render_404();
|
||||
}
|
||||
|
||||
if ( ! self::$is_login ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $pagenow, $error, $interim_login, $action, $user_login, $user, $errors, $redirect_to;
|
||||
|
||||
require_once ABSPATH . 'wp-login.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse wp-login.php.
|
||||
*
|
||||
* This has to actively end the request rather than redirect it, and it
|
||||
* has to do so here. The web server sends `/wp-login.php` to that file
|
||||
* directly, not through index.php, so by the time `wp_loaded` fires we
|
||||
* are inside its `require wp-load.php` — and the moment that returns, the
|
||||
* login screen renders. Rewriting `$pagenow` earlier changes what
|
||||
* WordPress thinks the request is; it does not stop the script that is
|
||||
* already running.
|
||||
*
|
||||
* A 404 and not a redirect: sending callers to the new address would hand
|
||||
* the slug to the first scanner that asked for the old one.
|
||||
*/
|
||||
private static function render_404() {
|
||||
global $wp_query;
|
||||
|
||||
if ( ! defined( 'WP_USE_THEMES' ) ) {
|
||||
define( 'WP_USE_THEMES', true );
|
||||
}
|
||||
|
||||
// REQUEST_URI was pointed at a path that cannot exist, so the normal
|
||||
// query produces a normal 404 and the theme renders its own page.
|
||||
wp();
|
||||
|
||||
if ( $wp_query instanceof WP_Query ) {
|
||||
$wp_query->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 '</label></td></tr>';
|
||||
|
||||
echo '<tr><th>' . esc_html__( 'Login address', 'sirius-press' ) . '</th><td>';
|
||||
printf(
|
||||
'<code>%s</code> <input type="text" name="sirius_login_slug" value="%s" class="regular-text" placeholder="%s" autocomplete="off" spellcheck="false">',
|
||||
esc_html( trailingslashit( home_url( '/' ) ) ),
|
||||
esc_attr( SPA_Login_URL::slug() ),
|
||||
esc_attr__( 'wp-login.php', 'sirius-press' )
|
||||
);
|
||||
echo '<p class="description">';
|
||||
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 '</p>';
|
||||
if ( SPA_Login_URL::slug() ) {
|
||||
echo '<p class="description"><strong>';
|
||||
printf(
|
||||
/* translators: %s: the current login URL. */
|
||||
esc_html__( 'Signing in now happens at %s.', 'sirius-press' ),
|
||||
'<code>' . esc_html( home_url( '/' . SPA_Login_URL::slug() ) ) . '</code>'
|
||||
);
|
||||
echo '</strong> ';
|
||||
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 '</p>';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
|
||||
echo '</table>';
|
||||
submit_button();
|
||||
echo '</form>';
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue