diff --git a/.github/config.php b/.github/config.php index 65a6ad9..f337cc3 100644 --- a/.github/config.php +++ b/.github/config.php @@ -7,3 +7,9 @@ $TOKEN_AUTH = 'xyz'; $timeout = 5; + +// Test-only: lets the suite exercise IP-forward-header handling via the X-Test-Ip-Forward-Header +// request header. Gated on the local test-server URL so a stray copy to production is inert. +if (strpos($MATOMO_URL, '/tests/server/') !== false && !empty($_SERVER['HTTP_X_TEST_IP_FORWARD_HEADER'])) { + $http_ip_forward_header = $_SERVER['HTTP_X_TEST_IP_FORWARD_HEADER']; +} diff --git a/README.md b/README.md index 1c4198c..f4f3b5b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ To run this properly you will need: - latest version of Matomo installed on a server (or Matomo Cloud) - one or several website(s) to track with this Matomo, for example `http://{site_to_be_tracked}` -- the website to track must run on a server with PHP 5.3 or higher +- the website to track must run on a server with PHP 7.2 or higher - PHP must have either the CURL extension enabled or `allow_url_fopen=On` ## Installation @@ -114,6 +114,30 @@ You may change this timeout by editing the `$timeout` value in `config.php`. By default, the `matomo.php` proxy will contact your Matomo server with the User-Agent of the client requesting `matomo.php`. You may force the proxy script to use a particular User-Agent by editing the `$user_agent` value in `config.php`. +### 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: + +- **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. + +> ⚠️ **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`. + +### Auth-protected tracking parameters + +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`. +- **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. + +> Note: if your Matomo server sets `bulk_requests_require_authentication = 1`, it requires a single batch-level `token_auth` for the whole bulk request. The proxy supplies that batch-level token only when **every** entry in the batch is clean; if any entry carries an override parameter or its own `token_auth`, the proxy withholds it (a batch-level token would wrongly authorize that entry) and Matomo then rejects the **entire** bulk request — clean entries included. Under that configuration, send your own batch-level `token_auth` or avoid mixing override entries into proxied bulk requests. + +> Note: bulk tracking requests must be sent with an `application/x-www-form-urlencoded` content type (as the Matomo JavaScript tracker does). Bulk request bodies sent as `application/json` are not forwarded by the proxy. + +> Note: the proxy rebuilds the forwarded request from the parameters PHP parsed (`$_GET`/`$_POST`, or the decoded JSON for bulk), so it only ever sends Matomo what it inspected. Make sure the proxy host's PHP `post_max_size` is large enough for your biggest (bulk) tracking requests — a body exceeding it is dropped by PHP and not forwarded. + ## Contributing If you have found a bug, you are welcome to submit a pull request. @@ -125,8 +149,15 @@ Before running the tests, create a config.php file w/ the following contents in ``` getVisitIp(), - 'token_auth' => $TOKEN_AUTH, - ); + // 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); + + 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); + // 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(); + } + if (!clientProvidesAuthParams($_GET) && !clientProvidesAuthParams($_POST)) { + // Drop any empty/array token_auth the client sent so it can't clobber ours when + // $_GET is merged below (array_merge lets $_GET win on key collision). + unset($_GET['token_auth']); + $extraQueryParams['token_auth'] = $TOKEN_AUTH; + } - if (!isset($_GET['token_auth']) && !isset($_POST['token_auth'])) { - sanitizeTrackingOverrideParams($_GET); + // Rebuild the body from parsed $_POST, not the raw bytes, so it matches the params our token + // decision saw: a param dropped by max_input_vars is absent from both and can't slip past us. + if (!empty($_POST)) { + $forwardPostBody = http_build_query($_POST); + } + } } + // With an IP-forward header set, the visitor IP goes in that header (see getHttpContentAndStatus); + // we inject no cip/token and let Matomo apply its own auth rules. } $url = $MATOMO_URL . $path; @@ -128,7 +160,7 @@ if (version_compare(PHP_VERSION, '5.3.0', '<')) { // PHP 5.2 breaks with the new 204 status code so we force returning the image every time - list($content, $httpStatus) = getHttpContentAndStatus($url . '&send_image=1', $timeout, $user_agent); + list($content, $httpStatus) = getHttpContentAndStatus($url . '&send_image=1', $timeout, $user_agent, $forwardPostBody); $content = sanitizeContent($content); forwardHeaders($content); @@ -136,7 +168,7 @@ echo $content; } else { // PHP 5.3 and above - list($content, $httpStatus) = getHttpContentAndStatus($url, $timeout, $user_agent); + list($content, $httpStatus) = getHttpContentAndStatus($url, $timeout, $user_agent, $forwardPostBody); $content = sanitizeContent($content); forwardHeaders($content); @@ -159,7 +191,13 @@ function sanitizeContent($content) $matomoHost = parse_url($MATOMO_URL, PHP_URL_HOST); $proxyHost = parse_url($PROXY_URL, PHP_URL_HOST); - $content = str_replace($TOKEN_AUTH, '', $content); + // Scrub the token in raw and URL-encoded form (we inject it through http_build_query, which + // percent-encodes it, so a non-hex token could otherwise be reflected back unscrubbed). + $tokenForms = array_unique(array($TOKEN_AUTH, rawurlencode($TOKEN_AUTH), urlencode($TOKEN_AUTH))); + foreach ($tokenForms as $tokenForm) { + $content = str_replace($tokenForm, '', $content); + } + $content = str_replace($MATOMO_URL, $PROXY_URL, $content); $content = str_replace($matomoHost, $proxyHost, $content); @@ -240,7 +278,7 @@ function handleHeaderLine($curl, $headerLine) return $originalByteCount; } -function getHttpContentAndStatus($url, $timeout, $user_agent) +function getHttpContentAndStatus($url, $timeout, $user_agent, $postBody = '') { global $httpResponseHeaders; global $DEBUG_PROXY; @@ -284,25 +322,18 @@ function getHttpContentAndStatus($url, $timeout, $user_agent) ); } + // Forward the visitor IP via the configured header, for every request method. + if (!empty($http_ip_forward_header)) { + $visitIp = getVisitIp(); + $stream_options['http']['header'][] = "$http_ip_forward_header: $visitIp"; + } + // if there's POST data, send our proxy request as a POST if (!empty($_POST)) { - $postBody = file_get_contents("php://input"); - if (!isset($_GET['token_auth']) && !isset($_POST['token_auth'])) { - $didSanitizePostParams = sanitizeTrackingOverrideParams($_POST); - if ($didSanitizePostParams) { - $postBody = http_build_query($_POST); - } - } - $stream_options['http']['method'] = 'POST'; $stream_options['http']['header'][] = "Content-type: application/x-www-form-urlencoded"; $stream_options['http']['header'][] = "Content-Length: " . strlen($postBody); $stream_options['http']['content'] = $postBody; - - if (!empty($http_ip_forward_header)) { - $visitIp = getVisitIp(); - $stream_options['http']['header'][] = "$http_ip_forward_header: $visitIp"; - } } if ($useFopen) { @@ -378,16 +409,167 @@ function arrayValue($array, $key, $value = null) return $value; } -function sanitizeTrackingOverrideParams(&$params) +function clientProvidesAuthParams($params) { - $didSanitizeParams = false; - $queryParamsToUnset = ['cdt', 'country', 'region', 'city', 'lat', 'long', 'cip']; - foreach ($queryParamsToUnset as $queryParamToUnset) { - if (isset($params[$queryParamToUnset])) { - unset($params[$queryParamToUnset]); - $didSanitizeParams = true; + if (!is_array($params)) { + return false; + } + + // A non-empty string token_auth counts as client auth, exactly as Matomo reads it; an empty or + // array token is ignored by Matomo, so we must not treat it as one either. + if (isset($params['token_auth']) && is_string($params['token_auth']) && $params['token_auth'] !== '') { + 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'); + + foreach ($overrideParams as $param) { + if (array_key_exists($param, $params)) { + return true; + } + } + + return false; +} + +function withProxyTracking( + $params, + $visitIp, + #[\SensitiveParameter] + $tokenAuth, + $includeProxyToken +) { + // The entry is clean (no cip of its own), so set the real visitor IP. + $params['cip'] = $visitIp; + + // Lend our token only when the caller decided to; otherwise a client token authorizes the cip. + if ($includeProxyToken) { + $params['token_auth'] = $tokenAuth; + } + + return $params; +} + +function bulkEntryProvidesAuthParams($request) +{ + // Parse an entry exactly like rewriteBulkEntry(), so the batch scan and the rewrite agree. + if (is_string($request)) { + $parsedUrl = @parse_url($request); + if (empty($parsedUrl['query'])) { + return false; // no query: Matomo ignores this entry + } + + $params = array(); + @parse_str($parsedUrl['query'], $params); + + return clientProvidesAuthParams($params); + } + + if (is_array($request)) { + return clientProvidesAuthParams($request); + } + + return false; +} + +function rewriteBulkEntry( + $request, + $visitIp, + #[\SensitiveParameter] + $tokenAuth, + $includeProxyToken +) { + // Clean entries get our cip (plus our token when $includeProxyToken); an entry with its own auth + // params is left untouched for Matomo to reject. Parsing mirrors Matomo's BulkTracking plugin. + if (is_string($request)) { + $parsedUrl = @parse_url($request); + if (empty($parsedUrl['query'])) { + return $request; // no query: Matomo ignores this entry } + + $params = array(); + @parse_str($parsedUrl['query'], $params); + + if (clientProvidesAuthParams($params)) { + return $request; + } + + return '?' . http_build_query(withProxyTracking($params, $visitIp, $tokenAuth, $includeProxyToken)); + } + + if (is_array($request)) { + if (clientProvidesAuthParams($request)) { + return $request; + } + + return withProxyTracking($request, $visitIp, $tokenAuth, $includeProxyToken); } - return $didSanitizeParams; + return $request; +} + +function injectVisitIpIntoBulkRequest( + $rawPostBody, + $visitIp, + #[\SensitiveParameter] + $tokenAuth, + #[\SensitiveParameter] + $clientUrlToken = null +) { + // Strip line breaks before decoding, as Matomo does (Common::sanitizeLineBreaks). + $data = json_decode(str_replace(array("\n", "\r"), '', trim($rawPostBody)), true); + + // Not a decodable bulk request: forward unchanged and let Matomo deal with it. + if (!is_array($data) || !isset($data['requests']) || !is_array($data['requests'])) { + return $rawPostBody; + } + + // Read the body token string-only, exactly as Matomo does. + $clientHasBodyToken = isset($data['token_auth']) && is_string($data['token_auth']) && $data['token_auth'] !== ''; + $clientHasUrlToken = $clientUrlToken !== null; + + // Matomo reads the bulk token only from the body. Relocate a URL token there (when the body has + // none) so our injected cip is authorized deterministically, not via Matomo's global $_GET fallback. + if ($clientHasUrlToken && !$clientHasBodyToken) { + $data['token_auth'] = $clientUrlToken; + $clientHasBodyToken = true; + } + + $clientAuthenticates = $clientHasUrlToken || $clientHasBodyToken; + + // Does any entry carry an auth-protected override param or its own token_auth? + $anyOffendingEntry = false; + foreach ($data['requests'] as $request) { + if (bulkEntryProvidesAuthParams($request)) { + $anyOffendingEntry = true; + break; + } + } + + // A top-level token authorizes EVERY entry, so lend ours there only for a fully clean batch with + // no client token - then it authorizes nothing but our injected cip, and it also satisfies Matomo's + // bulk auth gate (bulk_requests_require_authentication checks each request's top-level token). + $useTopLevelProxyToken = !$clientAuthenticates && !$anyOffendingEntry; + + if ($useTopLevelProxyToken) { + $data['token_auth'] = $tokenAuth; + } + + // Otherwise (mixed batch, no client token) fall back to per-entry tokens on the clean entries; + // with a top-level token or client auth, entries get cip only. Per-entry tokens do NOT satisfy + // Matomo's bulk auth gate (it checks each request's top-level token), so on a server with + // bulk_requests_require_authentication=1 a mixed batch is rejected in full. + $includeProxyTokenPerEntry = !$clientAuthenticates && !$useTopLevelProxyToken; + + foreach ($data['requests'] as $index => $request) { + $data['requests'][$index] = rewriteBulkEntry($request, $visitIp, $tokenAuth, $includeProxyTokenPerEntry); + } + + $encoded = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + // Fall back to the original body if re-encoding fails (e.g. invalid UTF-8 from parse_str), + // so a malformed entry can never drop the whole batch. + return $encoded === false ? $rawPostBody : $encoded; } diff --git a/tests/ProxyTest.php b/tests/ProxyTest.php index a7ede92..88b7713 100644 --- a/tests/ProxyTest.php +++ b/tests/ProxyTest.php @@ -213,7 +213,7 @@ public function test_post_requests_are_proxied_correctly() $this->assertEquals($expected, $responseBody); } - public function test_post_requests_strip_admin_tracking_params_without_client_token_auth() + public function test_post_requests_without_client_token_get_no_proxy_token_and_params_are_kept() { $response = $this->send( 'foo=bar', @@ -227,13 +227,20 @@ public function test_post_requests_strip_admin_tracking_params_without_client_to $responseBody = $this->getBody($response); + // The client supplied auth-protected params but no token, so the proxy must NOT lend its + // token_auth. The params are forwarded untouched; Matomo rejects the request itself. $expected = << '127.0.0.1', - 'token_auth' => '', 'foo' => 'bar', ) array ( + 'country' => 'ru', + 'region' => '77', + 'city' => 'Moscow', + 'lat' => '55.75', + 'long' => '37.61', + 'cdt' => '2020-01-01 00:00:00', 'action_name' => 'spoof', ) RESPONSE; @@ -242,7 +249,7 @@ public function test_post_requests_strip_admin_tracking_params_without_client_to $this->assertEquals($expected, $responseBody); } - public function test_post_requests_keep_admin_tracking_params_with_client_token_auth_in_post_body() + public function test_post_requests_with_client_token_auth_keep_params_and_get_no_proxy_token() { $response = $this->send( 'foo=bar', @@ -256,10 +263,11 @@ public function test_post_requests_keep_admin_tracking_params_with_client_token_ $responseBody = $this->getBody($response); + // The client authenticates itself: the proxy keeps its own token out (so Matomo uses the + // client token) and forwards the params unchanged. $expected = << '127.0.0.1', - 'token_auth' => '', 'foo' => 'bar', ) array ( @@ -278,7 +286,124 @@ public function test_post_requests_keep_admin_tracking_params_with_client_token_ $this->assertEquals($expected, $responseBody); } - public function test_post_requests_preserve_raw_body_when_no_admin_tracking_params_are_removed() + public function test_get_request_with_admin_param_without_token_gets_cip_but_no_token() + { + $response = $this->send('idsite=1&country=ru'); + + $responseBody = $this->getBody($response); + + $expected = << '127.0.0.1', + 'idsite' => '1', + 'country' => 'ru', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_get_request_with_cdo_without_token_gets_cip_but_no_token() + { + // cdo (custom datetime offset) backdates the visit and requires auth in Matomo, so it must + // also withhold our token. + $response = $this->send('idsite=1&cdo=200000'); + + $responseBody = $this->getBody($response); + + $expected = << '127.0.0.1', + 'idsite' => '1', + 'cdo' => '200000', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_get_request_with_client_cip_is_not_overridden_and_gets_no_token() + { + $response = $this->send('idsite=1&cip=6.6.6.6&country=ru'); + + $responseBody = $this->getBody($response); + + $expected = << '1', + 'cip' => '6.6.6.6', + 'country' => 'ru', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_empty_token_auth_alone_is_tracked_with_proxy_token() + { + // An empty token_auth is "no token" (as Matomo reads it). With no override present the + // proxy injects its cip+token (and drops the empty client token so it can't clobber ours). + $response = $this->send('idsite=1&token_auth='); + + $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_token_auth_alone_is_tracked_with_proxy_token() + { + // Same as above with an array-typed token_auth, which Matomo also ignores. + $response = $this->send('idsite=1&token_auth[]=x'); + + $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_empty_token_auth_with_override_does_not_receive_proxy_token() + { + // Empty token + an override: the override alone must withhold our token (type-juggling + // bypass), so Matomo rejects the cip instead of us authorizing it. + $response = $this->send('idsite=1&token_auth=&cip=6.6.6.6'); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringNotContainsString('', $responseBody); + } + + public function test_array_token_auth_with_override_does_not_receive_proxy_token() + { + $response = $this->send('idsite=1&token_auth[]=x&country=ru'); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringNotContainsString('', $responseBody); + } + + public function test_post_requests_forward_body_rebuilt_from_parsed_post() { $response = $this->send( 'foo=bar&raw_input=1', @@ -292,6 +417,8 @@ public function test_post_requests_preserve_raw_body_when_no_admin_tracking_para $responseBody = $this->getBody($response); + // The forwarded body is rebuilt from the parsed $_POST (so it only ever contains what the + // proxy inspected); the value is unchanged, only the space re-encodes as '+'. $expected = << '127.0.0.1', @@ -302,7 +429,325 @@ public function test_post_requests_preserve_raw_body_when_no_admin_tracking_para array ( 'action_name' => 'hello world', ) -RAW: action_name=hello%20world +RAW: action_name=hello+world +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_bulk_request_injects_cip_and_top_level_token_into_clean_batch() + { + $body = '{"requests":["?idsite=1&rec=1&action_name=one","?idsite=1&rec=1&action_name=two"],"send_image":0}'; + + $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()); + // A fully clean batch gets cip injected per entry, and our token once at the JSON body top + // level - one token authorizes the whole clean batch and satisfies Matomo's bulk auth gate. + $this->assertStringContainsString('action_name=one&cip=', $responseBody); + $this->assertStringContainsString('action_name=two&cip=', $responseBody); + // The token is set at the top level (sanitized to ), not per entry. + $this->assertStringContainsString('"token_auth":""', $responseBody); + $this->assertEquals(0, substr_count($responseBody, 'token_auth=')); + + // The outer query carries only what the client sent - no cip/token injected at that level. + $expectedGet = << '1', +) +GET; + $this->assertStringContainsString($expectedGet, $responseBody); + } + + public function test_bulk_request_leaves_offending_string_entry_untouched() + { + $body = '{"requests":["?idsite=1&rec=1&action_name=clean","?idsite=1&rec=1&country=ru"]}'; + + $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()); + // The clean entry is injected; the offending entry (country, no token) is left verbatim so + // Matomo rejects it - it receives no cip/token from us. + $this->assertStringContainsString('action_name=clean&cip=', $responseBody); + $this->assertStringContainsString('"?idsite=1&rec=1&country=ru"', $responseBody); + $this->assertEquals(1, substr_count($responseBody, 'token_auth=')); + } + + public function test_bulk_request_with_offending_entry_does_not_set_top_level_token() + { + // One clean entry + one offending entry (country, no token). A top-level token would + // authorize EVERY entry, so the offending entry must keep us from setting one - we fall back + // to per-entry injection on the clean entry only. + $body = '{"requests":["?idsite=1&rec=1&action_name=clean","?idsite=1&rec=1&country=ru"]}'; + + $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()); + // The clean entry still gets its own per-entry cip + token (URL form), the offending entry is + // left verbatim, and crucially there is no batch-level token (JSON form) authorizing them all. + $this->assertStringContainsString('action_name=clean&cip=', $responseBody); + $this->assertEquals(1, substr_count($responseBody, 'token_auth=')); + $this->assertStringContainsString('"?idsite=1&rec=1&country=ru"', $responseBody); + $this->assertStringNotContainsString('"token_auth":""', $responseBody); + } + + public function test_bulk_request_leaves_offending_object_entry_untouched() + { + $body = '{"requests":[{"idsite":"1","rec":"1","action_name":"clean"},{"idsite":"1","cip":"6.6.6.6"}]}'; + + $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()); + // Clean object entry gets cip + token injected (cip key appended right after action_name). + $this->assertStringContainsString('"action_name":"clean","cip":', $responseBody); + $this->assertStringContainsString('"token_auth":""', $responseBody); + // The offending object entry (cip, no token) is left exactly as sent. + $this->assertStringContainsString('{"idsite":"1","cip":"6.6.6.6"}', $responseBody); + } + + public function test_bulk_request_with_top_level_token_injects_cip_only() + { + $body = '{"requests":["?idsite=1&rec=1&action_name=one"],"token_auth":"client-token"}'; + + $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()); + // The client authenticates the batch with its body token: clean entries get cip (which + // that token authorizes) but NOT our token; the client's body token is preserved. + $this->assertStringContainsString('action_name=one&cip=', $responseBody); + $this->assertStringNotContainsString('token_auth=', $responseBody); + $this->assertStringContainsString('"token_auth":"client-token"', $responseBody); + } + + public function test_bulk_request_with_url_token_is_relocated_into_body() + { + $body = '{"requests":["?idsite=1&rec=1&action_name=one"],"send_image":0}'; + + $response = $this->send( + 'raw_input=1&token_auth=client-token', + null, + null, + ['content-type' => 'application/x-www-form-urlencoded'], + null, + 'POST', + $body + ); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + // The client authenticates via the URL token. Matomo reads the bulk token only from the body, + // so we relocate the client's token into the body top level (and drop it from the outer query). + // Clean entries get cip - which that token authorizes - but never our own token. + $this->assertStringContainsString('action_name=one&cip=', $responseBody); + $this->assertStringNotContainsString('', $responseBody); + $this->assertStringContainsString('"token_auth":"client-token"', $responseBody); + // The client token was moved out of the outer query into the body, not left in $_GET. + $this->assertStringNotContainsString("'token_auth' => 'client-token'", $responseBody); + } + + public function test_forward_header_mode_does_not_inject_cip_or_token_for_single_request() + { + $response = $this->send( + 'idsite=1&action_name=clean', + null, + null, + ['X-Test-Ip-Forward-Header' => 'X-Forwarded-For'] + ); + + $responseBody = $this->getBody($response); + + // With an IP-forward header configured the proxy injects nothing; the visitor IP is sent + // in the configured header (for GET too), and Matomo derives the IP from the connection. + $expected = << '1', + 'action_name' => 'clean', +) +array ( + 'X_FORWARDED_FOR' => '127.0.0.1', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_forward_header_mode_forwards_bulk_body_verbatim() + { + $body = '{"requests":["?idsite=1&rec=1&country=ru"],"send_image":0}'; + + $response = $this->send( + 'raw_input=1', + null, + null, + ['content-type' => 'application/x-www-form-urlencoded', 'X-Test-Ip-Forward-Header' => 'X-Forwarded-For'], + null, + 'POST', + $body + ); + + $responseBody = $this->getBody($response); + + $this->assertEquals(200, $response->getStatusCode()); + // No rewriting in forward-header mode: the body (including the offending entry) is passed + // through unchanged and the proxy injects no cip/token. + $this->assertStringContainsString('RAW: ' . $body, $responseBody); + $this->assertStringNotContainsString('cip=', $responseBody); + $this->assertStringNotContainsString('token_auth=', $responseBody); + } + + public function test_bulk_request_leaves_offending_object_entry_with_country_untouched() + { + $body = '{"requests":[{"idsite":"1","action_name":"clean"},{"idsite":"1","country":"ru"}]}'; + + $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()); + // Clean object entry gets cip + token; the offending object entry (country) is verbatim. + $this->assertStringContainsString('"action_name":"clean","cip":', $responseBody); + $this->assertStringContainsString('"token_auth":""', $responseBody); + $this->assertStringContainsString('{"idsite":"1","country":"ru"}', $responseBody); + } + + public function test_post_form_with_client_cip_is_not_overridden_and_gets_no_token() + { + $response = $this->send( + 'foo=bar', + null, + null, + ['content-type' => 'application/x-www-form-urlencoded'], + null, + 'POST', + 'cip=6.6.6.6&action_name=x' + ); + + $responseBody = $this->getBody($response); + + // Client cip in the POST body: the proxy adds neither its own cip nor a token. + $expected = << 'bar', +) +array ( + 'cip' => '6.6.6.6', + 'action_name' => 'x', +) +RESPONSE; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, $responseBody); + } + + public function test_bulk_request_with_invalid_json_is_forwarded_unchanged() + { + // Contains "requests" (so it's treated as bulk) but is not decodable: forward verbatim. + $body = '{"requests":[}'; + + $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('RAW: ' . $body, $responseBody); + $this->assertStringNotContainsString('cip=', $responseBody); + $this->assertStringNotContainsString('token_auth=', $responseBody); + } + + public function test_forward_header_mode_does_not_inject_for_single_post() + { + $response = $this->send( + 'foo=bar', + null, + null, + ['content-type' => 'application/x-www-form-urlencoded', 'X-Test-Ip-Forward-Header' => 'X-Forwarded-For'], + null, + 'POST', + 'action_name=clean' + ); + + $responseBody = $this->getBody($response); + + // Forward-header mode: nothing injected, body forwarded, visitor IP only in the header. + $expected = << 'bar', +) +array ( + 'action_name' => 'clean', +) +array ( + 'X_FORWARDED_FOR' => '127.0.0.1', +) RESPONSE; $this->assertEquals(200, $response->getStatusCode()); @@ -328,7 +773,6 @@ public function test_debug_requests_are_scrubbed_properly() RESPONSE; $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals(131, $response->getHeader('content-length')[0]); $this->assertEquals($expected, $responseBody); } diff --git a/tests/server/matomo.php b/tests/server/matomo.php index 1d43d58..18918b1 100644 --- a/tests/server/matomo.php +++ b/tests/server/matomo.php @@ -16,8 +16,14 @@ var_export($_POST); } +// Echo the raw request body so tests can assert on the exact forwarded payload (e.g. bulk +// requests). Enabled per-request via the `raw_input` query parameter to keep other tests stable. +if (!empty($_GET['raw_input'])) { + echo "\nRAW: " . file_get_contents('php://input'); +} + $headers = array(); -foreach (array('DNT', 'X_DO_NOT_TRACK') as $headerName) { +foreach (array('DNT', 'X_DO_NOT_TRACK', 'X_FORWARDED_FOR') as $headerName) { if (isset($_SERVER['HTTP_' . $headerName])) { $headers[$headerName] = $_SERVER['HTTP_' . $headerName]; }