Harden proxy auth handling and add bulk-request support#104
Merged
Conversation
The proxy injects a privileged token_auth plus the real visitor IP (cip)
into the tracking requests it forwards. Because cip and the location/
timestamp params (country, region, city, lat, long, cdt, cdo) are only
honored by Matomo for an authenticated request, that token would also
authorize the same params if a client smuggled them in - letting a visitor
spoof their IP, location or timestamp. The previous fix stripped those
params from single GET/POST requests, but did not cover bulk requests and
could be bypassed with an empty/array token_auth.
Rework the approach: instead of stripping params, the proxy now only lends
its token to requests (and bulk entries) that did not try to override
anything. Anything that carries its own auth-protected param or token gets
no token from us, so Matomo rejects/skips it exactly as if it had been sent
directly without authentication - we never silently track a request Matomo
would reject.
- Add bulk () handling: each clean nested entry gets
cip + token injected; offending entries are left untouched. Parsing and
bulk-detection mirror Matomo's BulkTracking plugin so the two cannot
disagree about what an entry contains.
- Two IP-forwarding modes: when is set the proxy
injects nothing and the visitor IP rides in that header (now sent for GET
as well as POST); otherwise the IP is conveyed via cip authorized by the
token.
- Type-juggling safe: a client token counts only when a non-empty string
(as Matomo reads it); override params are detected by key presence, so
empty/array values cannot bypass the check. Covers cdt and cdo.
- Don't override a client-supplied cip.
Update the README (visitor-IP forwarding modes, auth-protected params,
bulk content-type note) and config.php.example. Add tests covering single
and bulk requests in both modes, and the type-juggling cases.
guzzle ^6.5 pulls in guzzlehttp/psr7 1.9.x, which composer 2.10+ refuses to install due to security advisories, breaking `composer install` on every CI job (PHPCS and all PHPUnit versions). guzzle ^7 resolves psr7 ^2 (unaffected). Test-only dependency; ProxyTest's HTTP client usage is unchanged and the full suite (45 tests) passes locally under guzzle 7.11. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AltamashShaikh
approved these changes
Jun 15, 2026
mneudert
approved these changes
Jun 16, 2026
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.
Summary
Hardens how the tracker-proxy handles authentication-controlled tracking parameters, and adds proper support for bulk tracking requests. The proxy no longer lets its privileged
token_authauthorize parameters a client tried to set itself (IP / location / timestamp overrides), for both single and bulk requests, while still recording the correct visitor IP.Background
The proxy forwards browser tracking requests to a hidden Matomo server and injects two things: the real visitor IP as
cip, and a privilegedtoken_auth(write/admin) needed to authorize thatcip. The side effect is that the same token authorizes every auth-controlled parameter in the request. In Matomo,cip,country,region,city,lat,long,cdtandcdoare only honored for an authenticated request — otherwise the request is rejected (InvalidRequestParameterException→ HTTP 400 for a single request; the offending entry is skipped for a bulk request). So a visitor who appended e.g.&country=ru&cdt=…would have those overrides authorized by the proxy's token and silently tracked.A previous change (#103) addressed this for single requests by stripping the override params. That had two problems:
{"requests":[…]}POST by default, and the overrides live inside the nested entries — which the strip logic never inspected. The proxy's token (reached via Matomo's auth fallback) then authorized them.isset(), sotoken_auth=(empty) ortoken_auth[]=x(array) made the proxy skip stripping while Matomo treated the token as absent and fell back to the injected privileged token.It also changed semantics in a questionable way: stripping turns a request Matomo would reject into one that tracks (minus the override).
What changed
Approach: withhold the token instead of stripping params. The proxy injects its
token_authonly for requests/entries that did not try to override anything. Anything carrying its own auth-controlled param (or its owntoken_auth) gets no token from us, so Matomo applies its native rules — rejecting a single request, skipping an offending bulk entry — rather than uslaundering it.
proxy.phpinjectVisitIpIntoBulkRequest): the body is parsed exactly the way Matomo'sBulkTrackingplugin parses it (json_decode,parse_url+parse_strfor string entries, arrays used directly, same"requests"detection,sanitizeLineBreaks). Each clean entry getscip+token_authinjected; offending entries are left verbatim. A client-supplied top-leveltoken_authmakes the whole body pass through untouched (the client's token governs).$http_ip_forward_headerset → the proxy injects nothing; the visitor IP is sent in that header (now for GET as well as POST) and Matomo derives it from the connection. Cleanest, no token involved.cip, authorized by the token, only on clean requests/entries.token_authcounts only when it's a non-empty string (matching how Matomo reads it); auth-controlled params are detected by key presence, so empty/array values can't slip past. Includes bothcdtandcdo.cipis never overridden — if the client sent one, we don't add ours (an authorized client may set it; an unauthorized one is rejected).Why this approach
We deliberately do not track a request Matomo would reject. If a client includes an auth-controlled override without valid auth, the proxy refuses to lend its token, so Matomo rejects/skips it — same outcome as sending it directly. Clean requests (the normal case) are unaffected and still get the correct visitor IP.
Verified against Matomo
cipnever reaches them — per-entrycipinjection is genuinely required (core/Tracker/RequestSet.php,plugins/BulkTracking/Tracker/Requests.php).cdoalone backdates the visit (cdt = now - abs(cdo)) and requires auth if older than 24h (core/Tracker/Request.php::getCustomTimestamp).core/Request/AuthenticationToken.php) — the proxy never creates a conflicting second token source.RequestHandlerTrait::$fieldsThatRequireAuth+Request.php).replaces #95
Checklist
Review