Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions simple-analytics.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
* @note Manual loading rather than Composer to avoid potential conflict with plugins/themes that ship older autoloader.
*/
require __DIR__ . '/src/Support/SvgIcon.php';
require __DIR__ . '/src/Support/IpAddress.php';
require __DIR__ . '/helpers.php';
require __DIR__ . '/src/Plugin.php';
require __DIR__ . '/src/WordPressHooks.php';
Expand Down
5 changes: 4 additions & 1 deletion src/Settings/Blocks/Fields/IpList.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace SimpleAnalytics\Settings\Blocks\Fields;

use SimpleAnalytics\Setting;
use SimpleAnalytics\Support\IpAddress;
use SimpleAnalytics\Settings\Concerns\HasDocs;
use SimpleAnalytics\Settings\Concerns\HasPlaceholder;
use SimpleAnalytics\UI\LabelComponent;
Expand Down Expand Up @@ -34,7 +35,7 @@ public function getValueType(): string
public function render(): void
{
$value = implode("\n", Setting::array($this->getKey()));
$currentIp = $_SERVER['REMOTE_ADDR'];
$currentIp = IpAddress::current();
?>
<?php
(new LabelComponent(
Expand Down Expand Up @@ -62,6 +63,7 @@ class="block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inse
echo esc_textarea($value);
?></textarea>
</div>
<?php if ($currentIp !== null): ?>
<div class="mt-2">
<button
type="button"
Expand All @@ -77,6 +79,7 @@ class="rounded bg-white px-2 py-1 text-xs font-semibold text-gray-900 shadow-sm
?>)
</button>
</div>
<?php endif; ?>
<p class="mt-2 text-sm text-gray-500">
Enter IP addresses to exclude from tracking, one per line.
</p>
Expand Down
31 changes: 31 additions & 0 deletions src/Support/IpAddress.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace SimpleAnalytics\Support;

class IpAddress
{
public static function current(): ?string
{
// Preserve the existing preference for the visitor's forwarded address.
// Reverse proxies must overwrite this header with a trustworthy value.
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? null;
if (is_string($ip)) {
$ip = explode(',', $ip, 2)[0];
} elseif ($ip === null) {
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
}

// Do not also match REMOTE_ADDR: a shared proxy IP could exclude every
// visitor behind that proxy. Invalid forwarded values remain unmatched.
return self::normalize(apply_filters('simpleanalytics_client_ip', $ip));
}

public static function normalize($value): ?string
{
if (! is_string($value)) return null;
$value = trim($value);
if (filter_var($value, FILTER_VALIDATE_IP) === false) return null;

return inet_ntop(inet_pton($value));
}
}
8 changes: 5 additions & 3 deletions src/TrackingRules.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace SimpleAnalytics;

use SimpleAnalytics\Support\IpAddress;

class TrackingRules
{
protected $settings;
Expand All @@ -13,13 +15,13 @@ public function __construct(WordPressSettings $settings)

public function hasExcludedIp(): bool
{
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? null);
$ip = IpAddress::current();

if (empty($ip)) return false;

$list = $this->settings->array(SettingName::EXCLUDED_IP_ADDRESSES);
$list = array_map([IpAddress::class, 'normalize'], $this->settings->array(SettingName::EXCLUDED_IP_ADDRESSES));

return in_array($ip, $list);
return in_array($ip, $list, true);
}

public function hasExcludedUserRole(): bool
Expand Down
63 changes: 63 additions & 0 deletions tests/Regression/client-ip.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

require __DIR__ . '/../Support/isolated-options.php';

sa_with_isolated_options(static function () {
$key = SimpleAnalytics\SettingName::EXCLUDED_IP_ADDRESSES;
$rules = new SimpleAnalytics\TrackingRules(new SimpleAnalytics\WordPressSettings());
update_option($key, ['203.0.113.10', '2001:0DB8:0:0:0:0:0:1']);

// Existing installations prefer a forwarded visitor over the proxy peer.
$_SERVER['REMOTE_ADDR'] = '10.0.0.1';
$_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.10';
sa_assert($rules->hasExcludedIp(), 'Existing forwarded visitor exclusions must keep working.');
$_SERVER['HTTP_X_FORWARDED_FOR'] = ' 203.0.113.10, 10.0.0.2';
sa_assert($rules->hasExcludedIp(), 'A proxy chain must resolve to its first visitor address.');

$field = new SimpleAnalytics\Settings\Blocks\Fields\IpList($key, 'IP addresses');
ob_start();
$field->render();
$html = ob_get_clean();
sa_assert(strpos($html, 'Add Current IP (203.0.113.10)') !== false, 'The UI must offer the forwarded visitor used by tracking.');

// Old UI saves may contain the proxy address. Never broaden those rules
// to all visitors who share that proxy or any later address in the chain.
$_SERVER['REMOTE_ADDR'] = '203.0.113.10';
$_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.20, 203.0.113.10';
sa_assert(! $rules->hasExcludedIp(), 'A shared proxy must not exclude an unrelated visitor.');
foreach (['invalid', '', ', 203.0.113.10', []] as $invalid) {
$_SERVER['HTTP_X_FORWARDED_FOR'] = $invalid;
sa_assert(! $rules->hasExcludedIp(), 'An invalid forwarded value must not fall back to an excluded proxy.');
}

unset($_SERVER['HTTP_X_FORWARDED_FOR']);
sa_assert($rules->hasExcludedIp(), 'Direct visitor exclusions must keep working without a forwarded header.');

$_SERVER['REMOTE_ADDR'] = '2001:db8::1';
sa_assert($rules->hasExcludedIp(), 'Equivalent IPv6 addresses must match.');
ob_start();
$field->render();
$html = ob_get_clean();
sa_assert(strpos($html, 'Add Current IP (2001:db8::1)') !== false, 'The UI must use the same normalized address.');

$override = static function ($ip) { return '203.0.113.10'; };
add_filter('simpleanalytics_client_ip', $override);
$_SERVER['REMOTE_ADDR'] = '10.0.0.1';
sa_assert($rules->hasExcludedIp(), 'A deployment-specific IP resolver must apply to tracking.');
ob_start();
$field->render();
$html = ob_get_clean();
sa_assert(strpos($html, 'Add Current IP (203.0.113.10)') !== false, 'The same filter must apply to the UI.');
remove_filter('simpleanalytics_client_ip', $override);

foreach (['invalid', '203.0.113.10, 10.0.0.1', '', null, []] as $invalid) {
$_SERVER['REMOTE_ADDR'] = $invalid;
sa_assert(! $rules->hasExcludedIp(), 'Invalid addresses must not match.');
}
unset($_SERVER['REMOTE_ADDR']);
sa_assert(! $rules->hasExcludedIp(), 'Missing addresses must not match.');
ob_start();
$field->render();
$html = ob_get_clean();
sa_assert(strpos($html, 'Add Current IP') === false, 'Hide the add button when the address is unavailable.');
});