| 1 |
<?php |
| 2 |
|
| 3 |
namespace Leadin; |
| 4 |
|
| 5 |
use Leadin\data\Filters; |
| 6 |
use Leadin\data\Portal_Options; |
| 7 |
use Leadin\utils\ProxyUtils; |
| 8 |
|
| 9 |
/** |
| 10 |
* Class responsible for proxy mappings. |
| 11 |
*/ |
| 12 |
class Proxy_Mappings { |
| 13 |
|
| 14 |
const PROXY_MAPS_CACHE_TTL_FILTER = 'proxy_maps_cache_ttl'; |
| 15 |
const PROXY_MAPS_CACHE_TTL = 1800; |
| 16 |
const PREDEFINED_PATH_PATTERNS = array( |
| 17 |
'~^/_hcms/.*$~', |
| 18 |
'~^/hs/.*$~', |
| 19 |
'~^/hubfs/.*$~', |
| 20 |
'~^/hs-fs/.*$~', |
| 21 |
'~^/cs/c/.*$~', |
| 22 |
'~^/e3t/.*$~', |
| 23 |
'~^/events/public/v1/.*$~', |
| 24 |
); |
| 25 |
|
| 26 |
/** |
| 27 |
* Proxy_Mappings constructor, register callback for template redirect and scheduler. |
| 28 |
*/ |
| 29 |
public function __construct() { |
| 30 |
add_action( 'init', array( $this, 'register_custom_schedule' ) ); |
| 31 |
add_action( 'template_redirect', array( $this, 'proxy_requests' ) ); |
| 32 |
add_action( 'wp', array( $this, 'schedule_and_fetch_mapping_update' ) ); |
| 33 |
add_action( 'leadin_update_proxy_mappings', array( $this, 'fetch_and_cache_mappings' ) ); |
| 34 |
add_action( 'leadin_reset_wp_mappings_cache', array( $this, 'refetch_proxy_mapping' ) ); |
| 35 |
} |
| 36 |
|
| 37 |
/** |
| 38 |
* Registers the custom cron schedule which schedules and fetches the mapping update |
| 39 |
* |
| 40 |
* @return void |
| 41 |
*/ |
| 42 |
public function register_custom_schedule() { |
| 43 |
add_filter( |
| 44 |
'cron_schedules', |
| 45 |
function( $schedules ) { |
| 46 |
$schedules[ self::PROXY_MAPS_CACHE_TTL_FILTER ] = array( |
| 47 |
'interval' => 1800, |
| 48 |
'display' => __( 'Fetch Proxy Maps Schedule', 'leadin' ), |
| 49 |
); |
| 50 |
return $schedules; |
| 51 |
} |
| 52 |
); |
| 53 |
} |
| 54 |
|
| 55 |
/** |
| 56 |
* Fetches proxy mappings from a remote API and caches them. |
| 57 |
* |
| 58 |
* This function retrieves the portal ID and uses it to fetch proxy mappings |
| 59 |
* from a specified API endpoint. The fetched mappings are then cached for |
| 60 |
* a predefined duration. If the portal ID is empty or an error occurs during |
| 61 |
* the fetch process, appropriate error messages are logged. |
| 62 |
* |
| 63 |
* @return void |
| 64 |
*/ |
| 65 |
public function fetch_and_cache_mappings() { |
| 66 |
if ( empty( Portal_Options::get_portal_id() ) ) { |
| 67 |
ProxyUtils::error_log( 'Portal ID is empty. Skipping fetching mappings.' ); |
| 68 |
return; |
| 69 |
} |
| 70 |
|
| 71 |
$json_url = ProxyUtils::get_plugin_mappings_api_url(); |
| 72 |
ProxyUtils::info_log( "Fetching mappings from: $json_url" ); |
| 73 |
|
| 74 |
$response = wp_remote_get( |
| 75 |
$json_url, |
| 76 |
array( |
| 77 |
'headers' => array( |
| 78 |
'Content-Type' => 'application/json', |
| 79 |
'Accept' => 'application/json', |
| 80 |
), |
| 81 |
'body' => array( 'portalId' => Portal_Options::get_portal_id() ), |
| 82 |
) |
| 83 |
); |
| 84 |
|
| 85 |
if ( is_wp_error( $response ) ) { |
| 86 |
ProxyUtils::error_log( 'Error fetching JSON mappings: ' . $response->get_error_message() ); |
| 87 |
return; |
| 88 |
} |
| 89 |
|
| 90 |
$mappings = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 91 |
|
| 92 |
if ( is_array( $mappings ) ) { |
| 93 |
set_transient( 'proxy_mappings', $mappings, self::PROXY_MAPS_CACHE_TTL ); |
| 94 |
ProxyUtils::info_log( 'Mappings cached successfully.' ); |
| 95 |
} else { |
| 96 |
ProxyUtils::error_log( 'Invalid JSON format for proxy mappings.' ); |
| 97 |
} |
| 98 |
} |
| 99 |
|
| 100 |
/** |
| 101 |
* Refetches the proxy mappings. |
| 102 |
* |
| 103 |
* This function is responsible for refetching the proxy mappings. It is |
| 104 |
* called when the mappings need to be updated, such as when the mappings |
| 105 |
* are disabled or when the mappings are reset. |
| 106 |
* |
| 107 |
* @return void |
| 108 |
*/ |
| 109 |
public function refetch_proxy_mapping() { |
| 110 |
$this->schedule_and_fetch_mapping_update( true ); |
| 111 |
} |
| 112 |
|
| 113 |
/** |
| 114 |
* Schedules and fetches the mapping update. |
| 115 |
* |
| 116 |
* This function is responsible for scheduling and fetching the mapping |
| 117 |
* update. It is called when the mappings need to be updated, such as when |
| 118 |
* the mappings are disabled or when the mappings are reset. |
| 119 |
* |
| 120 |
* @param bool $force_fetch Whether to force the fetch. |
| 121 |
* |
| 122 |
* @return void |
| 123 |
*/ |
| 124 |
public function schedule_and_fetch_mapping_update( $force_fetch = false ) { |
| 125 |
if ( ! Portal_Options::get_proxy_mappings_enabled() ) { |
| 126 |
return; |
| 127 |
} |
| 128 |
|
| 129 |
if ( $force_fetch ) { |
| 130 |
$this->fetch_and_cache_mappings(); |
| 131 |
} |
| 132 |
|
| 133 |
if ( !wp_next_scheduled( 'leadin_update_proxy_mappings' ) ) { |
| 134 |
$this->fetch_and_cache_mappings(); |
| 135 |
wp_schedule_event( time() + self::PROXY_MAPS_CACHE_TTL, self::PROXY_MAPS_CACHE_TTL_FILTER, 'leadin_update_proxy_mappings' ); |
| 136 |
ProxyUtils::info_log( 'Scheduled mapping update event.' ); |
| 137 |
} |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Proxies the requests. |
| 142 |
* |
| 143 |
* This function is responsible for proxying the requests. It retrieves the |
| 144 |
* HTTP host and request URI from the server, and then uses these values to |
| 145 |
* determine the proxy path. If a proxy path is found, the request is proxied |
| 146 |
* to the target URL. If no proxy path is found, a message is logged. |
| 147 |
* |
| 148 |
* @return void |
| 149 |
*/ |
| 150 |
public function proxy_requests() { |
| 151 |
if ( ! Portal_Options::get_proxy_mappings_enabled() ) { |
| 152 |
ProxyUtils::info_log( 'Proxy is not enabled.' ); |
| 153 |
return; |
| 154 |
} |
| 155 |
|
| 156 |
$http_host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; |
| 157 |
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; |
| 158 |
|
| 159 |
$proxy_path = $this->get_proxy_path( $http_host, $request_uri ); |
| 160 |
if ( is_null( $proxy_path ) ) { |
| 161 |
ProxyUtils::info_log( "No hubspot mapping found for the url: $request_uri" ); |
| 162 |
return; |
| 163 |
} |
| 164 |
|
| 165 |
$target_url = ProxyUtils::get_proxy_base_url() . $proxy_path; |
| 166 |
|
| 167 |
$remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; |
| 168 |
|
| 169 |
$original_headers = $this->get_request_headers(); |
| 170 |
|
| 171 |
$headers = array_merge( |
| 172 |
$original_headers, |
| 173 |
array( |
| 174 |
'X-HS-Public-Host' => ProxyUtils::get_destination_domain(), |
| 175 |
'X-Forwarded-For' => ( ! empty( ProxyUtils::get_client_ip() ) ? ProxyUtils::get_client_ip() . ', ' : '' ) . $remote_addr, |
| 176 |
'X-HubSpot-Trust-Forwarded-For' => 'true', |
| 177 |
// wp_remote_get blocks if the connection is left open |
| 178 |
'Connection' => 'close' |
| 179 |
) |
| 180 |
); |
| 181 |
|
| 182 |
$headers = $this->strip_headers( $headers ); |
| 183 |
|
| 184 |
if ( isset( $headers['Cookie'] ) ) { |
| 185 |
$headers['Cookie'] = $this->filter_wordpress_cookies( $headers['Cookie'] ); |
| 186 |
if ( empty( $headers['Cookie'] ) ) { |
| 187 |
unset( $headers['Cookie'] ); |
| 188 |
} |
| 189 |
} |
| 190 |
|
| 191 |
$args = array( |
| 192 |
'headers' => $headers, |
| 193 |
); |
| 194 |
|
| 195 |
ProxyUtils::info_log( "Proxying request to: $target_url" ); |
| 196 |
|
| 197 |
$response = wp_remote_get( $target_url, $args ); |
| 198 |
|
| 199 |
if ( is_wp_error( $response ) ) { |
| 200 |
ProxyUtils::error_log( 'Error retrieving content: ' . $response->get_error_message() ); |
| 201 |
wp_die( 'Error retrieving content.' ); |
| 202 |
} |
| 203 |
|
| 204 |
$body = wp_remote_retrieve_body( $response ); |
| 205 |
$http_code = wp_remote_retrieve_response_code( $response ); |
| 206 |
$response_headers = wp_remote_retrieve_headers( $response ); |
| 207 |
|
| 208 |
foreach ( $this->build_passthrough_headers( $response_headers ) as $passthrough_header ) { |
| 209 |
header( $passthrough_header['name'] . ': ' . $passthrough_header['value'], $passthrough_header['replace'] ); |
| 210 |
} |
| 211 |
|
| 212 |
status_header( $http_code ); |
| 213 |
header( 'X-HS-WP-Plugin-Proxy-URL: ' . $target_url ); |
| 214 |
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 215 |
echo $body; |
| 216 |
exit; |
| 217 |
} |
| 218 |
|
| 219 |
/** |
| 220 |
* Gets the proxy path. |
| 221 |
* |
| 222 |
* This function is responsible for getting the proxy path. It retrieves the |
| 223 |
* mappings from the cache and then iterates over the mappings to find the |
| 224 |
* matching domain and path. If a match is found, the new path is returned. |
| 225 |
* |
| 226 |
* @param string $current_domain The current domain. |
| 227 |
* @param string $request_uri The request URI. |
| 228 |
* |
| 229 |
* @return string|null The new path. |
| 230 |
*/ |
| 231 |
private function get_proxy_path( $current_domain, $request_uri ) { |
| 232 |
if ( $this->is_predefined_path( $request_uri ) === true ) { |
| 233 |
ProxyUtils::info_log( 'Predefined path: ' . $request_uri ); |
| 234 |
return $request_uri; |
| 235 |
} |
| 236 |
|
| 237 |
$mappings = $this->get_cached_mappings(); |
| 238 |
|
| 239 |
if ( is_array( $mappings ) && ! empty( $mappings ) ) { |
| 240 |
foreach ( $mappings as $mapping ) { |
| 241 |
ProxyUtils::info_log( 'Mapping: ' . json_encode( $mapping ) ); |
| 242 |
$wp_path = rtrim( $mapping['wp_path'], '/' ); |
| 243 |
$hs_path = rtrim( $mapping['hs_path'], '/' ); |
| 244 |
$domain = $mapping['domain']; |
| 245 |
|
| 246 |
if ( $current_domain !== $domain ) { |
| 247 |
continue; |
| 248 |
} |
| 249 |
|
| 250 |
$pattern = $this->get_wp_path_pattern( $wp_path ); |
| 251 |
if ( ! is_null( $pattern ) && preg_match( $pattern, rtrim( $request_uri, '/' ), $matches ) ) { |
| 252 |
if ( isset( $mapping['id'] ) ) { |
| 253 |
header( 'X-HS-WP-Plugin-Proxy-Mapping-ID: ' . $mapping['id'] ); |
| 254 |
} |
| 255 |
return $this->get_new_hs_path( $hs_path, $matches, $request_uri ); |
| 256 |
} |
| 257 |
} |
| 258 |
} |
| 259 |
return null; |
| 260 |
} |
| 261 |
|
| 262 |
/** |
| 263 |
* Gets the WordPress path pattern. |
| 264 |
* |
| 265 |
* This function is responsible for getting the WordPress path pattern. It |
| 266 |
* retrieves the WordPress path and then constructs a pattern based on the |
| 267 |
* path. If the path contains a wildcard, the pattern is modified to include |
| 268 |
* the wildcard. |
| 269 |
* |
| 270 |
* @param string $wp_path The WordPress path. |
| 271 |
* |
| 272 |
* @return string|null The pattern. |
| 273 |
*/ |
| 274 |
private function get_wp_path_pattern( $wp_path ) { |
| 275 |
if ( substr( $wp_path, -1 ) === '*' ) { |
| 276 |
if ( substr_count( $wp_path, '*' ) > 1 ) { |
| 277 |
ProxyUtils::error_log( "Invalid mapping: Multiple wildcards in wpPath $wp_path" ); |
| 278 |
return null; |
| 279 |
} |
| 280 |
// Remove the trailing '*' and any trailing slash |
| 281 |
// e.g. '/test-path/*' becomes '/test-path'. |
| 282 |
$base = rtrim( substr( $wp_path, 0, -1 ), '/' ); |
| 283 |
|
| 284 |
// Build a regex with two branches: |
| 285 |
// Branch 1: Exactly the base path (followed by a query string or end-of-string) |
| 286 |
// Branch 2: The base path followed by a slash and then at least one character (i.e. extra path data), |
| 287 |
// followed by a query string or end-of-string. |
| 288 |
$pattern = '~^(?:' |
| 289 |
. preg_quote( $base, '~' ) . '(?:\?.*|$)' // Branch 1. |
| 290 |
. '|' |
| 291 |
. preg_quote( $base . '/', '~' ) . '([^?]+)(?:\?.*|$)' // Branch 2. |
| 292 |
. ')$~'; |
| 293 |
return $pattern; |
| 294 |
} |
| 295 |
// When no wildcard is present, match exactly. |
| 296 |
return '~^' . preg_quote( $wp_path, '~' ) . '$~'; |
| 297 |
} |
| 298 |
|
| 299 |
/** |
| 300 |
* Gets the new HubSpot path. |
| 301 |
* |
| 302 |
* This function is responsible for getting the new HubSpot path. It retrieves |
| 303 |
* the HubSpot path, matches, and original request URI, and then constructs |
| 304 |
* a new path based on these values. |
| 305 |
* |
| 306 |
* @param string $hs_path The HubSpot path. |
| 307 |
* @param array $matches The matches. |
| 308 |
* @param string $original_request_uri The original request URI. |
| 309 |
* |
| 310 |
* @return string The new path. |
| 311 |
*/ |
| 312 |
private function get_new_hs_path( $hs_path, $matches, $original_request_uri ) { |
| 313 |
// If the HubSpot path contains a wildcard '*' then replace it. |
| 314 |
if ( strpos( $hs_path, '*' ) !== false ) { |
| 315 |
// If there's a captured value, use it; otherwise use an empty string. |
| 316 |
$replacement = ( isset( $matches[1] ) && ! empty( $matches[1] ) ) |
| 317 |
? wp_parse_url( $matches[1], PHP_URL_PATH ) |
| 318 |
: ''; |
| 319 |
|
| 320 |
// Replace '*' with the captured value (or empty string). |
| 321 |
$new_path = str_replace( '*', $replacement, $hs_path ); |
| 322 |
|
| 323 |
// If the replacement is empty, remove any trailing slash. |
| 324 |
if ( empty( $replacement ) ) { |
| 325 |
$new_path = rtrim( $new_path, '/' ); |
| 326 |
} |
| 327 |
|
| 328 |
// Append the query string from the original URI, if present. |
| 329 |
$query_string = wp_parse_url( $original_request_uri, PHP_URL_QUERY ); |
| 330 |
if ( $query_string ) { |
| 331 |
$new_path .= '?' . $query_string; |
| 332 |
} |
| 333 |
return $new_path; |
| 334 |
} |
| 335 |
// If there's no wildcard in the HubSpot path, return it as-is. |
| 336 |
return $hs_path; |
| 337 |
} |
| 338 |
|
| 339 |
/** |
| 340 |
* Gets the cached mappings. |
| 341 |
* |
| 342 |
* @return array The mappings. |
| 343 |
*/ |
| 344 |
private function get_cached_mappings() { |
| 345 |
return get_transient( 'proxy_mappings' ); |
| 346 |
} |
| 347 |
|
| 348 |
/** |
| 349 |
* Checks if the path is predefined. |
| 350 |
* |
| 351 |
* @param string $path url path. |
| 352 |
* |
| 353 |
* @return bool Whether the path is predefined. |
| 354 |
*/ |
| 355 |
private function is_predefined_path( $path ) { |
| 356 |
foreach ( self::PREDEFINED_PATH_PATTERNS as $pattern ) { |
| 357 |
if ( preg_match( $pattern, $path ) ) { |
| 358 |
return true; |
| 359 |
} |
| 360 |
} |
| 361 |
return false; |
| 362 |
} |
| 363 |
|
| 364 |
/** |
| 365 |
* Retrieves and sanitizes HTTP request headers for proxying. |
| 366 |
* |
| 367 |
* Uses getallheaders() when available, otherwise falls back to parsing |
| 368 |
* $_SERVER superglobal for HTTP_* entries. All header values are sanitized |
| 369 |
* using sanitize_text_field() to prevent injection attacks. |
| 370 |
* |
| 371 |
* @return array Associative array of sanitized header name => value pairs. |
| 372 |
*/ |
| 373 |
private function get_request_headers() { |
| 374 |
$headers = array(); |
| 375 |
|
| 376 |
if ( function_exists( 'getallheaders' ) ) { |
| 377 |
$all_headers = getallheaders(); |
| 378 |
if ( is_array( $all_headers ) ) { |
| 379 |
foreach ( $all_headers as $name => $value ) { |
| 380 |
$headers[ $name ] = sanitize_text_field( $value ); |
| 381 |
} |
| 382 |
} |
| 383 |
} else { |
| 384 |
$headers = $this->get_headers_from_server_superglobal(); |
| 385 |
} |
| 386 |
|
| 387 |
return $headers; |
| 388 |
} |
| 389 |
|
| 390 |
private function get_headers_from_server_superglobal() { |
| 391 |
$headers = array(); |
| 392 |
|
| 393 |
foreach ( $_SERVER as $key => $value ) { |
| 394 |
if ( strpos( $key, 'HTTP_' ) === 0 ) { |
| 395 |
$header_name = $this->normalize_header_name( substr( $key, 5 ) ); |
| 396 |
$headers[ $header_name ] = sanitize_text_field( wp_unslash( $value ) ); |
| 397 |
} |
| 398 |
} |
| 399 |
|
| 400 |
return $headers; |
| 401 |
} |
| 402 |
|
| 403 |
private function strip_headers( $headers ) { |
| 404 |
$headers_to_strip = array( 'host', 'content-length', 'cf-connecting-ip', 'true-client-ip', 'accept-encoding' ); |
| 405 |
foreach ( array_keys( $headers ) as $header_key ) { |
| 406 |
if ( in_array( strtolower( $header_key ), $headers_to_strip, true ) ) { |
| 407 |
unset( $headers[ $header_key ] ); |
| 408 |
} |
| 409 |
} |
| 410 |
return $headers; |
| 411 |
} |
| 412 |
|
| 413 |
/** |
| 414 |
* Builds the list of response headers to forward back to the client. |
| 415 |
* |
| 416 |
* Skips hop-by-hop headers, strips CR/LF to prevent header injection, and |
| 417 |
* expands multi-value headers (e.g. multiple Set-Cookie) into separate |
| 418 |
* entries so an array value is never stringified to the literal "Array". |
| 419 |
* |
| 420 |
* @param iterable $response_headers The upstream response headers. |
| 421 |
* @return array List of array( 'name' => string, 'value' => string, 'replace' => bool ). |
| 422 |
*/ |
| 423 |
private function build_passthrough_headers( $response_headers ) { |
| 424 |
$skip_headers = array( |
| 425 |
'transfer-encoding', |
| 426 |
'content-encoding', |
| 427 |
'content-length', |
| 428 |
'connection', |
| 429 |
'keep-alive', |
| 430 |
); |
| 431 |
|
| 432 |
$headers = array(); |
| 433 |
foreach ( $response_headers as $name => $value ) { |
| 434 |
if ( in_array( strtolower( $name ), $skip_headers, true ) ) { |
| 435 |
continue; |
| 436 |
} |
| 437 |
|
| 438 |
$values = is_array( $value ) ? array_values( $value ) : array( $value ); |
| 439 |
$replace = true; |
| 440 |
foreach ( $values as $single_value ) { |
| 441 |
$headers[] = array( |
| 442 |
'name' => $name, |
| 443 |
'value' => str_replace( array( "\r", "\n" ), '', $single_value ), |
| 444 |
'replace' => $replace, |
| 445 |
); |
| 446 |
$replace = false; |
| 447 |
} |
| 448 |
} |
| 449 |
|
| 450 |
return $headers; |
| 451 |
} |
| 452 |
|
| 453 |
private function normalize_header_name( $name ) { |
| 454 |
return str_replace( ' ', '-', ucwords( strtolower( str_replace( '_', ' ', $name ) ) ) ); |
| 455 |
} |
| 456 |
|
| 457 |
/** |
| 458 |
* Filters out WordPress authentication cookies from a cookie header string. |
| 459 |
* |
| 460 |
* Removes cookies with WordPress-specific prefixes (wordpress_, wp-settings-, |
| 461 |
* wp_woocommerce_) to prevent leaking authentication credentials to external |
| 462 |
* proxy target servers. |
| 463 |
* |
| 464 |
* @param string $cookie_header The raw Cookie header value. |
| 465 |
* @return string Filtered cookie header with WordPress cookies removed. |
| 466 |
*/ |
| 467 |
private function filter_wordpress_cookies( $cookie_header ) { |
| 468 |
$wp_cookie_prefixes = array( |
| 469 |
'wordpress_', |
| 470 |
'wp-settings-', |
| 471 |
'wp_woocommerce_', |
| 472 |
); |
| 473 |
|
| 474 |
$cookies = explode( ';', $cookie_header ); |
| 475 |
$filtered_cookies = array(); |
| 476 |
|
| 477 |
foreach ( $cookies as $cookie ) { |
| 478 |
$cookie = trim( $cookie ); |
| 479 |
if ( empty( $cookie ) ) { |
| 480 |
continue; |
| 481 |
} |
| 482 |
|
| 483 |
$is_wp_cookie = false; |
| 484 |
foreach ( $wp_cookie_prefixes as $prefix ) { |
| 485 |
if ( strpos( $cookie, $prefix ) === 0 ) { |
| 486 |
$is_wp_cookie = true; |
| 487 |
break; |
| 488 |
} |
| 489 |
} |
| 490 |
|
| 491 |
if ( ! $is_wp_cookie ) { |
| 492 |
$filtered_cookies[] = $cookie; |
| 493 |
} |
| 494 |
} |
| 495 |
|
| 496 |
return implode( '; ', $filtered_cookies ); |
| 497 |
} |
| 498 |
} |
| 499 |
|