| Server IP : 216.238.66.20 / Your IP : 216.73.216.38 Web Server : nginx/1.30.4 System : Linux woropds 5.15.0-187-generic #197-Ubuntu SMP Fri Jul 17 19:17:01 UTC 2026 x86_64 User : root ( 0) PHP Version : 8.2.33 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : OFF | Perl : OFF | Python : OFF | Sudo : OFF | Pkexec : OFF Directory : /var/www/sweetheart.mx/htdocs/wp-content/plugins/wp-slimstat/src/Tracker/ |
Upload File : |
<?php
namespace SlimStat\Tracker;
use SlimStat\Services\Browscap;
use SlimStat\Utils\Consent;
class Ajax
{
/**
* Validate click position as strict "x,y" format with 1-5 digit coordinates.
*
* Rejects any value that does not match after whitespace trimming.
* No character stripping — tampered payloads are rejected outright
* so GDPR exports never contain repaired/synthetic coordinates.
*
* @param mixed $raw Raw position value from client.
* @return string Validated "x,y" or empty string if invalid.
*/
public static function sanitizePosition($raw): string
{
if (!is_string($raw)) {
return '';
}
$position = trim($raw);
if ($position !== '' && !preg_match('/^\d{1,5},\d{1,5}$/', $position)) {
return '';
}
return $position;
}
/**
* Validate and sanitize a base64url-encoded referer from the JS tracker payload.
*
* @internal Extracted from handle() (#306) to provide a unit-testable seam.
*
* Uses sanitize_url() with `android-app` added to the allow-list
* (Processor::REFERER_ALLOWED_SCHEMES) rather than the default wp_allowed_protocols():
* - app-scheme referers such as `android-app://com.google.android.googlequicksearchbox/`
* (Google Discover) survive — the original #306 bug was the default list emptying them;
* - disallowed schemes (javascript:, data:) are emptied here, at the boundary, so they can
* never reach storage even on the follow-up-event path that skips Processor::process();
* - unlike sanitize_text_field, percent-encoded query octets (%XX) are preserved, so
* getSearchTerms() can still decode non-Latin / spaced search terms downstream.
* The host-format check below and the post-storage scheme check in Processor::process()
* remain as defense in depth.
*
* @param mixed $rawEncoded Raw base64url ref value from the client payload.
* @return string|false Sanitized referer (possibly empty), or false when the referer is
* malformed and the whole request must be rejected.
*/
public static function sanitizeReferer($rawEncoded)
{
$referer = Utils::base64UrlDecode($rawEncoded);
$parsed_ref = parse_url($referer ?: '');
// Security: Validate referer format
if (false === $parsed_ref) {
return false;
}
// Security: Validate host (if present) - allow external domains for referer,
// but validate the host format to prevent injection. Accept either a DNS
// hostname or a bracketed IPv6 literal (parse_url keeps the brackets, e.g.
// "[2001:db8::1]"); otherwise a valid IPv6 referer would fail the check and
// drop the entire hit. filter_var validates the IPv6 structure (same
// FILTER_FLAG_IPV6 pattern Utils uses) and runs only when the host is not a
// plain hostname.
if (!empty($parsed_ref['host'])) {
$host = $parsed_ref['host'];
$is_hostname = (bool) preg_match('/^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/', $host);
$is_ipv6 = !$is_hostname
&& $host[0] === '[' && substr($host, -1) === ']'
&& false !== filter_var(substr($host, 1, -1), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
if (!$is_hostname && !$is_ipv6) {
return false;
}
}
// Security: Limit referer length to prevent DoS
if (strlen($referer) > 2048) {
$referer = substr($referer, 0, 2048);
}
return sanitize_url($referer, Processor::REFERER_ALLOWED_SCHEMES);
}
/**
* Handle AJAX tracking request with exit (for admin-ajax.php).
* This wrapper calls process() and exits with the result.
*/
public static function handle()
{
$result = self::process();
Utils::sendTrackingHeaders('ajax', $result);
echo $result;
exit;
}
/**
* Process tracking request and return result (for REST API and other contexts).
* Returns the tracking result without calling exit().
*
* @return string|int The tracking result (record ID with checksum, error code, or 0)
*/
public static function process()
{
$remote_ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
if (!empty($remote_ip)) {
$key = 'slimstat_rl_' . md5($remote_ip);
$hits_in_5s = (int) get_transient($key);
if ($hits_in_5s >= 10) {
return Utils::logError(429);
}
set_transient($key, $hits_in_5s + 1, 5);
}
if ('on' != \wp_slimstat::$settings['is_tracking']) {
return Utils::logError(204);
}
$id = 0;
// Use setter with validation
\wp_slimstat::set_data_js(apply_filters('slimstat_filter_pageview_data_js', \wp_slimstat::$raw_post_array));
$data_js = \wp_slimstat::get_data_js();
$stat = \wp_slimstat::get_stat();
$site_host = parse_url(get_site_url(), PHP_URL_HOST);
$home_host = parse_url(home_url(), PHP_URL_HOST);
$http_host = isset($_SERVER['HTTP_HOST']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'])) : '';
$allowed_hosts = array_filter([$site_host, $home_host, $http_host]);
$normalize_host = static function ($host) {
$host = strtolower((string) $host);
$host = preg_replace('/:\\d+$/', '', $host);
if (0 === strpos($host, 'www.')) {
$host = substr($host, 4);
}
return $host;
};
$allowed_hosts = array_unique(array_map($normalize_host, $allowed_hosts));
$is_allowed_host = static function ($host) use ($allowed_hosts, $normalize_host) {
if (empty($host)) {
return false;
}
return in_array($normalize_host($host), $allowed_hosts, true);
};
// Check if this is a consent upgrade request (needed for IP processing and later checks)
$isConsentUpgrade = !empty($data_js['consent_upgrade']) && '1' === $data_js['consent_upgrade'];
// GDPR Compliance: Ensure IP is always fresh from $_SERVER for navigation requests
// In anonymous mode, get_stat() may contain a hashed IP from previous requests
// We need to get the real IP from $_SERVER and then process it according to consent
[$stat['ip'], $stat['other_ip']] = Utils::getRemoteIp();
// Security: Validate and sanitize referer URL
$stat['referer'] = '';
if (!empty($data_js['ref'])) {
$referer = self::sanitizeReferer($data_js['ref']);
if (false === $referer) {
// Invalid referer format - reject request
return Utils::logError(201);
}
$stat['referer'] = $referer;
}
// Update stat after referer processing
\wp_slimstat::set_stat($stat);
if (!empty($data_js['id'])) {
// Defense-in-depth: check bot status even for follow-up AJAX events.
// The initial pageview (id=empty) goes through Processor::process() which
// has the full bot check, but follow-up events skip Processor entirely.
// This ensures bots executing JS are still blocked on updates. See #291.
if ('on' == \wp_slimstat::$settings['ignore_bots']) {
$browser = Browscap::get_browser();
if (1 == $browser['browser_type']) {
return Utils::logError(313);
}
}
$data_js['id'] = Utils::getValueWithoutChecksum($data_js['id']);
if (false === $data_js['id']) {
return Utils::logError(101);
}
$stat['id'] = intval($data_js['id']);
if ($stat['id'] < 0) {
do_action('slimstat_track_exit_' . abs($stat['id']));
return Utils::getValueWithChecksum($stat['id']);
}
// Process IP according to consent status (cookie set only by consent upgrade handler)
// $isConsentUpgrade already defined above
// Pass explicit consent flag if this is a consent upgrade request
$stat = \SlimStat\Providers\IPHashProvider::processIp($stat, $isConsentUpgrade);
if (Consent::piiAllowed($isConsentUpgrade)) {
if (!empty($GLOBALS['current_user']->ID)) {
$stat['username'] = $GLOBALS['current_user']->data->user_login;
$stat['email'] = $GLOBALS['current_user']->data->user_email;
$stat['notes'][] = 'user:' . $GLOBALS['current_user']->data->ID;
} elseif (isset($_COOKIE['comment_author_' . COOKIEHASH])) {
if (!empty($_COOKIE['comment_author_' . COOKIEHASH])) {
$stat['username'] = sanitize_user($_COOKIE['comment_author_' . COOKIEHASH]);
}
if (!empty($_COOKIE['comment_author_email_' . COOKIEHASH])) {
$stat['email'] = sanitize_email($_COOKIE['comment_author_email_' . COOKIEHASH]);
}
}
}
if (empty($data_js['pos'])) {
// Security: Validate and sanitize resource URL from JavaScript data
// This ensures we track the correct page for navigation requests while preventing injection attacks
if (!empty($data_js['res'])) {
$resource = Utils::base64UrlDecode($data_js['res']);
$parsed_resource = parse_url($resource ?: '');
// Security: Validate host is from current site domain
$site_host = parse_url(get_site_url(), PHP_URL_HOST);
if (false !== $parsed_resource && !empty($parsed_resource['host'])) {
// Security: Whitelist validation - only allow current site domain
if (!$is_allowed_host($parsed_resource['host'])) {
// Invalid host - reject request
return Utils::logError(203);
}
// Security: Validate path format (prevent path traversal attacks)
$path = !empty($parsed_resource['path']) ? $parsed_resource['path'] : '/';
// Remove any path traversal attempts
$path = str_replace(['../', '..\\', '%2e%2e', '%2E%2E'], '', $path);
// Validate path contains only safe characters
if (!preg_match('#^[/\w\-\.~!*\'();:@&=+$,?#\[\]%]*$#', $path)) {
// Invalid path format - reject request
return Utils::logError(203);
}
// Extract path from resource URL
$stat['resource'] = $path . (empty($parsed_resource['query']) ? '' : '?' . $parsed_resource['query']);
$stat['resource'] = sanitize_text_field(urldecode($stat['resource']));
$stat['resource'] = preg_replace_callback('/[^\x20-\x7E]/', function ($m) {
return '%' . bin2hex($m[0]);
}, $stat['resource']);
// Security: Limit resource length to prevent DoS
if (strlen($stat['resource']) > 2048) {
$stat['resource'] = substr($stat['resource'], 0, 2048);
}
}
}
// Update path: if no explicit resource was provided by JS, do NOT fall back to
// REQUEST_URI. REQUEST_URI here is the tracking endpoint itself
// (/wp-json/slimstat/v1/hit or /wp-admin/admin-ajax.php), not the page the
// visitor is on. Unsetting ensures Storage::updateRow()'s array_filter() omits
// the resource column so the DB value set on the initial pageview is preserved.
if (empty($stat['resource'])) {
unset($stat['resource']);
}
// Sync local stat (including id from client) to global before ensureVisitId,
// which calls get_stat()/set_stat() internally and would lose the id otherwise.
// See: https://github.com/wp-slimstat/wp-slimstat/issues/242
\wp_slimstat::set_stat($stat);
// Security: Ensure visit ID is generated successfully
$visitIdAssigned = Session::ensureVisitId(true);
$stat = \wp_slimstat::get_stat();
// Security: Validate visit_id exists - return error if generation failed
if (empty($stat['visit_id']) || $stat['visit_id'] <= 0) {
return Utils::logError(500);
}
$stat = Utils::getClientInfo($data_js, $stat);
if (empty($stat['resolution'])) {
$stat['dt_out'] = \wp_slimstat::date_i18n('U');
}
if (!empty($stat['fingerprint']) && Utils::isNewVisitor($stat['fingerprint'])) {
$stat['notes'] = ['new:yes'];
}
// Update stat before storage
\wp_slimstat::set_stat($stat);
// GDPR Compliance: Duplicate check for anonymous mode (same as Processor.php)
// In Anonymous Tracking Mode without PII, simulate normal session behavior
// This prevents duplicate records from page refreshes while still allowing:
// - New visits to different pages (different resource)
// - New sessions after session_duration expires
// - New visits from different browsers/devices (different visit_id)
$isAnonymousTracking = ('on' === (\wp_slimstat::$settings['anonymous_tracking'] ?? 'off'));
$piiAllowed = Consent::piiAllowed();
if ($isAnonymousTracking && !$piiAllowed && !empty($stat['visit_id']) && !empty($stat['resource'])) {
$session_duration = !empty(\wp_slimstat::$settings['session_duration']) ? intval(\wp_slimstat::$settings['session_duration']) : 1800;
$table = $GLOBALS['wpdb']->prefix . 'slim_stats';
$min_timestamp = $stat['dt'] - $session_duration;
$GLOBALS['wpdb']->query('START TRANSACTION');
try {
$fingerprint_check = '';
$fingerprint_value = null;
if (!empty($stat['fingerprint'])) {
$fingerprint_check = ' AND fingerprint = %s';
$fingerprint_value = $stat['fingerprint'];
}
$sql = "SELECT id, dt FROM {$table}
WHERE visit_id = %d
AND resource = %s
AND dt >= %d
AND dt <= %d
{$fingerprint_check}
ORDER BY dt DESC
LIMIT 1
FOR UPDATE";
$prepare_args = [
$stat['visit_id'],
$stat['resource'],
$min_timestamp,
$stat['dt']
];
if ($fingerprint_value !== null) {
$prepare_args[] = $fingerprint_value;
}
$existing_record = $GLOBALS['wpdb']->get_row(
$GLOBALS['wpdb']->prepare($sql, ...$prepare_args),
OBJECT
);
if (!empty($existing_record)) {
$stat['id'] = intval($existing_record->id);
\wp_slimstat::set_stat($stat);
$GLOBALS['wpdb']->query('COMMIT');
return Utils::getValueWithChecksum($stat['id']);
}
$GLOBALS['wpdb']->query('COMMIT');
} catch (\Exception $e) {
// Rollback on error
$GLOBALS['wpdb']->query('ROLLBACK');
}
}
$id = Storage::updateRow($stat);
} else {
// Security: Validate and sanitize event position (x,y coordinates)
$position = self::sanitizePosition($data_js['pos'] ?? '');
$event_info = [
// Defense-in-depth: sanitizePosition already guarantees digit-comma-digit
'position' => sanitize_text_field($position),
'id' => $stat['id'],
'dt' => \wp_slimstat::date_i18n('U'),
];
// Security: Validate and sanitize event notes
if (!empty($data_js['no'])) {
$notes = Utils::base64UrlDecode($data_js['no']);
// Security: Limit notes length
if (strlen($notes) > 256) {
$notes = substr($notes, 0, 256);
}
$event_info['notes'] = sanitize_text_field($notes);
}
$shouldEventBeTracked = apply_filters('slimstat_track_event_enabled', true, $event_info);
if ($shouldEventBeTracked) {
Storage::insertRow($event_info, $GLOBALS['wpdb']->prefix . 'slim_events');
}
if (!empty($data_js['res'])) {
$resource = Utils::base64UrlDecode($data_js['res']);
$parsed_resource = parse_url($resource ?: '');
if (false === $parsed_resource || empty($parsed_resource['host'])) {
return Utils::logError(203);
}
if (!empty($parsed_resource['path']) && in_array(pathinfo($parsed_resource['path'], PATHINFO_EXTENSION), \wp_slimstat::string_to_array(\wp_slimstat::$settings['extensions_to_track']))) {
$stat['resource'] = $parsed_resource['path'] . (empty($parsed_resource['query']) ? '' : '?' . $parsed_resource['query']);
$stat['content_type'] = 'download';
// Security: Validate and sanitize fingerprint
if (!empty($data_js['fh'])) {
$fingerprint = $data_js['fh'];
// Security: Validate fingerprint format (alphanumeric, dash, underscore only)
$fingerprint = preg_replace('/[^a-zA-Z0-9\-_]/', '', $fingerprint);
// Security: Limit fingerprint length
if (strlen($fingerprint) > 256) {
$fingerprint = substr($fingerprint, 0, 256);
}
$stat['fingerprint'] = sanitize_text_field($fingerprint);
}
// Update stat before processing
\wp_slimstat::set_stat($stat);
$id = Processor::process();
} elseif (!$is_allowed_host($parsed_resource['host'])) {
$sanitized_url = sanitize_url($resource);
$stat['outbound_resource'] = !empty($sanitized_url) ? $sanitized_url : '';
$stat['dt_out'] = \wp_slimstat::date_i18n('U');
// Update stat before storage
\wp_slimstat::set_stat($stat);
$id = Storage::updateRow($stat);
}
} else {
$stat['dt_out'] = \wp_slimstat::date_i18n('U');
// Update stat before storage
\wp_slimstat::set_stat($stat);
$id = Storage::updateRow($stat);
}
}
} else {
$stat['resource'] = '';
if (!empty($data_js['res'])) {
$stat['resource'] = Utils::base64UrlDecode($data_js['res']);
if (false === parse_url($stat['resource'] ?: '')) {
return Utils::logError(203);
}
}
$stat = Utils::getClientInfo($data_js, $stat);
if (!empty($data_js['ci'])) {
$validated_ci = Utils::getValueWithoutChecksum($data_js['ci']);
if (false === $validated_ci) {
Utils::logWarning(102);
$data_js['ci'] = '';
} else {
$data_js['ci'] = $validated_ci;
}
}
if (!empty($data_js['ci'])) {
$decoded_ci = Utils::base64UrlDecode($data_js['ci']);
$content_info = json_decode($decoded_ci, true);
// Security: Only accept JSON-encoded content info, reject serialized data.
// If the payload is stale or malformed, continue without trusting its metadata.
if (empty($content_info) || !is_array($content_info)) {
Utils::logWarning(103);
$stat['content_type'] = 'external';
} else {
foreach (['content_type', 'category', 'content_id', 'author'] as $a_key) {
if (!empty($content_info[$a_key]) && 'content_id' !== $a_key) {
$stat[$a_key] = sanitize_text_field($content_info[$a_key]);
} elseif (!empty($content_info[$a_key])) {
$stat[$a_key] = absint($content_info[$a_key]);
}
}
}
} else {
$stat['content_type'] = 'external';
}
if (!empty($stat['fingerprint']) && Utils::isNewVisitor($stat['fingerprint'])) {
$stat['notes'] = ['new:yes'];
}
// consent_upgrade already checked above, reuse the variable
if ($isConsentUpgrade) {
// Pass consent_upgrade flag to Processor via data_js
// Processor will handle the upgrade logic
}
// Update stat before processing
\wp_slimstat::set_stat($stat);
$id = Processor::process();
}
$isErrorCode = is_int($id) && $id < 0;
if (empty($id) || $isErrorCode) {
return $isErrorCode ? $id : 0;
}
do_action('slimstat_track_success');
return Utils::getValueWithChecksum($id);
}
}