PluginProbe
HubSpot All-In-One Marketing – Forms, Popups, Live Chat / 11.3.56
HubSpot All-In-One Marketing – Forms, Popups, Live Chat v11.3.56
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.56, at public/class-proxy-mappings.php

471 lines 14.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 $skip_headers = array(
209 'transfer-encoding',
210 'content-encoding',
211 'content-length',
212 'connection',
213 'keep-alive',
214 );
215
216 foreach ( $response_headers as $name => $value ) {
217 if ( in_array( strtolower( $name ), $skip_headers, true ) ) {
218 continue;
219 }
220 $safe_value = str_replace( array( "\r", "\n" ), '', $value );
221 header( "$name: $safe_value" );
222 }
223
224 status_header( $http_code );
225 header( 'X-HS-WP-Plugin-Proxy-URL: ' . $target_url );
226 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
227 echo $body;
228 exit;
229 }
230
231 /**
232 * Gets the proxy path.
233 *
234 * This function is responsible for getting the proxy path. It retrieves the
235 * mappings from the cache and then iterates over the mappings to find the
236 * matching domain and path. If a match is found, the new path is returned.
237 *
238 * @param string $current_domain The current domain.
239 * @param string $request_uri The request URI.
240 *
241 * @return string|null The new path.
242 */
243 private function get_proxy_path( $current_domain, $request_uri ) {
244 if ( $this->is_predefined_path( $request_uri ) === true ) {
245 ProxyUtils::info_log( 'Predefined path: ' . $request_uri );
246 return $request_uri;
247 }
248
249 $mappings = $this->get_cached_mappings();
250
251 if ( is_array( $mappings ) && ! empty( $mappings ) ) {
252 foreach ( $mappings as $mapping ) {
253 ProxyUtils::info_log( 'Mapping: ' . json_encode( $mapping ) );
254 $wp_path = rtrim( $mapping['wp_path'], '/' );
255 $hs_path = rtrim( $mapping['hs_path'], '/' );
256 $domain = $mapping['domain'];
257
258 if ( $current_domain !== $domain ) {
259 continue;
260 }
261
262 $pattern = $this->get_wp_path_pattern( $wp_path );
263 if ( ! is_null( $pattern ) && preg_match( $pattern, rtrim( $request_uri, '/' ), $matches ) ) {
264 if ( isset( $mapping['id'] ) ) {
265 header( 'X-HS-WP-Plugin-Proxy-Mapping-ID: ' . $mapping['id'] );
266 }
267 return $this->get_new_hs_path( $hs_path, $matches, $request_uri );
268 }
269 }
270 }
271 return null;
272 }
273
274 /**
275 * Gets the WordPress path pattern.
276 *
277 * This function is responsible for getting the WordPress path pattern. It
278 * retrieves the WordPress path and then constructs a pattern based on the
279 * path. If the path contains a wildcard, the pattern is modified to include
280 * the wildcard.
281 *
282 * @param string $wp_path The WordPress path.
283 *
284 * @return string|null The pattern.
285 */
286 private function get_wp_path_pattern( $wp_path ) {
287 if ( substr( $wp_path, -1 ) === '*' ) {
288 if ( substr_count( $wp_path, '*' ) > 1 ) {
289 ProxyUtils::error_log( "Invalid mapping: Multiple wildcards in wpPath $wp_path" );
290 return null;
291 }
292 // Remove the trailing '*' and any trailing slash
293 // e.g. '/test-path/*' becomes '/test-path'.
294 $base = rtrim( substr( $wp_path, 0, -1 ), '/' );
295
296 // Build a regex with two branches:
297 // Branch 1: Exactly the base path (followed by a query string or end-of-string)
298 // Branch 2: The base path followed by a slash and then at least one character (i.e. extra path data),
299 // followed by a query string or end-of-string.
300 $pattern = '~^(?:'
301 . preg_quote( $base, '~' ) . '(?:\?.*|$)' // Branch 1.
302 . '|'
303 . preg_quote( $base . '/', '~' ) . '([^?]+)(?:\?.*|$)' // Branch 2.
304 . ')$~';
305 return $pattern;
306 }
307 // When no wildcard is present, match exactly.
308 return '~^' . preg_quote( $wp_path, '~' ) . '$~';
309 }
310
311 /**
312 * Gets the new HubSpot path.
313 *
314 * This function is responsible for getting the new HubSpot path. It retrieves
315 * the HubSpot path, matches, and original request URI, and then constructs
316 * a new path based on these values.
317 *
318 * @param string $hs_path The HubSpot path.
319 * @param array $matches The matches.
320 * @param string $original_request_uri The original request URI.
321 *
322 * @return string The new path.
323 */
324 private function get_new_hs_path( $hs_path, $matches, $original_request_uri ) {
325 // If the HubSpot path contains a wildcard '*' then replace it.
326 if ( strpos( $hs_path, '*' ) !== false ) {
327 // If there's a captured value, use it; otherwise use an empty string.
328 $replacement = ( isset( $matches[1] ) && ! empty( $matches[1] ) )
329 ? wp_parse_url( $matches[1], PHP_URL_PATH )
330 : '';
331
332 // Replace '*' with the captured value (or empty string).
333 $new_path = str_replace( '*', $replacement, $hs_path );
334
335 // If the replacement is empty, remove any trailing slash.
336 if ( empty( $replacement ) ) {
337 $new_path = rtrim( $new_path, '/' );
338 }
339
340 // Append the query string from the original URI, if present.
341 $query_string = wp_parse_url( $original_request_uri, PHP_URL_QUERY );
342 if ( $query_string ) {
343 $new_path .= '?' . $query_string;
344 }
345 return $new_path;
346 }
347 // If there's no wildcard in the HubSpot path, return it as-is.
348 return $hs_path;
349 }
350
351 /**
352 * Gets the cached mappings.
353 *
354 * @return array The mappings.
355 */
356 private function get_cached_mappings() {
357 return get_transient( 'proxy_mappings' );
358 }
359
360 /**
361 * Checks if the path is predefined.
362 *
363 * @param string $path url path.
364 *
365 * @return bool Whether the path is predefined.
366 */
367 private function is_predefined_path( $path ) {
368 foreach ( self::PREDEFINED_PATH_PATTERNS as $pattern ) {
369 if ( preg_match( $pattern, $path ) ) {
370 return true;
371 }
372 }
373 return false;
374 }
375
376 /**
377 * Retrieves and sanitizes HTTP request headers for proxying.
378 *
379 * Uses getallheaders() when available, otherwise falls back to parsing
380 * $_SERVER superglobal for HTTP_* entries. All header values are sanitized
381 * using sanitize_text_field() to prevent injection attacks.
382 *
383 * @return array Associative array of sanitized header name => value pairs.
384 */
385 private function get_request_headers() {
386 $headers = array();
387
388 if ( function_exists( 'getallheaders' ) ) {
389 $all_headers = getallheaders();
390 if ( is_array( $all_headers ) ) {
391 foreach ( $all_headers as $name => $value ) {
392 $headers[ $name ] = sanitize_text_field( $value );
393 }
394 }
395 } else {
396 $headers = $this->get_headers_from_server_superglobal();
397 }
398
399 return $headers;
400 }
401
402 private function get_headers_from_server_superglobal() {
403 $headers = array();
404
405 foreach ( $_SERVER as $key => $value ) {
406 if ( strpos( $key, 'HTTP_' ) === 0 ) {
407 $header_name = $this->normalize_header_name( substr( $key, 5 ) );
408 $headers[ $header_name ] = sanitize_text_field( wp_unslash( $value ) );
409 }
410 }
411
412 return $headers;
413 }
414
415 private function strip_headers( $headers ) {
416 $headers_to_strip = array( 'host', 'content-length', 'cf-connecting-ip', 'true-client-ip' );
417 foreach ( array_keys( $headers ) as $header_key ) {
418 if ( in_array( strtolower( $header_key ), $headers_to_strip, true ) ) {
419 unset( $headers[ $header_key ] );
420 }
421 }
422 return $headers;
423 }
424
425 private function normalize_header_name( $name ) {
426 return str_replace( ' ', '-', ucwords( strtolower( str_replace( '_', ' ', $name ) ) ) );
427 }
428
429 /**
430 * Filters out WordPress authentication cookies from a cookie header string.
431 *
432 * Removes cookies with WordPress-specific prefixes (wordpress_, wp-settings-,
433 * wp_woocommerce_) to prevent leaking authentication credentials to external
434 * proxy target servers.
435 *
436 * @param string $cookie_header The raw Cookie header value.
437 * @return string Filtered cookie header with WordPress cookies removed.
438 */
439 private function filter_wordpress_cookies( $cookie_header ) {
440 $wp_cookie_prefixes = array(
441 'wordpress_',
442 'wp-settings-',
443 'wp_woocommerce_',
444 );
445
446 $cookies = explode( ';', $cookie_header );
447 $filtered_cookies = array();
448
449 foreach ( $cookies as $cookie ) {
450 $cookie = trim( $cookie );
451 if ( empty( $cookie ) ) {
452 continue;
453 }
454
455 $is_wp_cookie = false;
456 foreach ( $wp_cookie_prefixes as $prefix ) {
457 if ( strpos( $cookie, $prefix ) === 0 ) {
458 $is_wp_cookie = true;
459 break;
460 }
461 }
462
463 if ( ! $is_wp_cookie ) {
464 $filtered_cookies[] = $cookie;
465 }
466 }
467
468 return implode( '; ', $filtered_cookies );
469 }
470 }
471