Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
TrackingDataRedactor
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
3 / 3
9
100.00% covered (success)
100.00%
1 / 1
 redactQueryString
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 redactUrl
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 isSensitive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Redacts sensitive parameters from query strings and referer URLs before they
5 * are written to the user tracking logs.
6 *
7 * The tracking logs record the raw request query string and referer. Without
8 * redaction these can leak secrets that travel in the URL, most notably the
9 * signature of a password reset link (?sig=...), which would allow account
10 * takeover if the log file were ever read.
11 *
12 * This Source Code Form is subject to the terms of the Mozilla Public License,
13 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
14 * obtain one at https://mozilla.org/MPL/2.0/.
15 *
16 * @package   phpMyFAQ
17 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
18 * @copyright 2026 phpMyFAQ Team
19 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
20 * @link      https://www.phpmyfaq.de
21 * @since     2026-08-03
22 */
23
24declare(strict_types=1);
25
26namespace phpMyFAQ\User;
27
28final readonly class TrackingDataRedactor
29{
30    /** @var string Placeholder written in place of a sensitive value */
31    public const string REDACTED = '[redacted]';
32
33    /**
34     * Lowercase names of query parameters whose values must never be logged.
35     *
36     * @var list<string>
37     */
38    private const array SENSITIVE_PARAMETERS = [
39        'sig',
40        'signature',
41        'token',
42        'csrf',
43        'csrftoken',
44        'key',
45        'secret',
46        'password',
47        'passwd',
48        'pwd',
49        'pass',
50        'auth',
51        'apikey',
52        'api_key',
53        'access_token',
54    ];
55
56    /**
57     * Redacts the values of sensitive parameters in a raw query string while
58     * preserving parameter order and the values of non-sensitive parameters.
59     */
60    public function redactQueryString(string $queryString): string
61    {
62        if ($queryString === '') {
63            return '';
64        }
65
66        $pairs = explode('&', $queryString);
67        foreach ($pairs as $index => $pair) {
68            $separatorPosition = strpos($pair, needle: '=');
69            if ($separatorPosition === false) {
70                continue;
71            }
72
73            $name = substr($pair, offset: 0, length: $separatorPosition);
74            if (!$this->isSensitive($name)) {
75                continue;
76            }
77
78            $pairs[$index] = $name . '=' . self::REDACTED;
79        }
80
81        return implode('&', $pairs);
82    }
83
84    /**
85     * Redacts sensitive parameters in the query part of a URL (e.g. a referer),
86     * leaving the scheme, host, path and any fragment untouched.
87     */
88    public function redactUrl(string $url): string
89    {
90        $queryPosition = strpos($url, needle: '?');
91        if ($queryPosition === false) {
92            return $url;
93        }
94
95        $base = substr($url, offset: 0, length: $queryPosition + 1);
96        $query = substr($url, offset: $queryPosition + 1);
97
98        $fragment = '';
99        $fragmentPosition = strpos($query, needle: '#');
100        if ($fragmentPosition !== false) {
101            $fragment = substr($query, offset: $fragmentPosition);
102            $query = substr($query, offset: 0, length: $fragmentPosition);
103        }
104
105        return $base . $this->redactQueryString($query) . $fragment;
106    }
107
108    private function isSensitive(string $name): bool
109    {
110        return in_array(strtolower(urldecode($name)), self::SENSITIVE_PARAMETERS, strict: true);
111    }
112}