Skip to content

Add optional removal of the visitor IP before forwarding to Matomo - #106

Open
sgiehl wants to merge 5 commits into
masterfrom
dev-20467
Open

Add optional removal of the visitor IP before forwarding to Matomo#106
sgiehl wants to merge 5 commits into
masterfrom
dev-20467

Conversation

@sgiehl

@sgiehl sgiehl commented Aug 4, 2026

Copy link
Copy Markdown
Member

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 (default false). When enabled, the proxy sends cip=0.0.0.0 instead 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 whenever cip is 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.0 is 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 cip is forwarded untouched. Matomo honours a cip only on an authenticated request, so such a request is a deliberate decision to track a specific IP made by something holding a valid token — and clientProvidesAuthParams() already withholds the proxy's token from it. The browser JS tracker never sends cip, 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-supplied cip values 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 truthy strpos check, so a "requests" marker at offset 0 is not a bulk request there. The proxy used !== false and disagreed — meaning such a request skipped cip injection 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.md gains 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 —

  • the write/admin token is still required: Matomo rejects an unauthenticated cip with HTTP 400 and records nothing, so dropping the token discards traffic rather than degrading it;
  • TrackingSpamPrevention: since every visit reports 0.0.0.0, if the maximum actions per visit setting is in use, the first visitor over the limit gets 0.0.0.0/32 blocked and all tracking stops silently. Unlimited by default.

Testing

20 new cases: placeholder injection, client-cip pass-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_fopen on and off — CI only exercises one), phpcs clean.

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.
sgiehl added 2 commits August 4, 2026 14:20
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
sgiehl force-pushed the dev-20467 branch 2 times, most recently from a19ab6b to 6286c16 Compare August 4, 2026 13:29
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.
@sgiehl
sgiehl marked this pull request as ready for review August 4, 2026 14:52
@sgiehl
sgiehl requested a review from a team August 4, 2026 16:20
Comment thread proxy.php Outdated
Comment thread README.md Outdated
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.
@sgiehl
sgiehl requested a review from tzi August 5, 2026 16:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants