` site-wide. * * The CMS subdomain is internal infrastructure - must never appear in search engines. * RankMath / Yoast users may already do this; we enforce it as a safety net. */ public function force_noindex_meta(): void { echo '' . "\n"; } /** * Filter wp_robots() output (used by WP core + RankMath/Yoast in some paths). * * @param array $robots Existing robots directives. * @return array */ public function force_noindex_robots( array $robots ): array { $robots['noindex'] = true; $robots['nofollow'] = true; $robots['noarchive'] = true; $robots['nosnippet'] = true; unset( $robots['index'], $robots['follow'] ); return $robots; } /** * Block REST API for non-authenticated users, except explicit public routes. * * Runs on rest_pre_dispatch (priority 5, before the route handler). By this * point WordPress has fully resolved authentication, so is_user_logged_in() * reflects a valid Application Password, cookie or any other method. * * Hatch frontends authenticate with Application Passwords for /wp/v2/*, but * several Hatch routes are public by design (comments, form submits, * WooCommerce store reads); those bypass this filter, otherwise the * visitor-facing features would answer 401 even though their own * permission_callback is open. * * @param mixed $result null (or pre-existing response) * @param WP_REST_Server $server the REST server * @param WP_REST_Request $request the incoming request * @return mixed */ public function block_rest_unauthenticated_dispatch( $result, $server, $request ) { // If someone else already short-circuited with a response, respect it. if ( null !== $result ) { return $result; } if ( is_user_logged_in() ) { return $result; } // Allow Hatch public routes (designed for anonymous visitors). $route = (string) $request->get_route(); $method = (string) $request->get_method(); // OPTIONS = CORS preflight - always allow. if ( 'OPTIONS' === strtoupper( $method ) ) { return $result; } // Hatch public routes (more reliable than path-string scan on REQUEST_URI). // Every route here ONLY exposes already-public data - published posts, // rendered SEO meta, public menus, etc. Adding routes here keeps the // REST-lock toggle safe to flip on without 404'ing the Astro frontend. $public_patterns = array( '#^/hatch/v1/comments$#', // Headless auth, storefront and order lookup. Each handler does its // own key, nonce or rate-limit check, and the frontend calls them // signed out. '#^/hatch/v1/auth/[a-z_\-]+$#', '#^/hatch/v1/store(/.+)?$#', '#^/hatch/v1/order(/.+)?$#', '#^/hatch/v1/design$#', '#^/hatch/v1/integrations$#', '#^/hatch/v1/forms$#', '#^/hatch/v1/forms/[a-z_]+/\d+(/submit)?$#', '#^/hatch/v1/menus(/.+)?$#', '#^/hatch/v1/features$#', '#^/hatch/v1/seo-head$#', '#^/hatch/v1/schema$#', '#^/hatch/v1/redirects$#', '#^/hatch/v1/code-snippets$#', '#^/hatch/v1/seo-meta$#', // Universal post/page/CPT resolver - `route_content_by_slug` only // returns post_status=publish. The Astro frontend hits this for // every render; without it in the allowlist, REST-lock=ON 404s // every page on the headless site. '#^/hatch/v1/content$#', // Posts block list - published-only content with filter args. '#^/hatch/v1/content/list$#', // Block tree (used by per-block Astro components). The handler // enforces context=edit auth internally; context=view is public. '#^/hatch/v1/post/\d+/blocks$#', ); foreach ( $public_patterns as $re ) { if ( preg_match( $re, $route ) ) { return $result; } } return new WP_Error( 'hatch_rest_not_logged_in', __( 'REST API restricted to authenticated users.', 'hatch-connect' ), array( 'status' => 401 ) ); } /** * Selective override for wp_is_application_passwords_available. * * WordPress turns Application Passwords off on plain HTTP because Basic * authentication would send the password in clear text. Hatch keeps that * rule: it never enables them over a real network connection without TLS. * * Behaviour: * - Already available (HTTPS, or a "local" environment): passthrough. * - Not HTTPS, environment type "local" or "development", REST request * carrying a Basic auth header: return true, so a local Docker or * wp-env site behind an HTTP-only address still authenticates. * - Everything else: passthrough. A site behind a TLS-terminating proxy * that PHP cannot see should tell WordPress about it (set * $_SERVER['HTTPS'] from X-Forwarded-Proto in wp-config.php) instead * of relying on this filter. * * @param bool $is_available WP's default (false on non-HTTPS). * @return bool */ public function enable_app_passwords_for_rest_basic_auth( $is_available ): bool { if ( $is_available || is_ssl() || ! in_array( wp_get_environment_type(), array( 'local', 'development' ), true ) ) { return (bool) $is_available; } $is_rest = ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || false !== strpos( Hatch_Request::request_uri(), '/wp-json/' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only checks that Basic auth credentials are present. $has_basic_auth = ! empty( $_SERVER['PHP_AUTH_USER'] ) && ! empty( $_SERVER['PHP_AUTH_PW'] ); return $is_rest && $has_basic_auth; } /** * Allow ALL user endpoints to remain registered - removing them breaks * the WP admin Users page (it loads the list via REST) AND breaks _embed * author payloads on posts. Instead, anonymous requests to the LIST are * blocked via rest_pre_dispatch (enum protection) while authenticated * admins go through normally. * * v0.46 - Was removing /wp/v2/users entirely → admin Users page showed * empty list / spinner forever. Switched to a runtime auth check. * * @param array $endpoints Existing REST endpoints. * @return array */ public function block_users_endpoint( array $endpoints ): array { // No removals. See block_users_list_for_anon for the actual gate. return $endpoints; } /** * Block anonymous user LIST requests (the enum vector). Authenticated * users see the list as they always have. Individual user reads stay * fully open since WP core only exposes public byline fields there. * * @param mixed $result Existing dispatch result. * @param WP_REST_Server $server REST server. * @param WP_REST_Request $request Current request. * @return mixed */ public function block_users_list_for_anon( $result, $server, $request ) { unset( $server ); if ( null !== $result ) { return $result; } if ( is_user_logged_in() ) { return $result; } $route = $request instanceof WP_REST_Request ? (string) $request->get_route() : ''; if ( '/wp/v2/users' === $route && 'GET' === $request->get_method() ) { return new WP_Error( 'hatch_users_list_protected', __( 'User listing requires authentication.', 'hatch-connect' ), array( 'status' => 401 ) ); } return $result; } /** * Remove pingback HTTP header. * * @param array $headers Headers being sent. * @return array */ public function remove_xmlrpc_pingback_header( array $headers ): array { unset( $headers['X-Pingback'] ); return $headers; } /** * Block ?author=N enumeration on the classic WP frontend. * * Returns a hard 404 (matching the admin label) so scanners see a dead URL * rather than a redirect they could follow back to find the user index. * * v0.7.7 hotfix: skip REST API requests. WP does not run the classic * `?author=N` -> `/author//` redirect on REST endpoints, so the * enumeration vector this hook exists for does not apply there. Leaving * the block on REST silently 404'd every headless author archive * (`/wp/v2/posts?author=` and `/hatch/v1/content/list?author=`), * even when the caller sent a valid Application Password. The `/wp/v2/users` * endpoint remains stripped by `remove_users_endpoint`, so username-based * enumeration is still blocked; ID-based post filtering is now permitted * for headless clients that already know the id. */ public function block_user_enumeration(): void { if ( is_admin() ) { return; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return; } $req_uri = Hatch_Request::request_uri(); if ( '' !== $req_uri ) { $path = (string) wp_parse_url( $req_uri, PHP_URL_PATH ); if ( false !== strpos( $path, '/wp-json/' ) ) { return; } } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only inspection of the current request; no state change. if ( isset( $_GET['rest_route'] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only inspection of the current request; no state change. if ( isset( $_GET['author'] ) ) { status_header( 404 ); nocache_headers(); wp_die( '', '', array( 'response' => 404 ) ); } } /** * Remove /wp-json/wp/v2/users from the REST API surface entirely. * * Independent of the REST-lock toggle so the "stops credential-stuffing * recon" promise on the Hide-usernames toggle is true unconditionally. */ public function remove_users_endpoint( array $endpoints ): array { foreach ( array( '/wp/v2/users', '/wp/v2/users/(?P[\d]+)' ) as $route ) { if ( isset( $endpoints[ $route ] ) ) { unset( $endpoints[ $route ] ); } } return $endpoints; } /** * Hard-403 any request to /xmlrpc.php so scanners get a dead endpoint. * * `xmlrpc_enabled => false` only blocks method dispatch; the endpoint * itself still responds 200 with a method-list message. This makes the * endpoint return 403 to anyone, matching the admin label. */ public function block_xmlrpc_endpoint(): void { $req = Hatch_Request::request_uri(); if ( '' === $req ) { return; } $path = wp_parse_url( $req, PHP_URL_PATH ); if ( ! is_string( $path ) ) { return; } // Match `/xmlrpc.php` exactly (case-insensitive) - not anything that // happens to contain the string as a substring. if ( preg_match( '#^/xmlrpc\.php/?$#i', $path ) ) { status_header( 403 ); nocache_headers(); wp_die( '', '', array( 'response' => 403 ) ); } } /** * Inject `Disallow: /` into the dynamic robots.txt when the noindex toggle * is on, matching the admin label ("Disallow robots.txt"). * * @param string $output Existing robots.txt body. * @return string */ public function force_disallow_robots_txt( string $output ): string { // If WP is already discouraging (search-engine-visibility off), let WP's // default Disallow stand. Otherwise replace any User-agent: * block. $replacement = "User-agent: *\nDisallow: /\n"; // Strip any prior `User-agent: *` block so ours wins. $cleaned = preg_replace( '/User-agent:\s*\*\s*\n(?:[^\n]*\n)*/i', '', (string) $output ); return trim( $replacement . "\n" . (string) $cleaned ) . "\n"; } }