373 lines
8.9 KiB
PHP
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;
|
|
}
|
|
}
|
|
}
|