'track.js', 'track.js' => 'track.js', 'track.min.js' => 'track.min.js', 'rrweb.min.js' => 'rrweb.min.js', 'conversions.min.js' => 'conversions.min.js', ); /** The tracking endpoints relayed — this is not an open proxy. */ const API_PATHS = array( '/v1/tracking/event', '/v1/tracking/config', '/v1/tracking/recording', '/v1/tracking/recording-telemetry', // Page click/scroll heatmaps (cdn/src/heatmap.js, Pro and up). '/v1/tracking/heatmap', '/v1/tracking/heatmap-snapshot', ); /** * Custom Scripts are one path per snippet, so they need a pattern rather * than a fixed entry. The character classes mirror the backend route's own * {host}/{scriptId} constraints, keeping this a narrow allowlist rather * than a wildcard passthrough to /v1/tracking/*. */ const CUSTOM_SCRIPT_PATTERN = '#^/v1/tracking/cs/[A-Za-z0-9.-]+/[A-Za-z0-9_-]+\.js$#'; /** Seconds to wait for the CDN or the API. */ const TIMEOUT = 10; private $site_key; private $base_path; private $trusted_edge; private $ja4_header; private $ja4h_header; private $cdn_host; private $api_host; private $cache_ttl; /** * The proxy configured from Settings → AdProtektor. A constant of the same * name defined in wp-config.php wins, as it did when the plugin bundled the * standalone proxy. */ public static function from_settings() { return new self(array( 'site_key' => defined('ADPROTEKTOR_SITE_KEY') ? ADPROTEKTOR_SITE_KEY : get_option('adprotektor_site_key', ''), 'base_path' => defined('ADPROTEKTOR_BASE_PATH') ? ADPROTEKTOR_BASE_PATH : '/_adp', // Only when the admin confirms an edge (Cloudflare/nginx) fronts this // site; otherwise forwarded IP/JA4 headers are visitor-controlled. 'trusted_edge' => defined('ADPROTEKTOR_TRUSTED_EDGE') ? ADPROTEKTOR_TRUSTED_EDGE : get_option('adprotektor_trusted_edge', false), 'ja4_header' => defined('ADPROTEKTOR_JA4_HEADER') ? ADPROTEKTOR_JA4_HEADER : get_option('adprotektor_ja4_header', 'X-Adp-JA4'), 'ja4h_header' => defined('ADPROTEKTOR_JA4H_HEADER') ? ADPROTEKTOR_JA4H_HEADER : get_option('adprotektor_ja4h_header', 'X-Adp-JA4H'), 'cdn_host' => defined('ADPROTEKTOR_CDN_HOST') ? ADPROTEKTOR_CDN_HOST : 'https://cdn.adprotektor.app', 'api_host' => defined('ADPROTEKTOR_API_HOST') ? ADPROTEKTOR_API_HOST : 'https://api.adprotektor.app', // The CDN sends no-cache; a short local TTL keeps the proxy fast without // delaying bundle updates more than a few minutes. 'cache_ttl' => defined('ADPROTEKTOR_JS_CACHE_TTL') ? ADPROTEKTOR_JS_CACHE_TTL : 600, )); } public function __construct(array $settings) { $this->site_key = (string) $settings['site_key']; $this->base_path = rtrim((string) $settings['base_path'], '/'); $this->trusted_edge = (bool) $settings['trusted_edge']; $this->ja4_header = (string) $settings['ja4_header']; $this->ja4h_header = (string) $settings['ja4h_header']; $this->cdn_host = rtrim((string) $settings['cdn_host'], '/'); $this->api_host = rtrim((string) $settings['api_host'], '/'); $this->cache_ttl = max(0, (int) $settings['cache_ttl']); } /** Intercept as early as possible, before WordPress routes or redirects the request. */ public function register() { add_action('init', array($this, 'dispatch'), 0); } /** Answer the request and stop if it is for the base path; otherwise WordPress carries on. */ public function dispatch() { // Every request on the site passes here: leave the ones that are not ours // before reading anything else of them, the body above all. if ($this->subpath((string) wp_parse_url(self::server_value('REQUEST_URI'), PHP_URL_PATH)) === null) { return; } $response = $this->handle($this->current_request()); if ($response === null) { return; } if (!headers_sent()) { http_response_code($response['status']); foreach ($response['headers'] as $header) { header($header, stripos($header, 'Set-Cookie:') !== 0); } } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the tracking script or the API's JSON, relayed byte for byte under its own Content-Type; escaping would corrupt it. echo $response['body']; exit; } /** * This request as handle() takes it: unslashed (WordPress adds slashes to * $_SERVER) and stripped of tags. wp_strip_all_tags() rather than * sanitize_text_field(), because these values are relayed and must reach * AdProtektor as the visitor sent them — sanitize_text_field() drops * percent-encoded octets and collapses whitespace, which would rewrite the * request target and the user agent and break a verified agent's * signature. Control characters are stripped where a value becomes an * outbound header (clean()). */ public function current_request() { $names = array( 'Accept-Language', 'User-Agent', 'Origin', 'Referer', 'Cookie', 'Host', 'CF-Connecting-IP', 'X-Forwarded-For', 'X-Forwarded-Proto', 'Signature-Agent', 'Signature-Input', 'Signature', 'If-None-Match', $this->ja4_header, $this->ja4h_header, ); $headers = array(); foreach ($names as $name) { $value = $name === '' ? '' : self::server_value('HTTP_' . strtoupper(str_replace('-', '_', $name))); if ($value !== '') { $headers[strtolower($name)] = $value; } } // PHP exposes the request's Content-Type as CONTENT_TYPE, and only some // servers as HTTP_CONTENT_TYPE too (Apache does not). $type = self::server_value('CONTENT_TYPE'); if ($type === '') { $type = self::server_value('HTTP_CONTENT_TYPE'); } if ($type !== '') { $headers['content-type'] = $type; } $method = strtoupper(self::server_value('REQUEST_METHOD')); $method = preg_match('/^[A-Z]+$/', $method) ? $method : 'GET'; return array( 'method' => $method, 'uri' => self::server_value('REQUEST_URI'), 'https' => is_ssl(), 'remote_addr' => self::server_value('REMOTE_ADDR'), 'headers' => $headers, 'body' => in_array($method, array('POST', 'PUT'), true) ? (string) file_get_contents('php://input') : '', ); } /** * The response for a request, or null when it is not for the base path. * * @param array $request method, uri, https, remote_addr, headers (lower-case names), body * @return array|null status, headers ("Name: value" lines), body */ public function handle(array $request) { $sub = $this->subpath((string) wp_parse_url($request['uri'], PHP_URL_PATH)); if ($sub === null) { return null; } $sub = $sub === '' ? '/' : $sub; // Custom Scripts: track.js builds each snippet's