Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] === ''
Expand Down
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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'] === ''
Expand Down
15 changes: 15 additions & 0 deletions config.php.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.).
//
Expand Down
68 changes: 59 additions & 9 deletions proxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -120,22 +135,30 @@
// 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
// be relocated there. Only $_GET matters here - for a bulk POST, $_POST is the mangled body.
$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
Expand Down Expand Up @@ -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;'
Expand Down Expand Up @@ -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";
}

Expand Down Expand Up @@ -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)) {
Expand All @@ -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)) {
Expand All @@ -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.
Expand Down
Loading
Loading