/`, so the Location header * converts a user ID into a login name. Counting upward from 1 enumerates the site. * 3. `/wp-sitemap-users-1.xml` lists author archive URLs, which contain the same slugs. * * The fourth route, login error messages distinguishing "unknown username" from "wrong * password", was already closed by CSDT_Login::generic_login_errors(). This class deliberately * does not touch it; one owner per behaviour. * * ── Scoped to UNAUTHENTICATED requests only ── * * Every guard here checks `is_user_logged_in()` first. A signed-in editor needs the user list * to assign an author, the block editor calls `/wp/v2/users` to populate that control, and * breaking either would be a far worse outcome than the leak. So the rule is narrow: anonymous * callers get nothing, authenticated ones are unaffected. * * ── Why the numeric form only ── * * `/?author=1` is guarded; `/author/some-name/` is not. The first converts an ID an attacker * guesses into a name they did not know. The second requires already knowing the name, and on * many sites it is a legitimate published archive. Blocking both would break author pages to * prevent nothing. * * @package CloudScale_Cyber_DevTools * @since 1.10.500 */ defined( 'ABSPATH' ) || exit; class CSDT_Enum_Guard { /** * Whether the guard is active. * * Default ON. Unusually for a setting that changes public behaviour, but the exposure is * a real one, the scope is anonymous-only, and a security plugin that ships this switched * off is a security plugin whose users are still exposed. The Explain modal names the one * integration pattern it can break. */ const OPTION_ENABLED = 'csdt_devtools_enum_guard'; /** Registers the guards. */ public static function init(): void { // Registered unconditionally and gated at call time. Deciding here would mean a // setting change mid-request installs nothing, the fault that left the 2FA // write-veto absent on the one request that mattered. add_filter( 'rest_endpoints', array( __CLASS__, 'protect_rest_users' ) ); add_action( 'template_redirect', array( __CLASS__, 'block_author_id_probe' ), 0 ); add_filter( 'wp_sitemaps_add_provider', array( __CLASS__, 'drop_users_sitemap' ), 10, 2 ); } /** Whether the guard should act on this request. */ public static function is_active(): bool { if ( '1' !== (string) get_option( self::OPTION_ENABLED, '1' ) ) { return false; } // Off only for a user who can already see the user list. "Signed in" was the // old test, and it exempted a self-registered subscriber, who then read every // username from /wp-json/wp/v2/users and the users sitemap. The capability // is the question; the cookie is not. if ( function_exists( 'current_user_can' ) && current_user_can( 'list_users' ) ) { return false; } return true; } /** * Requires a capability to read the REST users collection. * * The route is left registered and its permission callback replaced, rather than the route * being removed. Removing it answers 404, which tells a scanner the endpoint was * deliberately hidden and is a different shape from every other WordPress install; * replacing the callback answers 401 with WordPress's own message, which is * indistinguishable from a site where the accounts simply are not public. * * Single-user routes (`/users/1`) are covered too: those answer with the same slug. * * @since 1.10.500 * @param array $endpoints REST endpoint map. * @return array */ public static function protect_rest_users( $endpoints ) { if ( ! is_array( $endpoints ) ) { return $endpoints; } // NOT gated on is_active() here: rest_endpoints fires once and is cached by WordPress // for the request, but more importantly the capability check below is what decides, and // it is evaluated per request at dispatch time. Gating the whole filter on a // logged-in check would bake one request's answer into the route map. foreach ( array( '/wp/v2/users', '/wp/v2/users/(?P[\d]+)' ) as $route ) { if ( ! isset( $endpoints[ $route ] ) || ! is_array( $endpoints[ $route ] ) ) { continue; } foreach ( $endpoints[ $route ] as $i => $handler ) { if ( ! is_array( $handler ) ) { continue; } $methods = (string) ( $handler['methods'] ?? '' ); // Reads only. Writes already require a capability, and replacing their callback // would risk loosening something rather than tightening it. if ( false === strpos( $methods, 'GET' ) ) { continue; } $original = $handler['permission_callback'] ?? null; $endpoints[ $route ][ $i ]['permission_callback'] = static function ( $request ) use ( $original ) { if ( self::is_active() ) { return new WP_Error( 'rest_user_cannot_view', __( 'Sorry, you are not allowed to list users.', 'cloudscale-cyber-devtools' ), array( 'status' => 401 ) ); } // Otherwise defer to whatever WordPress (or another plugin) had here, so // this guard cannot accidentally GRANT access that was previously refused. if ( is_callable( $original ) ) { return $original( $request ); } return true; }; } } return $endpoints; } /** * Refuses the `?author=` probe. * * WordPress answers it with a 301 to `/author//`, which is the enumeration: the * attacker supplies a number and the Location header returns a name. * * Answered as a 404 rather than a redirect or a 403. A 403 confirms that something is * protecting the URL, and a redirect to the home page is a distinctive fingerprint; a 404 * is what a site with no author archives looks like, which is the least informative * response available. * * @since 1.10.500 */ public static function block_author_id_probe(): void { try { if ( ! self::is_active() ) { return; } if ( ! function_exists( 'get_query_var' ) ) { return; } // `author` holds the numeric form; `author_name` holds the slug form, which is a // legitimate published archive and is deliberately left alone. $author = get_query_var( 'author' ); if ( '' === $author || null === $author ) { return; } if ( ! is_numeric( $author ) ) { return; } // A request that already resolved to a name did not need the ID translated, so // nothing was disclosed. if ( '' !== (string) get_query_var( 'author_name' ) ) { return; } global $wp_query; if ( is_object( $wp_query ) ) { $wp_query->set_404(); } if ( function_exists( 'status_header' ) ) { status_header( 404 ); } if ( function_exists( 'nocache_headers' ) ) { nocache_headers(); } } catch ( \Throwable $e ) { // Never break a page over this. A leak is bad; a white screen is worse. if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Removes the users provider from the sitemap index. * * `/wp-sitemap-users-1.xml` lists an author archive URL per account, each containing the * login slug, the same disclosure as the REST endpoint, by a route nobody thinks to check. * * @since 1.10.500 * @param mixed $provider Sitemap provider. * @param string $name Provider name. * @return mixed False to drop it. */ public static function drop_users_sitemap( $provider, $name = '' ) { if ( 'users' === $name && self::is_active() ) { return false; } return $provider; } /** * What this site currently discloses, for the panel. * * Reported as state rather than as a score. Each line is a route an attacker uses, and * whether it is open right now. * * @since 1.10.500 * @return array Route label => true when closed. */ public static function status(): array { $on = '1' === (string) get_option( self::OPTION_ENABLED, '1' ); return array( 'rest_users' => $on, 'author_id' => $on, 'users_sitemap' => $on, // Owned by CSDT_Login, not by this class. Shown here because this is where a // reader looks for it, and a panel that omits it implies the gap is open. 'login_errors' => class_exists( 'CSDT_Login' ) && is_callable( array( 'CSDT_Login', 'generic_login_errors' ) ), ); } // ── Admin UI ───────────────────────────────────────────────────────── /** Renders the Account Disclosure panel. */ public static function render_panel(): void { $on = '1' === (string) get_option( self::OPTION_ENABLED, '1' ); ?>
🕵️ 'Why a login name matters', 'rec' => 'Read this first', 'html' => 'A login name is half a credential. Every brute-force tool starts by collecting them, and a correct name turns an impossible guess into a plausible one, which is why the failed-login counts on this site are all against admin and a handful of other common names.

WordPress gives them away by default, and it does so through several unrelated routes. Closing one is the usual mistake.', ), array( 'name' => 'The four routes, and who closes each', 'rec' => 'How it works', 'html' => '1. /wp-json/wp/v2/users returns every account\'s slug to anyone, signed in or not. Now answered 401 for anonymous callers, using WordPress\'s own wording so the response does not advertise which plugin refused it.

2. /?author=1 answers with a redirect to /author/<login>/, so the address bar converts a number into a name, and counting upward enumerates the site. Now a 404, which is what a site with no author archives looks like.

3. /wp-sitemap-users-1.xml lists an author archive URL per account, containing the same names. The users section is now left out of the sitemap.

4. Login error messages. "Unknown username" versus "the password you entered is incorrect" tells an attacker which half they got right. That one was already closed, by the Brute Force panel, it is listed here because this is where you would look for it.', ), array( 'name' => 'What it does NOT block, on purpose', 'rec' => 'Honest limits', 'html' => 'A named author archive, /author/some-name/, is left working. Reaching it requires already knowing the name, so it discloses nothing new, and on many sites it is a published page with real traffic. Blocking it would break author pages to prevent nothing.

Signed-in users are never affected. The block editor asks /wp/v2/users for the list that fills the author dropdown, and breaking that would be a worse outcome than the leak.', ), array( 'name' => 'The one thing this can break', 'rec' => 'Before you enable it', 'html' => 'An external tool that reads /wp-json/wp/v2/users without authenticating will start receiving 401. That is rare, and a tool doing it is relying on the very exposure this closes, but if you have a headless front end or a directory integration that lists authors anonymously, it will need an application password instead.

Everything else, sitemaps for posts and pages, author archives reached by name, the REST API for content, is unaffected.', ), ) ); ?>
__( '/wp-json/wp/v2/users: lists every account slug', 'cloudscale-cyber-devtools' ), 'author_id' => __( '/?author=1: turns a user ID into a login name', 'cloudscale-cyber-devtools' ), 'users_sitemap' => __( '/wp-sitemap-users-1.xml: lists author archives', 'cloudscale-cyber-devtools' ), 'login_errors' => __( 'Login errors: "unknown username" vs "wrong password"', 'cloudscale-cyber-devtools' ), ); foreach ( self::status() as $route => $closed ) : ?>
$on ? __( 'Saved. Anonymous requests for account names are refused.', 'cloudscale-cyber-devtools' ) : __( 'Saved. Your account names are public again.', 'cloudscale-cyber-devtools' ), ) ); } }