sugarcommerce / network
Request-level network utilities: spoof-resistant client IP behind proxies and CDNs, CIDR matching, GDPR anonymisation, user-agent and bot detection, referrer and campaign attribution. Zero dependencies.
Requires
- php: >=8.5
Requires (Dev)
- phpunit/phpunit: ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Request-level network utilities: spoof-resistant client IP behind proxies and CDNs, CIDR matching, GDPR anonymisation, user-agent and bot detection, referrer and campaign attribution. Zero dependencies.
Why
$_SERVER['HTTP_X_FORWARDED_FOR'] is a request header. Anyone can send it. Reading it without checking who the request came from means an attacker sets their own IP address โ which defeats every rate limit, ban list, fraud rule and geo-restriction built on top of it.
The fix is not complicated, but it has to be done in the right order: trust the forwarding header only when the connecting address is a proxy you actually trust, and then take the right hop from the chain. Almost every hand-rolled implementation gets one of those two wrong.
Features
- ๐ก๏ธ Spoof-resistant client IP โ forwarding headers honoured only from trusted proxies.
- โ๏ธ Cloudflare-aware โ the published edge ranges, plus country, ray id and Tor detection.
- ๐ฏ CIDR matching โ IPv4 and IPv6, plus IPv4 wildcards and allowlist validation.
- ๐ต๏ธ GDPR anonymisation โ last octet or last 80 bits zeroed, written as a CIDR if you like, or a keyed hash for a stable pseudonym.
- ๐ท๏ธ Hosts โ ports off bracketed IPv6, typed domains cleaned, subdomain checks on a label boundary, loopback hosts read whole.
- ๐งพ Visit records โ who downloaded, for a log: keyed hashes to count with, and a country, device and masked network to show.
- ๐ค Bot detection โ search crawlers, and the AI crawlers tracked by
ai.robots.txt. - ๐ฑ Device and browser โ with the client hints needed for the things a user agent no longer tells you.
- ๐ Attribution โ referrer classification and UTM campaigns, sanitised for storage.
Requirements
PHP 8.5+
Installation
composer require sugarcommerce/network
Client IP
use SugarCommerce\Network\{ClientIp, TrustedProxies}; // Behind Cloudflare $resolver = new ClientIp( TrustedProxies::cloudflare() ); // Behind your own load balancer $resolver = new ClientIp( TrustedProxies::none()->and_also( '10.0.0.0/8' ) ); // Direct โ no proxy, no forwarding headers honoured $resolver = new ClientIp(); $ip = $resolver->resolve(); // ?string
The rule this enforces: a forwarding header is only read when REMOTE_ADDR is inside a trusted range. Directly-connected traffic is taken at face value, whatever headers it sends.
TrustedProxies::cloudflare( [ '10.0.0.0/8' ] ); // Cloudflare plus your own TrustedProxies::local(); // RFC 1918 โ development TrustedProxies::none()->and_also( '10.0.0.5' )->using_headers( 'X-Real-IP' );
Headers matter as much as ranges. Only X-Forwarded-For is read by default, walked from the right, because every proxy appends to it. CF-Connecting-IP and X-Real-IP are single values that only the last hop sets โ your own nginx passes a client's CF-Connecting-IP straight through โ so name one with using_headers() only when the proxy you trust overwrites it. cloudflare() reads CF-Connecting-IP while its list is Cloudflare's alone, and drops it once you add ranges of your own.
Set this to match your actual deployment. Trusting every private range while sitting behind a CDN that does not strip headers is how the guard gets bypassed; trusting nothing while behind a load balancer means every visitor shares one IP.
Addresses
use SugarCommerce\Network\{Cidr, Ip}; Cidr::contains( '10.0.0.5', '10.0.0.0/8' ); // CIDR Cidr::contains_any( $ip, $ranges ); Cidr::matches( $ip, '192.168.1.*' ); // wildcard (IPv4 only) Cidr::matches( $ip, '192.168.1.10' ); // exact address Cidr::matches_any( $ip, $patterns ); Cidr::filter_valid( $operator_input ); // drop typos before storing Ip::is_private( $ip ); Ip::version( $ip ); // 4 or 6 Ip::anonymize( '203.0.113.42' ); // '203.0.113.0' Ip::anonymize( '2001:db8::1' ); // '2001:db8::' Ip::hash( $ip, $secret ); // stable pseudonym Ip::anonymized_cidr( '203.0.113.42' ); // '203.0.113.0/24'
anonymize() is the GDPR-safe form to log โ zeroing the last octet (or the last 80 bits) leaves you geography and rough network without an identifier.
anonymized_cidr() is the same masking written as the network it stands for, so a log reader cannot take 203.0.113.0 for somebody's address.
hash() is for when you need to count unique visitors without storing who they were. Keep the secret out of the database; with it, the mapping is reversible by brute force in seconds โ the address space is small.
Hosts
use SugarCommerce\Network\Host; Host::without_port( '[::1]:8080' ); // '::1' Host::without_port( 'shop.example.com:8443' ); // 'shop.example.com' Host::normalize( 'https://Shop.Example.com/page' ); // 'shop.example.com' Host::normalize( '203.0.113.5' ); // '' โ not a domain Host::is_within( 'a.example.com', 'example.com' ); // true Host::is_within( 'evil-example.com', 'example.com' ); // false Host::is_loopback( '[::1]:8080' ); // true Host::is_loopback( 'app.localhost:3000' ); // true Host::is_loopback( 'localhost.evil.example' ); // false
without_port() handles bracketed IPv6, where stripping :\d+$ leaves the brackets on, and leaves a bare IPv6 address alone, where it would eat the last group. normalize() is for a domain somebody typed โ they paste URLs โ and refuses IP addresses, single labels and anything DNS would not hold. is_within() compares on a label boundary.
is_loopback() is for the conveniences that must stay on the developer's machine โ a dev-only admin with no password, a plain-HTTP OAuth redirect to a native client. Loopback is localhost and any name under .localhost (RFC 6761), all of 127.0.0.0/8, and ::1 in any spelling, bracketed or bare, including IPv4 loopback mapped into IPv6. The whole value must be the host plus an optional numeric port, so localhost.evil.example, 127.0.0.1.nip.io and [::1]x are not loopback; nor are NAT64 spellings of 127.0.0.1, shorthand IPv4 (127.1) or a trailing dot. It reads a name, not a connection: a Host header is whatever the client sent, so pair it with the connection's address where it gates anything.
Blocklists
The rules a blocklist runs on, without the storage โ so the add form, the command line and the check cannot drift apart.
use SugarCommerce\Network\Blocklist; $rules = new Blocklist( fn( string $ip ): string => $hash->ip( $ip ) ); // how an address is stored $rules->pattern( 'email_domain', ' @Spam.Example ' ); // '@spam.example'; '' when not valid for its type $rules->pattern( 'ip', '203.0.113.9' ); // the stored hash foreach ( $rules->keys( $ip, $email ) as $type => $pattern ) { // ip, then email, then domain // look up ( $type, $pattern ) }
Types are ip, email and email_domain. A domain entry matches that exact domain. Entries are validated; lookups are not โ an address too odd to store is still looked up, so its domain still matches. Pass a second closure to store email addresses your own way.
Visits
What a download log or audit trail keeps about a visitor, without keeping the person.
use SugarCommerce\Network\{TrustedProxies, Visit}; $visit = Visit::capture( Visit::hmac( $secret ), // or fn( string $v ): string => your keyed hash TrustedProxies::cloudflare(), // whose forwarding headers to believe Visit::CLOUDFLARE_COUNTRY // a country header, believed from those proxies only; '' for none ); $visit->ip_hash; // for COUNT(DISTINCT โฆ) and spotting a shared link; '' when no address $visit->agent_hash; // the trimmed, capped agent; '' when none was sent $visit->summary(); // [ 'country' => 'IE', 'device' => 'Chrome 131 on macOS', 'network' => '203.0.113.0' ]
The address is the one ClientIp resolves, canonical, so one IPv6 spelling is one hash. The country header is read only when the connection came from a trusted proxy, and only two letters survive โ XX and Cloudflare's T1 do not. No agent is a null device; one nothing recognises is Unknown. Render network with Ip::anonymized_cidr().
User agents
use SugarCommerce\Network\UserAgent; $agent = UserAgent::current(); // trimmed, โค512 characters, '' when absent UserAgent::clean( $passed_in ); // same rules for an agent from elsewhere; null when blank UserAgent::browser( $agent ); // 'Chrome' UserAgent::browser_version( $agent ); UserAgent::os( $agent ); // 'macOS' UserAgent::device_type( $agent ); // 'mobile' | 'tablet' | 'desktop' UserAgent::describe( $agent ); // 'Chrome 131 on macOS' UserAgent::is_bot( $agent ); UserAgent::is_ai_bot( $agent ); // GPTBot, ClaudeBot, PerplexityBot, โฆ UserAgent::bot_name( $agent );
Three things no user agent can tell you, whatever the library:
- Windows 11 is undetectable. It reports
Windows NT 10.0, byte-identical to Windows 10 โ the token was frozen deliberately. Any pattern matching a build number looks plausible and never fires. - macOS reports 10.15.7 forever. Frozen at Catalina regardless of the actual version.
- iPads mostly claim to be Macs. "Request Desktop Website" has been the iPadOS default since 13.
For the first, use client hints:
use SugarCommerce\Network\ClientHints; ClientHints::windows_version(); // '11' โ needs the Accept-CH header ClientHints::is_mobile(); ClientHints::platform(); ClientHints::model();
Client hints only arrive if you ask for them. Send ClientHints::ACCEPT_CH as the Accept-CH response header, and the browser includes them on subsequent requests.
Attribution
use SugarCommerce\Network\Referrer; $source = Referrer::analyse( $_SERVER['HTTP_REFERER'] ?? '', $_GET ); $source->channel; // 'search' | 'social' | 'email' | 'paid' | 'campaign' | 'referral' | 'direct' $source->name; // 'Google', 'Twitter', a campaign name, a domain $source->term; // search term or utm_source $source->label(); // 'Google search for "widgets"' $source->to_array(); // the shape to persist against an order Referrer::is_internal( $referrer, $site_url );
UTM parameters win over the header, since a tag is deliberate and a Referer may have been trimmed by the browser.
Everything here is attacker-controlled, so everything here is sanitised. Only http and https referrers survive โ a stored javascript: URL rendered as a link is cross-site scripting one click away โ and the characters that break out of an HTML attribute are encoded or dropped. See SECURITY.md.
Cloudflare
use SugarCommerce\Network\Cloudflare; $cf = new Cloudflare(); $cf->is_active(); // request came through the edge $cf->country(); // 'IE' โ from CF-IPCountry $cf->ray_id(); $cf->is_tor(); $cf->tls_version();
Country here is free and instant, with no GeoIP database to ship or keep current โ but it is only present when the request actually came through Cloudflare, so check is_active() first.
Testing
composer install
composer test
320 tests, 556 assertions โ including every header-spoofing path, visit records behind proxies and over IPv6, loopback hosts and the names that only start like one, ports on bracketed and bare IPv6 hosts, typed domains, label-boundary matching, IPv4-mapped and NAT64 addresses, the user-agent patterns verified against MDN and vendor documentation, and the referrer sanitisation cases.
License
GPL-2.0-or-later