Conversation
Some organisations must ensure the visitor IP never reaches the analytics application at all, not even to be anonymised there. Matomo anonymises the IP inside Matomo, so the full IP arrives first. $REMOVE_VISITOR_IP moves that boundary out to the proxy. When enabled the proxy forwards the placeholder cip=0.0.0.0 instead of the visitor IP and ignores $http_ip_forward_header. The placeholder is sent rather than nothing because Matomo only falls back to the connection IP when cip is empty - omitting it would make Matomo record the proxy's own IP and report the proxy's location as real visitor data. The option governs the IP the proxy contributes. A request supplying its own cip is forwarded untouched: Matomo honors a cip only on an authenticated request, so such a request is a deliberate decision to track a specific IP, and clientProvidesAuthParams() already withholds our token from it. This needs no change to the bulk path at all - clean entries already receive the forwarded IP, cip-bearing entries are already left alone. getVisitIp() now has a single caller, getVisitIpToForward(), so the real IP cannot leave the proxy by any path when the option is on. Also aligns the bulk-request detection with Matomo's Requests::isUsingBulkRequest(), which uses a truthy strpos check: a "requests" marker at offset 0 is not a bulk request there, so treating it as one here meant skipping cip injection on a request Matomo tracks as an ordinary one, silently losing the visitor IP. The option is off by default; existing deployments are unaffected.
Matomo reads `cip` string-only and falls back to the connection IP for an empty or array value, without raising an error. The proxy however counted any `cip` key as client-supplied, so appending `&cip=` suppressed the injected IP entirely and Matomo recorded the proxy's own address - and geolocated its datacentre - as the visitor's. With $REMOVE_VISITOR_IP that defeats the placeholder the option exists to guarantee; without it, the real visitor IP is silently lost. clientSuppliesVisitIp() now applies the same non-empty-string rule the file already applies to token_auth two lines above, in both the injection guard and clientProvidesAuthParams(). A value Matomo would ignore is dropped from $_GET/$_POST so it cannot win the merge; a real client cip is still forwarded untouched and still receives no token from us. In a bulk batch an empty cip additionally made the entry look auth-protected, demoting the whole batch from a top-level token to per-entry tokens, which a server with bulk_requests_require_authentication rejects outright. The $http_ip_forward_header conflict warning is now only logged when $DEBUG_PROXY is on: it reports a permanent misconfiguration, so a busy proxy was writing one synchronous log line per request indefinitely. Docs: an unauthenticated cip is only an HTTP 400 for a single request; a bulk request - what the JS tracker sends by default - returns HTTP 200 with "tracked":0, so there is no error status to alert on. Also names the TrackingSpamPrevention iprange_allowlist[] ini key rather than implying a UI setting, notes its excluded/included_countries hazard, and corrects the ban threshold from "exceed" to "reach".
…rage The "Auth-protected tracking parameters" section listed cip among the parameters that always withhold the proxy token, which stopped being true for an empty or array cip in the previous commit. Qualifies it as non-empty, matching clientProvidesAuthParams(). Adds the behaviour-change note the README already uses for this kind of change. Two cases previously ended up with no cip at all, so Matomo recorded the proxy's IP rather than the visitor's, and both now send the visitor IP whether or not $REMOVE_VISITOR_IP is set: an empty or array-valued cip, and a POST body whose bulk marker sits at offset 0. $REMOVE_VISITOR_IP now takes the `if (! isset($X))` default shape used by every neighbouring option, and the conflict resolution - which is logic, not a default - moves below the DO NOT MODIFY marker. Regression coverage for the bulk shapes the proxy must not try to rewrite (undecodable body, entry without a query, list-typed entry) and for POST bodies on the two non-tracking endpoints. The bulk helper now sends an X-Forwarded-For, so those assertions prove getVisitIp()'s header sources are bypassed rather than only that REMOTE_ADDR is unused. Drops the X_REAL_IP echo entry, which no test used and getVisitIp() never reads.
sgiehl
force-pushed
the
dev-20467
branch
2 times, most recently
from
August 4, 2026 13:29
a19ab6b to
6286c16
Compare
The README said a warning is written to the error log for as long as $REMOVE_VISITOR_IP and $http_ip_forward_header are both configured, which stopped being true when that error_log() was gated on $DEBUG_PROXY. It also claimed setting the option was "the only setup step" immediately above two warnings about things to change in Matomo. Both descriptions had grown by accumulation, with the two facts most likely to take tracking down - the write-token requirement and the TrackingSpamPrevention limits - separated by around 25 lines of qualifying prose. Reordered to lead with what the option does and its one setup step, then those two checks, then the impact table, then the limits and edge cases as a scannable list. No claim changed except the error-log one; the rest was re-verified against the implementation and against Matomo core. Also notes that the guarantee covers what the proxy sends: anything the operator's own infrastructure inserts between the proxy and Matomo, such as a reverse proxy or WAF adding X-Forwarded-For, is outside its control. That was the one remaining route for an IP to arrive and had never been written down. Says a request keeps its own cip and receives no token from us, rather than that it is "forwarded untouched" - a single request's query is re-encoded through http_build_query(), so only the values are preserved, not the bytes. The empty/array-cip replacement is no longer stated as unconditional either: the decision is made across $_GET and $_POST together, so a non-empty cip in either one leaves both alone. config.php.example follows the same order and loses the note about which truthy values enable the flag, which described PHP rather than the feature. Test changes alongside: the no-query bulk entry test now asserts that the batch still receives a batch-level token, which authorizes nothing because Matomo drops such an entry before building a request from it; the matomo.js test states what it cannot cover, since the fake matomo.js is a static file and cannot echo the headers the proxy sent; and a new test pins the case where a client's own token_auth authorizes the placeholder we injected, which is the security-relevant intersection of the two features.
tzi
reviewed
Aug 5, 2026
tzi
reviewed
Aug 5, 2026
Matomo builds its tracker params as $_GET + $_POST (Tracker\RequestSet), so a
cip key in the query wins over one in the body whatever its value. The proxy
asked clientSuppliesVisitIp() about the two arrays separately and skipped the
block if either said yes, so a non-empty body cip could hide an empty query
cip that Matomo would actually read:
POST /matomo.php?idsite=1&cip= body: cip=6.6.6.6
Both were forwarded untouched, Matomo resolved cip to '' and fell back to the
connection IP, and the visit was recorded against the proxy - the outcome the
empty-cip handling exists to prevent. No visitor IP leaked, but the fix did
not hold for that shape.
Judging $_GET + $_POST mirrors the expression Matomo itself uses, so the two
cannot disagree. Verified against the four relevant shapes: an empty or array
query cip now gets the placeholder even when the body carries a non-empty
one, while a body cip with no query cip is still honoured untouched.
clientProvidesAuthParams() needs no matching change: the unset above it drops
both cip entries first, so the token decision no longer sees them.
Reported by tzi in review.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
Some organisations are required to ensure the visitor IP never reaches the analytics application at all — not even to be anonymised there. Matomo's own IP anonymisation happens inside Matomo, so the full IP arrives there first. This adds an opt-in Tracker Proxy option that moves that boundary out to the proxy.
What this adds
$REMOVE_VISITOR_IP(defaultfalse). When enabled, the proxy sendscip=0.0.0.0instead of the visitor IP — for single and bulk tracking requests alike — never reads the visitor IP at all, and ignores$http_ip_forward_header(which would send it straight back), logging a warning while both are configured.getVisitIp()now has exactly one caller,getVisitIpToForward(), which is what makes "the real IP cannot leave the proxy" checkable at a glance.Why a placeholder rather than sending nothing
Matomo's
Request::getIpString()falls back to the connection IP whenevercipis empty. Omitting it would therefore make Matomo record the proxy's own IP for every visit, and Location/Provider reports would show the proxy's datacentre as if it were real visitor data.0.0.0.0is accepted verbatim, geolocates to Unknown, and the Provider plugin skips its reverse-DNS lookup for addresses ending in.0.Scope: the IP the proxy contributes
A request that supplies its own
cipis forwarded untouched. Matomo honours aciponly on an authenticated request, so such a request is a deliberate decision to track a specific IP made by something holding a valid token — andclientProvidesAuthParams()already withholds the proxy's token from it. The browser JS tracker never sendscip, so ordinary visitor traffic is unaffected.This is what keeps the change small: the bulk path needs no modification at all. Clean entries already receive the forwarded IP (now the placeholder);
cip-bearing entries are already left alone. An earlier revision of this branch tried to strip client-suppliedcipvalues instead, and every leak found in review was a failure mode of that stripping — surgically editing input the proxy only partly understands. Not doing it is both safer and ~50 lines smaller.Drive-by fix: bulk detection now matches Matomo
Matomo's
Requests::isUsingBulkRequest()uses a truthystrposcheck, so a"requests"marker at offset 0 is not a bulk request there. The proxy used!== falseand disagreed — meaning such a request skippedcipinjection entirely while Matomo tracked it as an ordinary request, silently losing the visitor IP. Pre-existing bug, fixed here with a regression test (verified failing without the fix).Backward compatibility
Off by default. All 57 pre-existing tests pass unchanged.
Documentation
README.mdgains a Removing the visitor IP section: setup, the impact table, the scope limits above, and two warnings that came out of verifying behaviour against Matomo core —cipwith HTTP 400 and records nothing, so dropping the token discards traffic rather than degrading it;0.0.0.0, if the maximum actions per visit setting is in use, the first visitor over the limit gets0.0.0.0/32blocked and all tracking stops silently. Unlimited by default.Testing
20 new cases: placeholder injection, client-
cippass-through (query, POST body, bulk string and object entries), precedence over the forward header on the tracking and non-tracking endpoints, the offset-0 detector fix, and truthy/falsy config coercion. Suite green on both transports (allow_url_fopenon and off — CI only exercises one),phpcsclean.