Search by

sugarcommerce / network

arraypress

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.

Package info

github.com/SugarCommerce/network

Homepage

Issues

pkg:composer/sugarcommerce/network

Statistics

Installs: 62

Dependents: 1

Suggesters: 0

Stars: 0

1.7.0 2026-10-24 11:07 UTC

This package is auto-updated.

Last update: 2026-10-24 11:09:18 UTC


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