` 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";
}
}