PluginProbe
HubSpot All-In-One Marketing – Forms, Popups, Live Chat / 11.3.75
HubSpot All-In-One Marketing – Forms, Popups, Live Chat v11.3.75
11.3.75 11.3.73 11.3.71 11.3.70 11.3.69 11.3.64 11.3.65 11.3.62 11.3.61 11.3.56 11.3.58 11.0.31 11.0.52 11.0.54 11.0.56 11.0.58 11.0.7 11.1.10 11.1.11 11.1.13 11.1.14 11.1.15 11.1.2 11.1.20 11.1.21 All 73 releases
leadin / public / class-proxy-mappings.php

class-proxy-mappings.php in HubSpot All-In-One Marketing – Forms, Popups, Live Chat 11.3.75, at public/class-proxy-mappings.php

499 lines 15.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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