Passed
Push — master ( 99fcf4...ecd419 )
by Michael
09:49 queued 10s
created

XoopsSessionHandler::validateId()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 22
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 5
eloc 14
c 1
b 1
f 0
nc 4
nop 1
dl 0
loc 22
rs 9.4888
1
<?php
2
/**
3
 * XOOPS session handler
4
 *
5
 * You may not change or alter any portion of this comment or credits
6
 * of supporting developers from this source code or any supporting source code
7
 * which is considered copyrighted (c) material of the original comment or credit authors.
8
 * This program is distributed in the hope that it will be useful,
9
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
 *
12
 * @copyright       (c) 2000-2025 XOOPS Project (https://xoops.org)
13
 * @license             GNU GPL 2 (https://www.gnu.org/licenses/gpl-2.0.html)
14
 * @package             kernel
15
 * @since               2.0.0
16
 * @author              Kazumi Ono (AKA onokazu) http://www.myweb.ne.jp/, http://jp.xoops.org/
17
 * @author              Taiwen Jiang <[email protected]>
18
 */
19
20
defined('XOOPS_ROOT_PATH') || exit('Restricted access');
21
22
/**
23
 * XOOPS session handler (PHP 8.0+ with full types and lazy timestamp updates)
24
 * @package             kernel
25
 *
26
 * @author              Kazumi Ono    <[email protected]>
27
 * @author              Taiwen Jiang <[email protected]>
28
 * @author              XOOPS Development Team
29
 * @copyright       (c) 2000-2025 XOOPS Project (https://xoops.org)
30
 */
31
class XoopsSessionHandler implements
32
    \SessionHandlerInterface,
33
    \SessionUpdateTimestampHandlerInterface
34
{
35
    /** @var XoopsDatabase */
36
    public $db;
37
38
    /** @var int */
39
    public $securityLevel = 3;
40
41
    protected array $bitMasks = [
42
        2 => ['v4' => 16, 'v6' => 64],
43
        3 => ['v4' => 24, 'v6' => 56],
44
        4 => ['v4' => 32, 'v6' => 128],
45
    ];
46
47
    public bool $enableRegenerateId = true;
48
49
    public function __construct(XoopsDatabase $db)
50
    {
51
        global $xoopsConfig;
52
        $this->db = $db;
53
54
        $lifetime = ($xoopsConfig['use_mysession'] && $xoopsConfig['session_name'] != '')
55
            ? $xoopsConfig['session_expire'] * 60
56
            : ini_get('session.cookie_lifetime');
57
58
        $secure = (XOOPS_PROT === 'https://');
59
60
        $host = parse_url(XOOPS_URL, PHP_URL_HOST);
61
        if (!is_string($host)) {
0 ignored issues
show
introduced by
The condition is_string($host) is always true.
Loading history...
62
            $host = '';
63
        }
64
        $cookieDomain = XOOPS_COOKIE_DOMAIN;
65
        if (class_exists('\Xoops\RegDom\RegisteredDomain')) {
66
            if (!\Xoops\RegDom\RegisteredDomain::domainMatches($host, $cookieDomain)) {
67
                $cookieDomain = '';
68
            }
69
        }
70
71
        $options = [
72
            'lifetime' => $lifetime,
73
            'path'     => '/',
74
            'domain'   => $cookieDomain,
75
            'secure'   => $secure,
76
            'httponly' => true,
77
            'samesite' => 'Lax',
78
        ];
79
        session_set_cookie_params($options);
80
    }
81
82
    // --- SessionHandlerInterface (typed) ---
83
84
    public function open(string $savePath, string $sessionName): bool
85
    {
86
        return true;
87
    }
88
89
    public function close(): bool
90
    {
91
        $this->gc_force();
92
        return true;
93
    }
94
95
    public function read(string $sessionId): string|false
96
    {
97
        $ip = \Xmf\IPAddress::fromRequest();
98
        $sql = sprintf(
99
            'SELECT sess_data, sess_ip FROM %s WHERE sess_id = %s',
100
            $this->db->prefix('session'),
101
            $this->db->quote($sessionId)
102
        );
103
104
        $result = $this->db->queryF($sql);
0 ignored issues
show
Deprecated Code introduced by
The function XoopsDatabase::queryF() has been deprecated. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

104
        $result = /** @scrutinizer ignore-deprecated */ $this->db->queryF($sql);
Loading history...
105
        if (!$this->db->isResultSet($result)) {
106
            return false; // storage failure
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the return type mandated by SessionHandlerInterface::read() of string.

In the issue above, the returned value is violating the contract defined by the mentioned interface.

Let's take a look at an example:

interface HasName {
    /** @return string */
    public function getName();
}

class Name {
    public $name;
}

class User implements HasName {
    /** @return string|Name */
    public function getName() {
        return new Name('foo'); // This is a violation of the ``HasName`` interface
                                // which only allows a string value to be returned.
    }
}
Loading history...
107
        }
108
109
        $row = $this->db->fetchRow($result);
0 ignored issues
show
Bug introduced by
The method fetchRow() does not exist on XoopsDatabase. Since it exists in all sub-types, consider adding an abstract or default implementation to XoopsDatabase. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

109
        /** @scrutinizer ignore-call */ 
110
        $row = $this->db->fetchRow($result);
Loading history...
110
        if ($row === false) {
111
            return ''; // not found → empty string
112
        }
113
114
        [$sess_data, $sess_ip] = $row;
115
116
        if ($this->securityLevel > 1) {
117
            if (false === $ip->sameSubnet(
118
                    $sess_ip,
119
                    $this->bitMasks[$this->securityLevel]['v4'],
120
                    $this->bitMasks[$this->securityLevel]['v6']
121
                )) {
122
                return ''; // IP mismatch → treat as no data
123
            }
124
        }
125
126
        return $sess_data;
127
    }
128
129
    public function write(string $sessionId, string $data): bool
130
    {
131
        $remoteAddress = \Xmf\IPAddress::fromRequest()->asReadable();
132
        $sid = $this->db->quote($sessionId);
133
        $now = time();
134
135
        $sql = sprintf(
136
            'INSERT INTO %s (sess_id, sess_updated, sess_ip, sess_data)
137
             VALUES (%s, %u, %s, %s)
138
             ON DUPLICATE KEY UPDATE
139
             sess_updated = %u, sess_data = %s, sess_ip = %s',
140
            $this->db->prefix('session'),
141
            $sid,
142
            $now,
143
            $this->db->quote($remoteAddress),
144
            $this->db->quote($data),
145
            $now,
146
            $this->db->quote($data),
147
            $this->db->quote($remoteAddress)
148
        );
149
150
        $ok = $this->db->exec($sql);
151
        // update_cookie() only affects PHP versions with PHP_VERSION_ID < 70300 (see session74.php); on PHP 8+ it is effectively a no-op
152
        $this->update_cookie();
153
        return (bool)$ok;
154
    }
155
156
    public function destroy(string $sessionId): bool
157
    {
158
        $sql = sprintf(
159
            'DELETE FROM %s WHERE sess_id = %s',
160
            $this->db->prefix('session'),
161
            $this->db->quote($sessionId)
162
        );
163
        return (bool)$this->db->exec($sql);
164
    }
165
166
    public function gc(int $max_lifetime): int|false
167
    {
168
        if ($max_lifetime <= 0) {
169
            return 0;
0 ignored issues
show
Bug Best Practice introduced by
The expression return 0 returns the type integer which is incompatible with the return type mandated by SessionHandlerInterface::gc() of boolean.

In the issue above, the returned value is violating the contract defined by the mentioned interface.

Let's take a look at an example:

interface HasName {
    /** @return string */
    public function getName();
}

class Name {
    public $name;
}

class User implements HasName {
    /** @return string|Name */
    public function getName() {
        return new Name('foo'); // This is a violation of the ``HasName`` interface
                                // which only allows a string value to be returned.
    }
}
Loading history...
170
        }
171
        $mintime = time() - $max_lifetime;
172
        $sql = sprintf(
173
            'DELETE FROM %s WHERE sess_updated < %u',
174
            $this->db->prefix('session'),
175
            $mintime
176
        );
177
        if ($this->db->exec($sql)) {
178
            return (int)$this->db->getAffectedRows();
0 ignored issues
show
Bug introduced by
The method getAffectedRows() does not exist on XoopsDatabase. Since it exists in all sub-types, consider adding an abstract or default implementation to XoopsDatabase. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

178
            return (int)$this->db->/** @scrutinizer ignore-call */ getAffectedRows();
Loading history...
Bug Best Practice introduced by
The expression return (int)$this->db->getAffectedRows() returns the type integer which is incompatible with the return type mandated by SessionHandlerInterface::gc() of boolean.

In the issue above, the returned value is violating the contract defined by the mentioned interface.

Let's take a look at an example:

interface HasName {
    /** @return string */
    public function getName();
}

class Name {
    public $name;
}

class User implements HasName {
    /** @return string|Name */
    public function getName() {
        return new Name('foo'); // This is a violation of the ``HasName`` interface
                                // which only allows a string value to be returned.
    }
}
Loading history...
179
        }
180
        return false;
181
    }
182
183
    // --- SessionUpdateTimestampHandlerInterface (8.0+) ---
184
185
    /**
186
     * Validate that the stored session IP matches the current client IP
187
     * according to the configured security level and bit masks.
188
     */
189
    private function validateSessionIp(?string $storedIp): bool
190
    {
191
        // Low security levels or missing configuration do not enforce IP checks
192
        if ($this->securityLevel <= 1) {
193
            return true;
194
        }
195
196
        if (empty($storedIp) || !isset($_SERVER['REMOTE_ADDR'])) {
197
            // If we cannot reliably determine either side, keep previous behavior
198
            return true;
199
        }
200
201
        $clientIp = (string)$_SERVER['REMOTE_ADDR'];
202
203
        $levelMasks = $this->bitMasks[$this->securityLevel] ?? null;
204
        if ($levelMasks === null) {
205
            return true;
206
        }
207
208
        // Determine IP version and ensure both addresses are of the same family
209
        $isClientV4 = filter_var($clientIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
210
        $isClientV6 = filter_var($clientIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
211
        $isStoredV4 = filter_var($storedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
212
        $isStoredV6 = filter_var($storedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
213
214
        if ($isClientV4 && $isStoredV4) {
215
            $version = 'v4';
216
        } elseif ($isClientV6 && $isStoredV6) {
217
            $version = 'v6';
218
        } else {
219
            // Mixed or invalid IP families - do not enforce IP binding
220
            return true;
221
        }
222
223
        $bits = $levelMasks[$version] ?? null;
224
        if ($bits === null) {
225
            return true;
226
        }
227
228
        $clientBin = @inet_pton($clientIp);
229
        $storedBin = @inet_pton($storedIp);
230
        if ($clientBin === false || $storedBin === false || $clientBin === null || $storedBin === null) {
231
            // If binary conversion fails, fall back to previous permissive behavior
232
            return true;
233
        }
234
235
        return $this->applyIpMask($clientBin, $bits) === $this->applyIpMask($storedBin, $bits);
0 ignored issues
show
Bug introduced by
The method applyIpMask() does not exist on XoopsSessionHandler. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

235
        return $this->/** @scrutinizer ignore-call */ applyIpMask($clientBin, $bits) === $this->applyIpMask($storedBin, $bits);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
236
    }
237
238
    /**
239
     * Apply an N-bit network mask to a binary IP address.
240
     */
241
    private function applyIpMask(string $ipBin, int $bits): string
242
    {
243
        $bytes      = strlen($ipBin);
244
        $masked     = '';
245
        $fullBytes  = intdiv($bits, 8);
246
        $remaining  = $bits % 8;
247
248
        for ($i = 0; $i < $bytes; $i++) {
249
            $byte = ord($ipBin[$i]);
250
            if ($i < $fullBytes) {
251
                // Completely within the network portion
252
                $masked .= chr($byte);
253
            } elseif ($i === $fullBytes && $remaining > 0) {
254
                // Partially masked byte
255
                $mask   = (0xFF << (8 - $remaining)) & 0xFF;
256
                $masked .= chr($byte & $mask);
257
            } else {
258
                // Host portion is zeroed
259
                $masked .= chr(0);
260
            }
261
        }
262
263
        return $masked;
264
    }
265
266
    public function validateId(string $id): bool
267
    {
268
        $sql = sprintf(
269
            'SELECT sess_ip FROM %s WHERE sess_id = %s',
270
            $this->db->prefix('session'),
271
            $this->db->quote($id)
272
        );
273
        $res = $this->db->queryF($sql, 1, 0);
0 ignored issues
show
Unused Code introduced by
The call to XoopsDatabase::queryF() has too many arguments starting with 1. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

273
        /** @scrutinizer ignore-call */ 
274
        $res = $this->db->queryF($sql, 1, 0);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
Deprecated Code introduced by
The function XoopsDatabase::queryF() has been deprecated. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

273
        $res = /** @scrutinizer ignore-deprecated */ $this->db->queryF($sql, 1, 0);
Loading history...
274
        if (!$this->db->isResultSet($res)) {
275
            return false;
276
        }
277
        $row = $this->db->fetchRow($res);
278
        if ($row === false) {
279
            return false;
280
        }
281
282
        $storedIp = $row[0] ?? null;
283
        if (!$this->validateSessionIp(is_string($storedIp) ? $storedIp : null)) {
0 ignored issues
show
Bug introduced by
The method validateSessionIp() does not exist on XoopsSessionHandler. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

283
        if (!$this->/** @scrutinizer ignore-call */ validateSessionIp(is_string($storedIp) ? $storedIp : null)) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
284
            return false;
285
        }
286
287
        return true;
288
    }
289
290
    public function updateTimestamp(string $id, string $data): bool
291
    {
292
        $sql = sprintf(
293
            'UPDATE %s SET sess_updated = %u WHERE sess_id = %s',
294
            $this->db->prefix('session'),
295
            time(),
296
            $this->db->quote($id)
297
        );
298
        return (bool)$this->db->exec($sql);
299
    }
300
301
    // --- Helpers (same behavior as your current code) ---
302
303
    public function gc_force(): void
304
    {
305
        /*
306
         * Probabilistic garbage collection:
307
         * - We run GC with approximately 10% probability on each call.
308
         * - \random_int() is used instead of mt_rand() for modern, secure randomness.
309
         *   The range [1, 100] and threshold < 11 are chosen to preserve the
310
         *   original ~10% behavior from the mt_rand()-based implementation.
311
         * - Any failure of \random_int() is ignored so that session handling
312
         *   is not disrupted if a random source is temporarily unavailable.
313
         */
314
        try {
315
            if (\random_int(1, 100) < 11) {
316
                $expire = (int) @ini_get('session.gc_maxlifetime');
317
                if ($expire <= 0) {
318
                    $expire = 900;
319
                }
320
                $this->gc($expire);
321
            }
322
        } catch (\Throwable $e) {
323
            // ignore
324
        }
325
    }
326
327
    public function regenerate_id(bool $delete_old_session = false): bool
328
    {
329
        $success = $this->enableRegenerateId
330
            ? session_regenerate_id($delete_old_session)
331
            : true;
332
333
        if ($success) {
334
            $this->update_cookie();
335
        }
336
        return $success;
337
    }
338
339
    public function update_cookie($sess_id = null, $expire = null): void
0 ignored issues
show
Unused Code introduced by
The parameter $sess_id is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

339
    public function update_cookie(/** @scrutinizer ignore-unused */ $sess_id = null, $expire = null): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $expire is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

339
    public function update_cookie($sess_id = null, /** @scrutinizer ignore-unused */ $expire = null): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
340
    {
341
        // no-op for 8.0+; retained for parity with 7.4 implementation
342
    }
343
}
344