diff --git a/.github/config.php b/.github/config.php index 44af390..fa4a713 100644 --- a/.github/config.php +++ b/.github/config.php @@ -17,6 +17,11 @@ $http_ip_forward_header = $_SERVER['HTTP_X_TEST_IP_FORWARD_HEADER']; } +// Exercise removal of the visitor IP (any truthy value enables it). +if ($isTestServer && isset($_SERVER['HTTP_X_TEST_REMOVE_VISITOR_IP'])) { + $REMOVE_VISITOR_IP = $_SERVER['HTTP_X_TEST_REMOVE_VISITOR_IP']; +} + // Exercise cookie-allowlist filtering (comma-separated entries; empty value = explicit empty allowlist). if ($isTestServer && isset($_SERVER['HTTP_X_TEST_COOKIE_ALLOWLIST'])) { $COOKIE_ALLOWLIST = $_SERVER['HTTP_X_TEST_COOKIE_ALLOWLIST'] === '' diff --git a/README.md b/README.md index 11969f1..122ac6e 100644 --- a/README.md +++ b/README.md @@ -116,13 +116,55 @@ You may force the proxy script to use a particular User-Agent by editing the `$ ### Visitor IP forwarding -Because the proxy sits between your visitors and Matomo, it has to tell Matomo the real visitor IP — otherwise Matomo would record the proxy's IP. There are two ways this works: +Because the proxy sits between your visitors and Matomo, it has to tell Matomo the real visitor IP — otherwise Matomo would record the proxy's IP. There are three ways this works: - **Default — via `cip` + `token_auth`:** the proxy sends the visitor IP to Matomo as the `cip` tracking parameter, authorized by the `$TOKEN_AUTH` you configured (this is why the proxy user needs **write** or **admin** permission). Works out of the box with no Matomo-side configuration, for both single requests and bulk requests (the Matomo JavaScript tracker batches several actions into a single bulk request by default). - **Header-only — via `$http_ip_forward_header`:** set `$http_ip_forward_header` in `config.php` (for example to `X-Forwarded-For`) to forward the visitor IP in that header instead. In this mode the proxy injects **no** `cip`/`token_auth` at all and relies solely on the header for the visitor IP — so it doesn't even need a write/admin token. **This only works if Matomo is configured to trust the header:** both the web server in front of Matomo (Apache [mod_remoteip](https://httpd.apache.org/docs/2.4/mod/mod_remoteip.html), nginx [realip](https://www.nginx.com/resources/wiki/start/topics/examples/forwarded/)) **and** Matomo's trusted-proxy settings (`proxy_client_headers[]` / `proxy_ips[]` in its `config.ini.php`). If it isn't, Matomo records the proxy's IP for every visitor. +- **Not at all — via `$REMOVE_VISITOR_IP`:** the visitor IP is never sent to Matomo. See [Removing the visitor IP](#removing-the-visitor-ip) below. > ⚠️ **Breaking change:** previously `$http_ip_forward_header` was sent *in addition* to `cip`+`token_auth`; the proxy now treats it as the *sole* IP mechanism and injects nothing else. If you already set it, make sure Matomo's trusted-proxy configuration above is in place — otherwise leave it empty to keep using `cip`. +> ⚠️ **Behavior change:** two cases that previously ended up with no `cip` at all — so Matomo recorded the proxy's IP instead of the visitor's — now send the visitor IP as normal. This applies whether or not `$REMOVE_VISITOR_IP` is set: +> +> - a request whose only `cip` is empty or array-valued, which Matomo ignores in favour of the connection IP, so the proxy now treats it as absent; +> - a `POST` body in which the bulk marker `"requests"` appears at the very start, which Matomo does not treat as a bulk request — the proxy now agrees with it instead of forwarding the body unprocessed. + +### Removing the visitor IP + +Set `$REMOVE_VISITOR_IP = true;` in `config.php` to stop sending the visitor IP to Matomo altogether. The proxy forwards `cip=0.0.0.0` in its place — for single and bulk tracking requests alike — and never reads the visitor IP at all, so it cannot leak into a header either. + +Use this where the visitor IP must not reach Matomo even to be anonymised there, since Matomo's own IP anonymisation runs inside Matomo and the full IP would otherwise arrive first. + +Two things to check before you enable it: + +> ⚠️ **You still need the write/admin `$TOKEN_AUTH`** — including if you previously ran header-only mode without one. Matomo honors `cip` only on an authenticated request and otherwise records **nothing at all**, so a missing or read-only token discards your traffic instead of degrading it. This is easy to miss: a single request is rejected with HTTP 400, but a bulk request — what the JavaScript tracker sends by default — comes back as HTTP 200 with `{"status":"success","tracked":0,"invalid":N}`. + +> ⚠️ **Check TrackingSpamPrevention if you use its limits.** Every visit now reports `0.0.0.0`, so the first visitor to reach its *maximum actions per visit* limit gets `0.0.0.0/32` added to its blocked ranges — after which **every** visit is excluded and tracking stops entirely, silently. Its *included countries* setting behaves the same way, because the country now comes from Matomo's `Accept-Language` guess and is `xx` when it cannot be determined. Both are unset by default. If you use them, add `0.0.0.0` to `iprange_allowlist[]` under `[TrackingSpamPrevention]` in Matomo's `config.ini.php`, add `xx` to the country list, or leave the limits unset. + +#### Impact on your Matomo reports + +| Area | Effect | +|------|--------| +| Visits, pageviews, events, goals, ecommerce, campaigns, referrers, search engines, channels, downloads, outlinks, site search, content | Not directly affected | +| Location reports and maps, location-based segments, dashboards and scheduled reports | "Unknown" — though Matomo may still guess a country from the visitor's `Accept-Language` header | +| Visitor IP column and IP-based segments | `0.0.0.0` for every visit | +| IP exclusions, IP-based spam/bot blocking | Can no longer identify individual visitors | +| Provider / ISP reports | "Unknown" (the reverse DNS lookup is skipped) | +| Visits, Unique Visitors, Returning Visitors, bounce rate, visit duration | Less accurate **when cookies are unavailable**: Matomo uses the IP as part of its cookieless visitor fingerprint, so visitors sharing an OS, browser and language may be merged into one visit. With tracking cookies enabled the visitor ID takes precedence and the effect is limited. | +| Goals, funnels, ecommerce attribution | Affected only where the above merges separate visitors | +| QueuedTracking | Requests without a visitor ID are sharded by IP, so they all land in one queue instead of being spread across the configured number | + +#### Limits and edge cases + +- **The option covers the IP the proxy contributes, not one a caller sends deliberately.** A request supplying its own non-empty `cip` keeps it — the proxy adds neither a `cip` nor a token of its own — because Matomo honors `cip` only for a valid token holder, making such a request a deliberate decision to track a specific IP. The JavaScript tracker never sends `cip`, so ordinary visitor traffic is unaffected. To rule that out too, change those integrations or which tokens you issue; the proxy will not overrule them. +- An **empty or array-valued `cip`** is not such a decision — Matomo ignores those in favour of the connection IP — so the proxy drops it and sends the placeholder instead. Matomo resolves `cip` from the query string and the POST body together, with the query winning, and the proxy judges that same effective value. +- **Explicit location parameters** (`lat`, `long`, `city`, `region`, `country`) are not removed either, since Matomo already requires authentication for them. +- **Cookies** are forwarded unchanged unless you set `$COOKIE_ALLOWLIST` — see [Cookie forwarding](#cookie-forwarding) below. +- **`$http_ip_forward_header` is ignored** while this is on, since it would send the IP straight back. Clear it to remove the conflict; with `$DEBUG_PROXY` enabled the proxy also notes the conflict in the PHP error log. +- **The guarantee covers what the proxy sends.** Anything your own infrastructure adds to the outbound request between the proxy and Matomo — a reverse proxy, WAF or egress proxy inserting `X-Forwarded-For`, for example — is outside the proxy's control and needs checking separately. +- This assumes Matomo's **`tracking_requests_require_authentication`** is at its default of `1`. Set to `0`, Matomo honors an unauthenticated `cip`, so anything could submit an IP. +- The placeholder is `0.0.0.0` rather than **no `cip` at all** because Matomo falls back to the connection IP whenever `cip` is empty — sending nothing would record the proxy's own IP and report its location as real visitor data. + ### Cookie forwarding By default, the proxy forwards the visitor's entire `Cookie` header to Matomo unchanged. If your site also sets other cookies (session, consent-management, A/B testing, etc.) alongside Matomo's, those are forwarded too. @@ -142,6 +184,7 @@ To restrict this, set `$COOKIE_ALLOWLIST` in `config.php` to an array of cookie Some tracking parameters (`cip`, `cdt`, `cdo`, `country`, `region`, `city`, `lat`, `long`) are only honored by Matomo for an authenticated request. The proxy never lends its `$TOKEN_AUTH` to a request — or to an individual entry of a bulk request — that carries one of these override parameters or its own `token_auth`: - **Carries an override parameter, no token:** forwarded without the proxy's token, so Matomo rejects/skips it exactly as if it had been sent directly without authentication — rather than being silently tracked with the client-supplied override. To set these parameters legitimately, send your own valid `token_auth`. + - For `cip` this applies to a **non-empty** value only. Matomo ignores an empty or array-valued `cip` and falls back to the IP of the connection, so the proxy treats such a value as no `cip` at all: it is dropped and replaced with the IP the proxy would otherwise have sent. - **Carries its own `token_auth`:** the proxy adds no token of its own and lets the client's token govern. It still forwards the visitor IP as `cip`, so that token must have write access to authorize it (otherwise the request/entry is rejected). > ⚠️ **Behavior change:** if you add any of these parameters via `appendToTrackingUrl` (or otherwise) without your own `token_auth`, those requests are now **rejected** by Matomo. Previously the proxy stripped the parameter and tracked the rest of the hit; it no longer does. Send a valid `token_auth` if you need these parameters. @@ -176,6 +219,11 @@ if ($isTestServer && !empty($_SERVER['HTTP_X_TEST_IP_FORWARD_HEADER'])) { $http_ip_forward_header = $_SERVER['HTTP_X_TEST_IP_FORWARD_HEADER']; } +// Exercise removal of the visitor IP (any truthy value enables it). +if ($isTestServer && isset($_SERVER['HTTP_X_TEST_REMOVE_VISITOR_IP'])) { + $REMOVE_VISITOR_IP = $_SERVER['HTTP_X_TEST_REMOVE_VISITOR_IP']; +} + // Exercise cookie-allowlist filtering (comma-separated entries; empty value = explicit empty allowlist). if ($isTestServer && isset($_SERVER['HTTP_X_TEST_COOKIE_ALLOWLIST'])) { $COOKIE_ALLOWLIST = $_SERVER['HTTP_X_TEST_COOKIE_ALLOWLIST'] === '' diff --git a/config.php.example b/config.php.example index 2e21430..4edb519 100644 --- a/config.php.example +++ b/config.php.example @@ -50,6 +50,21 @@ $user_agent = ''; // $http_ip_forward_header = ''; +// Set this to true to stop sending the visitor IP to Matomo altogether: the proxy forwards the +// placeholder 0.0.0.0 as `cip` instead, and $http_ip_forward_header above is ignored. Use it where +// the visitor IP must not reach Matomo at all, not even to be anonymised there. +// +// Your $TOKEN_AUTH is still required. Matomo honors `cip` only on an authenticated request and +// otherwise records nothing at all, so a missing or read-only token discards your traffic instead of +// degrading it - and quietly: a single request is rejected with HTTP 400, but a bulk request (what +// the JS tracker sends by default) returns HTTP 200 with "tracked":0. +// +// Location and Provider reports then read "Unknown" and every visit shows 0.0.0.0. Read the impact +// list in README.md before enabling this: it also covers a TrackingSpamPrevention setting that can +// stop tracking entirely, and which requests keep their own values (one supplying its own `cip`, and +// explicit lat/long/city/region/country parameters). +$REMOVE_VISITOR_IP = false; + // By default, the proxy forwards the visitor's entire Cookie header to Matomo unchanged, which // also forwards unrelated site cookies (session, consent tools, A/B testing, etc.). // diff --git a/proxy.php b/proxy.php index ceba137..27f28fa 100644 --- a/proxy.php +++ b/proxy.php @@ -54,10 +54,25 @@ $user_agent = arrayValue($_SERVER, 'HTTP_USER_AGENT', ''); } +// Set to true to never send the visitor IP to Matomo, not even an anonymized one +if (! isset($REMOVE_VISITOR_IP)) { + $REMOVE_VISITOR_IP = false; +} + // ----------------------------- // DO NOT MODIFY BELOW THIS LINE // ----------------------------- +// Removing the visitor IP takes precedence: the header would send it straight back. Only reported +// when debugging - this is a permanent misconfiguration, so logging it per request would flood the +// error log of a busy proxy. +if ($REMOVE_VISITOR_IP && !empty($http_ip_forward_header)) { + if ($DEBUG_PROXY) { + error_log('$REMOVE_VISITOR_IP is enabled, so $http_ip_forward_header is ignored.'); + } + $http_ip_forward_header = ''; +} + // the HTTP response headers captured via fopen or curl $httpResponseHeaders = array(); @@ -120,9 +135,10 @@ // Without an IP-forward header, send the visitor IP as `cip` authorized by our token_auth - but // only when the client sent no token_auth or auth-protected param, so we never authorize its override. if (empty($http_ip_forward_header)) { - // Same bulk detection as Matomo's Requests::isUsingBulkRequest (both quote variants). - $isBulk = $rawPostBody !== '' - && (strpos($rawPostBody, '"requests"') !== false || strpos($rawPostBody, "'requests'") !== false); + // Same bulk detection as Matomo's Requests::isUsingBulkRequest, down to its truthy strpos + // check: a marker at offset 0 is not bulk there, so it must not be bulk here either. + $isBulk = !empty($rawPostBody) + && (strpos($rawPostBody, '"requests"') || strpos($rawPostBody, "'requests'")); if ($isBulk) { // Matomo reads the bulk token only from the JSON body, so pass any URL token_auth down to @@ -130,12 +146,19 @@ $clientUrlToken = (isset($_GET['token_auth']) && is_string($_GET['token_auth']) && $_GET['token_auth'] !== '') ? $_GET['token_auth'] : null; - $forwardPostBody = injectVisitIpIntoBulkRequest($rawPostBody, getVisitIp(), $TOKEN_AUTH, $clientUrlToken); + $forwardPostBody = injectVisitIpIntoBulkRequest($rawPostBody, getVisitIpToForward(), $TOKEN_AUTH, $clientUrlToken); // The batch token now lives in the JSON body; never also send one in the forwarded query. unset($_GET['token_auth']); } else { - if (!isset($_GET['cip']) && !isset($_POST['cip'])) { - $extraQueryParams['cip'] = getVisitIp(); + // Judge the same cip Matomo will read: it resolves tracker params as $_GET + $_POST + // (Tracker\RequestSet), so a cip key in the query wins over one in the body whatever + // its value. Checking the two separately would let an empty query cip hide behind a + // non-empty body cip that Matomo never reads. + if (!clientSuppliesVisitIp($_GET + $_POST)) { + // Drop an empty/array cip, which Matomo ignores anyway, so it can't clobber ours + // when $_GET is merged below (array_merge lets $_GET win on key collision). + unset($_GET['cip'], $_POST['cip']); + $extraQueryParams['cip'] = getVisitIpToForward(); } if (!clientProvidesAuthParams($_GET) && !clientProvidesAuthParams($_POST)) { // Drop any empty/array token_auth the client sent so it can't clobber ours when @@ -255,6 +278,18 @@ function getVisitIp() return arrayValue($_SERVER, 'REMOTE_ADDR'); } +function getVisitIpToForward() +{ + global $REMOVE_VISITOR_IP; + + // Matomo falls back to the connection IP - ours - when cip is empty, so send a placeholder. + if ($REMOVE_VISITOR_IP) { + return '0.0.0.0'; + } + + return getVisitIp(); +} + function transformHeaderLine($headerLine) { // if we're not on an https protocol, make sure cookies do not have 'secure;' @@ -383,7 +418,7 @@ function getHttpContentAndStatus($url, $timeout, $user_agent, $postBody = '') // Forward the visitor IP via the configured header, for every request method. if (!empty($http_ip_forward_header)) { - $visitIp = getVisitIp(); + $visitIp = getVisitIpToForward(); $stream_options['http']['header'][] = "$http_ip_forward_header: $visitIp"; } @@ -468,6 +503,16 @@ function arrayValue($array, $key, $value = null) return $value; } +function clientSuppliesVisitIp($params) +{ + // Only a non-empty string cip is read by Matomo; an empty or array value makes it fall back to + // the connection IP instead, so we must not treat those as a client-supplied IP either. + return is_array($params) + && isset($params['cip']) + && is_string($params['cip']) + && $params['cip'] !== ''; +} + function clientProvidesAuthParams($params) { if (!is_array($params)) { @@ -480,9 +525,14 @@ function clientProvidesAuthParams($params) return true; } + // Same reasoning for cip, which Matomo also reads string-only. + if (clientSuppliesVisitIp($params)) { + return true; + } + // Params Matomo only honors for an authenticated request. Checked by key presence // (type-agnostic) so it cannot be evaded with array/empty values. - $overrideParams = array('cdt', 'cdo', 'country', 'region', 'city', 'lat', 'long', 'cip'); + $overrideParams = array('cdt', 'cdo', 'country', 'region', 'city', 'lat', 'long'); foreach ($overrideParams as $param) { if (array_key_exists($param, $params)) { @@ -500,7 +550,7 @@ function withProxyTracking( $tokenAuth, $includeProxyToken ) { - // The entry is clean (no cip of its own), so set the real visitor IP. + // The entry is clean (no cip of its own), so set the IP we forward. $params['cip'] = $visitIp; // Lend our token only when the caller decided to; otherwise a client token authorizes the cip. diff --git a/tests/ProxyTest.php b/tests/ProxyTest.php index b66e91a..8829c3e 100644 --- a/tests/ProxyTest.php +++ b/tests/ProxyTest.php @@ -192,6 +192,35 @@ public function test_plugin_config_php_proxied_correctly() $this->assertEquals($expected, $responseBody); } + public function test_empty_query_cip_wins_over_a_body_cip_and_is_replaced() + { + // Matomo resolves tracker params as $_GET + $_POST, so the empty query cip is the one it + // would read - the non-empty body cip never reaches it and must not suppress our injection. + $response = $this->send( + 'idsite=1&cip=', + null, + null, + ['content-type' => 'application/x-www-form-urlencoded'], + null, + 'POST', + 'cip=6.6.6.6' + ); + + $responseBody = $this->getBody($response); + + $expected = << '127.0.0.1', + 'token_auth' => '', + 'idsite' => '1', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + $this->assertStringNotContainsString('6.6.6.6', $responseBody); + } + public function test_post_requests_are_proxied_correctly() { $response = $this->send('foo=bar', null, null, ['content-type' => 'application/x-www-form-urlencoded'], null, 'POST', 'baz=buz'); @@ -403,6 +432,45 @@ public function test_array_token_auth_with_override_does_not_receive_proxy_token $this->assertStringNotContainsString('', $responseBody); } + public function test_empty_client_cip_is_replaced_by_the_visitor_ip() + { + // Matomo reads cip string-only and falls back to the connection IP for an empty value, so an + // empty cip is "no cip" - treating it as a client override would record the proxy's own IP. + $response = $this->send('idsite=1&cip='); + + $responseBody = $this->getBody($response); + + $expected = << '127.0.0.1', + 'token_auth' => '', + 'idsite' => '1', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_array_client_cip_is_replaced_by_the_visitor_ip() + { + $response = $this->send('idsite=1&cip[]=6.6.6.6'); + + $responseBody = $this->getBody($response); + + $expected = << '127.0.0.1', + 'token_auth' => '', + 'idsite' => '1', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + $this->assertStringNotContainsString('6.6.6.6', $responseBody); + } + public function test_post_requests_forward_body_rebuilt_from_parsed_post() { $response = $this->send( @@ -1176,6 +1244,653 @@ public function test_non_array_cookie_allowlist_fails_closed_and_drops_all_cooki $this->assertEquals($expected, $responseBody); } + public function test_visitor_ip_removal_sends_placeholder_cip_with_proxy_token() + { + $response = $this->send('foo=bar', null, null, ['X-Test-Remove-Visitor-Ip' => '1']); + + $responseBody = $this->getBody($response); + + $expected = << '0.0.0.0', + 'token_auth' => '', + 'foo' => 'bar', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_visitor_ip_removal_ignores_client_ip_headers() + { + $headers = [ + 'X-Forwarded-For' => '8.8.8.8', + 'Client-Ip' => '9.9.9.9', + 'Cf-Connecting-Ip' => '6.6.6.6', + 'X-Test-Remove-Visitor-Ip' => '1', + ]; + $response = $this->send('foo=bar', null, null, $headers); + + $responseBody = $this->getBody($response); + + // The headers getVisitIp() would have read are neither used nor forwarded, so no header + // block is echoed at all. + $expected = << '0.0.0.0', + 'token_auth' => '', + 'foo' => 'bar', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + $this->assertStringNotContainsString('8.8.8.8', $responseBody); + $this->assertStringNotContainsString('9.9.9.9', $responseBody); + $this->assertStringNotContainsString('6.6.6.6', $responseBody); + } + + public function test_visitor_ip_removal_leaves_a_client_supplied_cip_untouched() + { + $response = $this->send('idsite=1&cip=6.6.6.6&foo=bar', null, null, ['X-Test-Remove-Visitor-Ip' => '1']); + + $responseBody = $this->getBody($response); + + // A client cip needs a valid token_auth of its own to be honored, so it is a deliberate + // request to track a specific IP: the proxy adds neither a placeholder nor its token. + $expected = << '1', + 'cip' => '6.6.6.6', + 'foo' => 'bar', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_visitor_ip_removal_leaves_a_client_supplied_cip_in_the_post_body_untouched() + { + $response = $this->send( + 'foo=bar&raw_input=1', + null, + null, + ['content-type' => 'application/x-www-form-urlencoded', 'X-Test-Remove-Visitor-Ip' => '1'], + null, + 'POST', + 'cip=6.6.6.6&action_name=x' + ); + + $responseBody = $this->getBody($response); + + $expected = << 'bar', + 'raw_input' => '1', +) +array ( + 'cip' => '6.6.6.6', + 'action_name' => 'x', +) +RAW: cip=6.6.6.6&action_name=x +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_visitor_ip_removal_replaces_empty_client_cip_with_placeholder() + { + $response = $this->send('idsite=1&cip=', null, null, ['X-Test-Remove-Visitor-Ip' => '1']); + + $responseBody = $this->getBody($response); + + // Matomo ignores an empty cip and falls back to the connection IP - the proxy's - so leaving + // it in place would record the proxy's IP and location as the visitor's. + $expected = << '0.0.0.0', + 'token_auth' => '', + 'idsite' => '1', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_visitor_ip_removal_replaces_array_client_cip_with_placeholder() + { + $response = $this->send('idsite=1&cip[]=6.6.6.6', null, null, ['X-Test-Remove-Visitor-Ip' => '1']); + + $responseBody = $this->getBody($response); + + // Matomo reads cip string-only, so an array value is ignored there too. + $expected = << '0.0.0.0', + 'token_auth' => '', + 'idsite' => '1', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + $this->assertStringNotContainsString('6.6.6.6', $responseBody); + } + + public function test_visitor_ip_removal_replaces_empty_query_cip_that_wins_over_a_body_cip() + { + $headers = [ + 'content-type' => 'application/x-www-form-urlencoded', + 'X-Forwarded-For' => '8.8.8.8', + 'X-Test-Remove-Visitor-Ip' => '1', + ]; + $response = $this->send('idsite=1&cip=', null, null, $headers, null, 'POST', 'cip=6.6.6.6'); + + $responseBody = $this->getBody($response); + + // Matomo would read the empty query cip and fall back to the connection IP - the proxy's - + // so judging the query and body separately would let the body cip hide the gap. + $expected = << '0.0.0.0', + 'token_auth' => '', + 'idsite' => '1', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + $this->assertStringNotContainsString('6.6.6.6', $responseBody); + $this->assertStringNotContainsString('8.8.8.8', $responseBody); + } + + public function test_visitor_ip_removal_replaces_array_query_cip_that_wins_over_a_body_cip() + { + $headers = [ + 'content-type' => 'application/x-www-form-urlencoded', + 'X-Forwarded-For' => '8.8.8.8', + 'X-Test-Remove-Visitor-Ip' => '1', + ]; + $response = $this->send('idsite=1&cip[]=1.2.3.4', null, null, $headers, null, 'POST', 'cip=6.6.6.6'); + + $responseBody = $this->getBody($response); + + $expected = << '0.0.0.0', + 'token_auth' => '', + 'idsite' => '1', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + $this->assertStringNotContainsString('1.2.3.4', $responseBody); + $this->assertStringNotContainsString('6.6.6.6', $responseBody); + } + + public function test_visitor_ip_removal_keeps_a_body_cip_when_the_query_has_none() + { + $headers = [ + 'content-type' => 'application/x-www-form-urlencoded', + 'X-Test-Remove-Visitor-Ip' => '1', + ]; + $response = $this->send('idsite=1', null, null, $headers, null, 'POST', 'cip=6.6.6.6'); + + $responseBody = $this->getBody($response); + + // With no cip in the query, the body cip is the one Matomo reads, so it counts as deliberate. + $expected = << '1', +) +array ( + 'cip' => '6.6.6.6', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + $this->assertStringNotContainsString('0.0.0.0', $responseBody); + $this->assertStringNotContainsString('', $responseBody); + } + + public function test_visitor_ip_removal_bulk_replaces_empty_client_cip_with_placeholder() + { + $body = '{"requests":["?idsite=1&rec=1&action_name=one&cip="]}'; + + $response = $this->sendBulkWithoutVisitorIp($body); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + // The entry counts as clean, so it gets the placeholder and the batch keeps its top-level + // token - an empty cip must not demote the batch to per-entry tokens. + $this->assertStringContainsString('cip=0.0.0.0', $responseBody); + $this->assertStringContainsString('"token_auth":""', $responseBody); + } + + public function test_visitor_ip_removal_lets_a_client_token_authorize_the_placeholder() + { + $headers = [ + 'X-Forwarded-For' => '8.8.8.8', + 'X-Test-Remove-Visitor-Ip' => '1', + ]; + $response = $this->send('idsite=1&token_auth=client-token', null, null, $headers); + + $responseBody = $this->getBody($response); + + // The client authenticates, so the proxy withholds its own token - the placeholder it added + // is authorized by the client's token instead. + $expected = << '0.0.0.0', + 'idsite' => '1', + 'token_auth' => 'client-token', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + $this->assertStringNotContainsString('', $responseBody); + $this->assertStringNotContainsString('8.8.8.8', $responseBody); + } + + public function test_visitor_ip_removal_keeps_explicit_location_params() + { + $response = $this->send('idsite=1&lat=1&long=2', null, null, ['X-Test-Remove-Visitor-Ip' => '1']); + + $responseBody = $this->getBody($response); + + // The option removes the IP, not explicitly supplied location parameters. + $expected = << '0.0.0.0', + 'idsite' => '1', + 'lat' => '1', + 'long' => '2', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_visitor_ip_removal_bulk_injects_placeholder_and_top_level_token() + { + $body = '{"requests":["?idsite=1&rec=1&action_name=one"],"send_image":0}'; + + $response = $this->sendBulkWithoutVisitorIp($body); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('action_name=one&cip=0.0.0.0', $responseBody); + $this->assertStringContainsString('"token_auth":""', $responseBody); + $this->assertStringNotContainsString('127.0.0.1', $responseBody); + } + + public function test_visitor_ip_removal_bulk_leaves_an_entry_with_its_own_cip_untouched() + { + $body = '{"requests":["?idsite=1&rec=1&cip=6.6.6.6"]}'; + + $response = $this->sendBulkWithoutVisitorIp($body); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + // Entry forwarded verbatim and given no token, exactly as without the option. + $this->assertStringContainsString('"?idsite=1&rec=1&cip=6.6.6.6"', $responseBody); + $this->assertStringNotContainsString('', $responseBody); + $this->assertStringNotContainsString('0.0.0.0', $responseBody); + } + + public function test_visitor_ip_removal_bulk_injects_placeholder_only_into_clean_entries() + { + $body = '{"requests":["?idsite=1&rec=1&action_name=clean","?idsite=1&rec=1&cip=6.6.6.6"]}'; + + $response = $this->sendBulkWithoutVisitorIp($body); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('action_name=clean&cip=0.0.0.0', $responseBody); + $this->assertStringContainsString('"?idsite=1&rec=1&cip=6.6.6.6"', $responseBody); + $this->assertStringNotContainsString('127.0.0.1', $responseBody); + } + + public function test_visitor_ip_removal_bulk_injects_placeholder_into_clean_object_entry() + { + $body = '{"requests":[{"idsite":"1","rec":"1","action_name":"clean"}]}'; + + $response = $this->sendBulkWithoutVisitorIp($body); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('"action_name":"clean","cip":"0.0.0.0"', $responseBody); + $this->assertStringContainsString('"token_auth":""', $responseBody); + $this->assertStringNotContainsString('127.0.0.1', $responseBody); + } + + public function test_bulk_marker_at_offset_zero_is_not_treated_as_bulk() + { + // Matomo's Requests::isUsingBulkRequest() uses a truthy strpos check, so a marker at offset + // 0 is not a bulk request there. The proxy must agree, or it skips cip injection on a + // request Matomo tracks as an ordinary one. + $body = '"requests"=x&idsite=1&rec=1'; + + $response = $this->send( + 'raw_input=1', + null, + null, + ['content-type' => 'application/x-www-form-urlencoded'], + null, + 'POST', + $body + ); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString("'cip' => '127.0.0.1'", $responseBody); + $this->assertStringContainsString("'token_auth' => ''", $responseBody); + } + + public function test_visitor_ip_removal_bulk_marker_at_offset_zero_gets_placeholder() + { + $body = '"requests"=x&idsite=1&rec=1'; + + $response = $this->send( + 'raw_input=1', + null, + null, + ['content-type' => 'application/x-www-form-urlencoded', 'X-Test-Remove-Visitor-Ip' => '1'], + null, + 'POST', + $body + ); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString("'cip' => '0.0.0.0'", $responseBody); + $this->assertStringNotContainsString('127.0.0.1', $responseBody); + } + + public function test_visitor_ip_removal_forwards_undecodable_bulk_body_unchanged() + { + // Documented limitation: a body the proxy cannot decode is passed through as-is (Matomo + // cannot parse it either, so it tracks nothing). The proxy must still contribute no token. + $body = '{"requests":["?idsite=1&rec=1&cip=6.6.6.6"'; + + $response = $this->sendBulkWithoutVisitorIp($body); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('RAW: ' . $body, $responseBody); + $this->assertStringNotContainsString('', $responseBody); + $this->assertStringNotContainsString('8.8.8.8', $responseBody); + } + + public function test_visitor_ip_removal_leaves_bulk_entry_without_query_unchanged() + { + // No '?' means no query for either side's parse_url, so Matomo discards the entry. The proxy + // must forward it as-is rather than trying to rewrite something it cannot parse. + $body = '{"requests":["idsite=1&rec=1&cip=6.6.6.6"]}'; + + $response = $this->sendBulkWithoutVisitorIp($body); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('"idsite=1&rec=1&cip=6.6.6.6"', $responseBody); + $this->assertStringNotContainsString('0.0.0.0', $responseBody); + $this->assertStringNotContainsString('8.8.8.8', $responseBody); + // The batch still counts as clean, so it gets a batch-level token. That authorizes nothing + // here: Matomo drops an entry it cannot parse into params before building a request from it. + $this->assertStringContainsString('"token_auth":""', $responseBody); + } + + public function test_visitor_ip_removal_bulk_list_entry_gets_placeholder_alongside_its_values() + { + // A list-typed entry is an array to both sides, so Matomo reads its keys as params: our + // placeholder lands under 'cip' and the nested string stays an inert '0' param. + $body = '{"requests":[["?idsite=1&rec=1&cip=6.6.6.6"]]}'; + + $response = $this->sendBulkWithoutVisitorIp($body); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('{"0":"?idsite=1&rec=1&cip=6.6.6.6","cip":"0.0.0.0"}', $responseBody); + $this->assertStringNotContainsString('8.8.8.8', $responseBody); + } + + public function test_visitor_ip_removal_does_not_touch_post_body_on_opt_out_endpoint() + { + $response = $this->send( + 'module=CoreAdminHome&action=optOut&raw_input=1', + null, + null, + [ + 'content-type' => 'application/x-www-form-urlencoded', + 'X-Forwarded-For' => '8.8.8.8', + 'X-Test-Remove-Visitor-Ip' => '1', + ], + '/matomo-proxy.php', + 'POST', + 'cip=6.6.6.6&other=1' + ); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + // Not a tracking endpoint: the proxy contributes no cip and no token either way, and must + // not rewrite the client's body. + $this->assertStringContainsString('RAW: cip=6.6.6.6&other=1', $responseBody); + $this->assertStringNotContainsString('0.0.0.0', $responseBody); + $this->assertStringNotContainsString('', $responseBody); + $this->assertStringNotContainsString('8.8.8.8', $responseBody); + } + + public function test_visitor_ip_removal_does_not_touch_post_body_on_plugin_config_endpoint() + { + $response = $this->send( + 'idsite=35&raw_input=1', + null, + null, + [ + 'content-type' => 'application/x-www-form-urlencoded', + 'X-Forwarded-For' => '8.8.8.8', + 'X-Test-Remove-Visitor-Ip' => '1', + ], + '/plugins/HeatmapSessionRecording/configs.php', + 'POST', + 'cip=6.6.6.6&other=1' + ); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('RAW: cip=6.6.6.6&other=1', $responseBody); + $this->assertStringNotContainsString('0.0.0.0', $responseBody); + $this->assertStringNotContainsString('', $responseBody); + $this->assertStringNotContainsString('8.8.8.8', $responseBody); + } + + public function test_visitor_ip_removal_takes_precedence_over_forward_header() + { + $headers = [ + 'X-Test-Ip-Forward-Header' => 'X-Forwarded-For', + 'X-Test-Remove-Visitor-Ip' => '1', + ]; + $response = $this->send('idsite=1&action_name=clean', null, null, $headers); + + $responseBody = $this->getBody($response); + + // The forward header is ignored, so the proxy is back on the cip path - with the placeholder. + $expected = << '0.0.0.0', + 'token_auth' => '', + 'idsite' => '1', + 'action_name' => 'clean', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_visitor_ip_removal_takes_precedence_over_forward_header_for_bulk() + { + $headers = [ + 'content-type' => 'application/x-www-form-urlencoded', + 'X-Test-Ip-Forward-Header' => 'X-Forwarded-For', + 'X-Test-Remove-Visitor-Ip' => '1', + ]; + $response = $this->send( + 'raw_input=1', + null, + null, + $headers, + null, + 'POST', + '{"requests":["?idsite=1&rec=1&action_name=one"]}' + ); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('action_name=one&cip=0.0.0.0', $responseBody); + $this->assertStringNotContainsString('X_FORWARDED_FOR', $responseBody); + } + + public function test_forward_header_mode_sends_ip_header_on_plugin_config_endpoint() + { + // Baseline for the next test: without removal, the header is forwarded on this endpoint too. + $headers = ['X-Test-Ip-Forward-Header' => 'X-Forwarded-For']; + $response = $this->send('idsite=35&trackerid=123456', null, null, $headers, '/plugins/HeatmapSessionRecording/configs.php'); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString("'X_FORWARDED_FOR' => '127.0.0.1'", $responseBody); + } + + public function test_visitor_ip_removal_sends_no_ip_header_on_plugin_config_endpoint() + { + $headers = [ + 'X-Test-Ip-Forward-Header' => 'X-Forwarded-For', + 'X-Test-Remove-Visitor-Ip' => '1', + ]; + $response = $this->send('idsite=35&trackerid=123456', null, null, $headers, '/plugins/HeatmapSessionRecording/configs.php'); + + $responseBody = $this->getBody($response); + + // No IP header, and no cip either - this endpoint is not a tracking endpoint. + $expected = << '35', + 'trackerid' => '123456', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_visitor_ip_removal_sends_no_ip_header_on_opt_out_endpoint() + { + $headers = [ + 'X-Test-Ip-Forward-Header' => 'X-Forwarded-For', + 'X-Test-Remove-Visitor-Ip' => '1', + ]; + $response = $this->send('module=CoreAdminHome&action=optOut', null, null, $headers, '/matomo-proxy.php'); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringNotContainsString('X_FORWARDED_FOR', $responseBody); + } + + public function test_visitor_ip_removal_still_serves_matomo_js() + { + // Smoke test only: the fake matomo.js is a static file, so it cannot echo the headers the + // proxy sent. That the option suppresses the IP-forward header on non-tracking requests is + // covered by test_visitor_ip_removal_sends_no_ip_header_on_{plugin_config,opt_out}_endpoint. + $response = $this->send(null, null, null, [ + 'X-Test-Ip-Forward-Header' => 'X-Forwarded-For', + 'X-Test-Remove-Visitor-Ip' => '1', + ]); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('this is matomo.js', $response->getBody()->getContents()); + $this->assertEquals('application/javascript; charset=UTF-8', $response->getHeader('Content-Type')[0]); + } + + public function test_falsy_remove_visitor_ip_keeps_forwarding_the_visitor_ip() + { + $response = $this->send('foo=bar', null, null, ['X-Test-Remove-Visitor-Ip' => '0']); + + $responseBody = $this->getBody($response); + + $expected = << '127.0.0.1', + 'token_auth' => '', + 'foo' => 'bar', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_truthy_string_remove_visitor_ip_enables_removal() + { + $response = $this->send('foo=bar', null, null, ['X-Test-Remove-Visitor-Ip' => 'yes']); + + $responseBody = $this->getBody($response); + + $expected = << '0.0.0.0', + 'token_auth' => '', + 'foo' => 'bar', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + private function sendBulkWithoutVisitorIp($body) + { + return $this->send( + 'raw_input=1', + null, + null, + [ + 'content-type' => 'application/x-www-form-urlencoded', + // A visitor IP getVisitIp() would pick up, so the assertions prove the header + // sources are bypassed and not merely that REMOTE_ADDR is unused. + 'X-Forwarded-For' => '8.8.8.8', + 'X-Test-Remove-Visitor-Ip' => '1', + ], + null, + 'POST', + $body + ); + } + private function send($query = null, DateTime $modifiedSince = null, $matomoUrl = null, $addHeaders = null, $path = null, $method = 'GET', $body = null, $forceIpV6 = false) { diff --git a/tests/server/matomo.php b/tests/server/matomo.php index 853d186..6893d6d 100644 --- a/tests/server/matomo.php +++ b/tests/server/matomo.php @@ -23,7 +23,7 @@ } $headers = array(); -foreach (array('DNT', 'X_DO_NOT_TRACK', 'X_FORWARDED_FOR', 'COOKIE') as $headerName) { +foreach (array('DNT', 'X_DO_NOT_TRACK', 'X_FORWARDED_FOR', 'CLIENT_IP', 'CF_CONNECTING_IP', 'COOKIE') as $headerName) { if (isset($_SERVER['HTTP_' . $headerName])) { $headers[$headerName] = $_SERVER['HTTP_' . $headerName]; }