robotstxt-telemetry/includes/class-robotstxt-telemetry-plugin-guards.php
2026-08-18 19:06:22 +00:00

373 lines
8.9 KiB
PHP

<?php
/**
* Per-plugin safe-mode enforcement.
*
* @package RobotstxtTelemetry
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'Robotstxt_Telemetry_Plugin_Guards' ) ) {
/**
* Class Robotstxt_Telemetry_Plugin_Guards
*
* Applies the per-plugin Safe profiles: telemetry endpoints are blocked
* with a mock response, and environment parameters are stripped from the
* requests of the plugins running in Safe mode. Requests are attributed
* to a plugin through the caller backtrace.
*/
class Robotstxt_Telemetry_Plugin_Guards {
/**
* Re-entry guard for the proxy pattern.
*
* @var bool
*/
private static $busy = false;
/**
* Register hooks.
*/
public function register() {
add_filter( 'pre_http_request', array( $this, 'apply_guards' ), 4, 3 );
}
/**
* Apply the Safe profiles to an outbound request.
*
* @param false|array|WP_Error $preempt Preempt value.
* @param array $args Request arguments.
* @param string $url Request URL.
* @return false|array|WP_Error
*/
public function apply_guards( $preempt, $args, $url ) {
if ( false !== $preempt || self::$busy ) {
return $preempt;
}
$parsed = wp_parse_url( $url );
if ( empty( $parsed['host'] ) ) {
return $preempt;
}
$candidates = $this->match_profiles( $parsed['host'], isset( $parsed['path'] ) ? $parsed['path'] : '' );
if ( empty( $candidates ) ) {
return $preempt;
}
$slug = $this->find_calling_plugin( array_keys( $candidates ) );
if ( '' === $slug ) {
return $preempt;
}
$modified = false;
foreach ( $candidates[ $slug ] as $matcher ) {
$action = isset( $matcher['do'] ) ? $matcher['do'] : '';
if ( 'block' === $action ) {
return $this->mock_response( isset( $matcher['mock'] ) ? $matcher['mock'] : '{}' );
}
if ( 'strip_query' === $action ) {
$new_url = $this->strip_query_keys( $url, $matcher['keys'] );
if ( $new_url !== $url ) {
$url = $new_url;
$modified = true;
}
continue;
}
if ( 'strip_body' === $action ) {
$new_body = $this->strip_body_keys( $args, $matcher['keys'] );
if ( null !== $new_body ) {
$args['body'] = $new_body;
$modified = true;
}
continue;
}
if ( 'strip_headers' === $action ) {
$new_headers = $this->strip_header_keys( $args, $matcher['headers'] );
if ( null !== $new_headers ) {
$args['headers'] = $new_headers;
$modified = true;
}
continue;
}
if ( 'strip_ua' === $action ) {
$new_ua = $this->strip_user_agent( $args );
if ( null !== $new_ua ) {
$args['user-agent'] = $new_ua;
$modified = true;
}
continue;
}
}
if ( ! $modified ) {
return $preempt;
}
self::$busy = true;
$response = wp_remote_request( $url, $args );
self::$busy = false;
return $response;
}
/**
* Find the profiles whose matchers apply to this host and path.
*
* @param string $host Request host.
* @param string $path Request path.
* @return array Slug => list of matching matchers.
*/
private function match_profiles( $host, $path ) {
$candidates = array();
$profiles = Robotstxt_Telemetry_Plugin_Profiles::get_profiles();
foreach ( $profiles as $slug => $profile ) {
if ( 'safe' !== Robotstxt_Telemetry_Network::get_plugin_mode( $slug ) ) {
continue;
}
foreach ( $profile['matchers'] as $matcher ) {
if ( ! $this->host_matches( $host, $matcher ) ) {
continue;
}
if ( isset( $matcher['path'] ) && ! preg_match( $matcher['path'], $path ) ) {
continue;
}
$candidates[ $slug ][] = $matcher;
}
}
return $candidates;
}
/**
* Whether a matcher applies to the request host.
*
* Matchers without a host restriction (path-only) apply to any host;
* the caller attribution keeps them scoped to their own plugin.
*
* @param string $host Request host.
* @param array $matcher Matcher.
* @return bool
*/
private function host_matches( $host, $matcher ) {
if ( isset( $matcher['host'] ) ) {
return strtolower( $host ) === strtolower( $matcher['host'] );
}
if ( isset( $matcher['host_contains'] ) ) {
return false !== strpos( strtolower( $host ), strtolower( $matcher['host_contains'] ) );
}
return true;
}
/**
* Find which of the candidate plugins issued the request.
*
* @param array $slugs Candidate plugin slugs.
* @return string Plugin slug or an empty string.
*/
private function find_calling_plugin( $slugs ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- File paths are required to attribute the request to its calling plugin.
$backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS );
foreach ( $backtrace as $frame ) {
if ( empty( $frame['file'] ) || ! is_string( $frame['file'] ) ) {
continue;
}
$file = str_replace( '\\', '/', $frame['file'] );
foreach ( $slugs as $slug ) {
if ( false !== strpos( $file, '/plugins/' . $slug . '/' ) ) {
return $slug;
}
}
}
return '';
}
/**
* Build a mock response for a blocked endpoint.
*
* @param string $body Mock body.
* @return array
*/
private function mock_response( $body ) {
return array(
'headers' => array(),
'body' => $body,
'response' => array(
'code' => 200,
'message' => 'OK',
),
'cookies' => array(),
'filename' => '',
);
}
/**
* Remove keys from the URL query string.
*
* @param string $url Request URL.
* @param array $keys Keys to remove.
* @return string
*/
private function strip_query_keys( $url, $keys ) {
$parts = explode( '?', $url, 2 );
if ( count( $parts ) < 2 ) {
return $url;
}
$query = wp_parse_args( $parts[1] );
$lower = array_map( 'strtolower', array_values( $keys ) );
$kept = array();
foreach ( $query as $key => $value ) {
if ( ! in_array( strtolower( (string) $key ), $lower, true ) ) {
$kept[ $key ] = $value;
}
}
$rebuilt = http_build_query( $kept );
return '' === $rebuilt ? $parts[0] : $parts[0] . '?' . $rebuilt;
}
/**
* Remove keys from the request body.
*
* Returns null when nothing changed.
*
* @param array $args Request arguments.
* @param array $keys Keys to remove.
* @return mixed|null
*/
private function strip_body_keys( $args, $keys ) {
if ( ! isset( $args['body'] ) ) {
return null;
}
$body = $args['body'];
if ( is_array( $body ) ) {
$stripped = $this->remove_keys( $body, $keys );
return $stripped === $body ? null : $stripped;
}
if ( ! is_string( $body ) || '' === $body ) {
return null;
}
$decoded = json_decode( $body, true );
if ( is_array( $decoded ) ) {
$stripped = $this->remove_keys( $decoded, $keys );
if ( $stripped === $decoded ) {
return null;
}
return wp_json_encode( $stripped );
}
$parsed = wp_parse_args( $body );
if ( empty( $parsed ) ) {
return null;
}
$stripped = $this->remove_keys( $parsed, $keys );
if ( $stripped === $parsed ) {
return null;
}
return http_build_query( $stripped );
}
/**
* Remove keys from an array (case-insensitive).
*
* @param array $data Source array.
* @param array $keys Keys to remove.
* @return array
*/
private function remove_keys( $data, $keys ) {
$lower = array_map( 'strtolower', array_values( $keys ) );
$kept = array();
foreach ( $data as $key => $value ) {
if ( in_array( strtolower( (string) $key ), $lower, true ) ) {
continue;
}
$kept[ $key ] = $value;
}
return $kept;
}
/**
* Remove headers from the request arguments.
*
* @param array $args Request arguments.
* @param array $headers Header names to remove.
* @return array|null
*/
private function strip_header_keys( $args, $headers ) {
if ( ! isset( $args['headers'] ) || ! is_array( $args['headers'] ) ) {
return null;
}
$lower = array_map( 'strtolower', array_values( $headers ) );
$kept = array();
$found = false;
foreach ( $args['headers'] as $name => $value ) {
if ( in_array( strtolower( (string) $name ), $lower, true ) ) {
$found = true;
continue;
}
$kept[ $name ] = $value;
}
return $found ? $kept : null;
}
/**
* Reduce a WordPress User-Agent to "WordPress/<version>".
*
* Returns null when there is nothing to strip.
*
* @param array $args Request arguments.
* @return string|null
*/
private function strip_user_agent( $args ) {
if ( ! isset( $args['user-agent'] ) || ! is_string( $args['user-agent'] ) ) {
return null;
}
$ua = $args['user-agent'];
if ( preg_match( '#^([^;]+)#', $ua, $matches ) ) {
$trimmed = preg_replace( '#/\d+$#', '', $matches[1] );
if ( $trimmed !== $ua ) {
return $trimmed;
}
}
return null;
}
}
}