Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
121 / 121
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
PHPSessionHandler
100.00% covered (success)
100.00%
121 / 121
100.00% covered (success)
100.00%
11 / 11
47
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setEnableFlags
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 isInstalled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 install
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 setManager
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 open
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 close
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 read
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 write
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
1 / 1
18
 destroy
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 gc
n/a
0 / 0
n/a
0 / 0
2
1<?php
2/**
3 * Session storage in object cache.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Session
22 */
23
24namespace MediaWiki\Session;
25
26use MediaWiki\MainConfigNames;
27use MediaWiki\MediaWikiServices;
28use Psr\Log\LoggerInterface;
29use Psr\Log\NullLogger;
30use SessionHandlerInterface;
31use Wikimedia\AtEase\AtEase;
32use Wikimedia\ObjectCache\BagOStuff;
33use Wikimedia\PhpSessionSerializer;
34
35/**
36 * Adapter for PHP's session handling
37 * @ingroup Session
38 * @since 1.27
39 */
40class PHPSessionHandler implements SessionHandlerInterface {
41    /** @var PHPSessionHandler */
42    protected static $instance = null;
43
44    /** @var bool Whether PHP session handling is enabled */
45    protected $enable = false;
46
47    /** @var bool */
48    protected $warn = true;
49
50    protected ?SessionManagerInterface $manager = null;
51    protected ?BagOStuff $store = null;
52    protected LoggerInterface $logger;
53
54    /** @var array Track original session fields for later modification check */
55    protected $sessionFieldCache = [];
56
57    protected function __construct( SessionManager $manager ) {
58        $this->setEnableFlags(
59            MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::PHPSessionHandling )
60        );
61        $manager->setupPHPSessionHandler( $this );
62    }
63
64    /**
65     * Set $this->enable and $this->warn
66     *
67     * Separate just because there doesn't seem to be a good way to test it
68     * otherwise.
69     *
70     * @param string $PHPSessionHandling See $wgPHPSessionHandling
71     */
72    private function setEnableFlags( $PHPSessionHandling ) {
73        switch ( $PHPSessionHandling ) {
74            case 'enable':
75                $this->enable = true;
76                $this->warn = false;
77                break;
78
79            case 'warn':
80                $this->enable = true;
81                $this->warn = true;
82                break;
83
84            case 'disable':
85                $this->enable = false;
86                $this->warn = false;
87                break;
88        }
89    }
90
91    /**
92     * Test whether the handler is installed
93     * @return bool
94     */
95    public static function isInstalled() {
96        return (bool)self::$instance;
97    }
98
99    /**
100     * Test whether the handler is installed and enabled
101     * @return bool
102     */
103    public static function isEnabled() {
104        return self::$instance && self::$instance->enable;
105    }
106
107    /**
108     * Install a session handler for the current web request
109     * @param SessionManager $manager
110     */
111    public static function install( SessionManager $manager ) {
112        if ( self::$instance ) {
113            $manager->setupPHPSessionHandler( self::$instance );
114            return;
115        }
116
117        // @codeCoverageIgnoreStart
118        if ( defined( 'MW_NO_SESSION_HANDLER' ) ) {
119            throw new \BadMethodCallException( 'MW_NO_SESSION_HANDLER is defined' );
120        }
121        // @codeCoverageIgnoreEnd
122
123        self::$instance = new self( $manager );
124
125        // Close any auto-started session, before we replace it
126        session_write_close();
127
128        try {
129            AtEase::suppressWarnings();
130
131            // Tell PHP not to mess with cookies itself
132            ini_set( 'session.use_cookies', 0 );
133            ini_set( 'session.use_trans_sid', 0 );
134
135            // T124510: Disable automatic PHP session related cache headers.
136            // MediaWiki adds its own headers and the default PHP behavior may
137            // set headers such as 'Pragma: no-cache' that cause problems with
138            // some user agents.
139            session_cache_limiter( '' );
140
141            // Also set a serialization handler
142            PhpSessionSerializer::setSerializeHandler();
143
144            // Register this as the save handler, and register an appropriate
145            // shutdown function.
146            session_set_save_handler( self::$instance, true );
147        } finally {
148            AtEase::restoreWarnings();
149        }
150    }
151
152    /**
153     * Set the manager, store, and logger
154     * @internal Use self::install().
155     * @param SessionManagerInterface $manager
156     * @param BagOStuff $store
157     * @param LoggerInterface $logger
158     */
159    public function setManager(
160        SessionManagerInterface $manager, BagOStuff $store, LoggerInterface $logger
161    ) {
162        if ( $this->manager !== $manager ) {
163            // Close any existing session before we change stores
164            if ( $this->manager ) {
165                session_write_close();
166            }
167            $this->manager = $manager;
168            $this->store = $store;
169            $this->logger = $logger;
170            PhpSessionSerializer::setLogger( $this->logger );
171        }
172    }
173
174    /**
175     * Initialize the session (handler)
176     * @internal For internal use only
177     * @param string $save_path Path used to store session files (ignored)
178     * @param string $session_name Session name (ignored)
179     * @return true
180     */
181    #[\ReturnTypeWillChange]
182    public function open( $save_path, $session_name ) {
183        if ( self::$instance !== $this ) {
184            throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
185        }
186        if ( !$this->enable ) {
187            throw new \BadMethodCallException( 'Attempt to use PHP session management' );
188        }
189        return true;
190    }
191
192    /**
193     * Close the session (handler)
194     * @internal For internal use only
195     * @return true
196     */
197    #[\ReturnTypeWillChange]
198    public function close() {
199        if ( self::$instance !== $this ) {
200            throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
201        }
202        $this->sessionFieldCache = [];
203        return true;
204    }
205
206    /**
207     * Read session data
208     * @internal For internal use only
209     * @param string $id Session id
210     * @return string Session data
211     */
212    #[\ReturnTypeWillChange]
213    public function read( $id ) {
214        if ( self::$instance !== $this ) {
215            throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
216        }
217        if ( !$this->enable ) {
218            throw new \BadMethodCallException( 'Attempt to use PHP session management' );
219        }
220
221        $session = $this->manager->getSessionById( $id, false );
222        if ( !$session ) {
223            return '';
224        }
225        $session->persist();
226
227        $data = iterator_to_array( $session );
228        $this->sessionFieldCache[$id] = $data;
229        return (string)PhpSessionSerializer::encode( $data );
230    }
231
232    /**
233     * Write session data
234     * @internal For internal use only
235     * @param string $id Session id
236     * @param string $dataStr Session data. Not that you should ever call this
237     *   directly, but note that this has the same issues with code injection
238     *   via user-controlled data as does PHP's unserialize function.
239     * @return bool
240     */
241    #[\ReturnTypeWillChange]
242    public function write( $id, $dataStr ) {
243        if ( self::$instance !== $this ) {
244            throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
245        }
246        if ( !$this->enable ) {
247            throw new \BadMethodCallException( 'Attempt to use PHP session management' );
248        }
249
250        $session = $this->manager->getSessionById( $id, true );
251        if ( !$session ) {
252            // This can happen under normal circumstances, if the session exists but is
253            // invalid. Let's emit a log warning instead of a PHP warning.
254            $this->logger->warning(
255                __METHOD__ . ': Session "{session}" cannot be loaded, skipping write.',
256                [
257                    'session' => $id,
258                ] );
259            return true;
260        }
261
262        // First, decode the string PHP handed us
263        $data = PhpSessionSerializer::decode( $dataStr );
264        if ( $data === null ) {
265            // @codeCoverageIgnoreStart
266            return false;
267            // @codeCoverageIgnoreEnd
268        }
269
270        // Now merge the data into the Session object.
271        $changed = false;
272        $cache = $this->sessionFieldCache[$id] ?? [];
273        foreach ( $data as $key => $value ) {
274            if ( !array_key_exists( $key, $cache ) ) {
275                if ( $session->exists( $key ) ) {
276                    // New in both, so ignore and log
277                    $this->logger->warning(
278                        __METHOD__ . ": Key \"$key\" added in both Session and \$_SESSION!"
279                    );
280                } else {
281                    // New in $_SESSION, keep it
282                    $session->set( $key, $value );
283                    $changed = true;
284                }
285            } elseif ( $cache[$key] === $value ) {
286                // Unchanged in $_SESSION, so ignore it
287            } elseif ( !$session->exists( $key ) ) {
288                // Deleted in Session, keep but log
289                $this->logger->warning(
290                    __METHOD__ . ": Key \"$key\" deleted in Session and changed in \$_SESSION!"
291                );
292                $session->set( $key, $value );
293                $changed = true;
294            } elseif ( $cache[$key] === $session->get( $key ) ) {
295                // Unchanged in Session, so keep it
296                $session->set( $key, $value );
297                $changed = true;
298            } else {
299                // Changed in both, so ignore and log
300                $this->logger->warning(
301                    __METHOD__ . ": Key \"$key\" changed in both Session and \$_SESSION!"
302                );
303            }
304        }
305        // Anything deleted in $_SESSION and unchanged in Session should be deleted too
306        // (but not if $_SESSION can't represent it at all)
307        PhpSessionSerializer::setLogger( new NullLogger() );
308        foreach ( $cache as $key => $value ) {
309            if ( !array_key_exists( $key, $data ) && $session->exists( $key ) &&
310                PhpSessionSerializer::encode( [ $key => true ] )
311            ) {
312                if ( $value === $session->get( $key ) ) {
313                    // Unchanged in Session, delete it
314                    $session->remove( $key );
315                    $changed = true;
316                } else {
317                    // Changed in Session, ignore deletion and log
318                    $this->logger->warning(
319                        __METHOD__ . ": Key \"$key\" changed in Session and deleted in \$_SESSION!"
320                    );
321                }
322            }
323        }
324        PhpSessionSerializer::setLogger( $this->logger );
325
326        // Save and update cache if anything changed
327        if ( $changed ) {
328            if ( $this->warn ) {
329                wfDeprecated( '$_SESSION', '1.27' );
330                $this->logger->warning( 'Something wrote to $_SESSION!' );
331            }
332
333            $session->save();
334            $this->sessionFieldCache[$id] = iterator_to_array( $session );
335        }
336
337        $session->persist();
338
339        return true;
340    }
341
342    /**
343     * Destroy a session
344     * @internal For internal use only
345     * @param string $id Session id
346     * @return true
347     */
348    #[\ReturnTypeWillChange]
349    public function destroy( $id ) {
350        if ( self::$instance !== $this ) {
351            throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
352        }
353        if ( !$this->enable ) {
354            throw new \BadMethodCallException( 'Attempt to use PHP session management' );
355        }
356        $session = $this->manager->getSessionById( $id, false );
357        if ( $session ) {
358            $session->clear();
359        }
360        return true;
361    }
362
363    /**
364     * Execute garbage collection.
365     * @internal For internal use only
366     * @param int $maxlifetime Maximum session life time (ignored)
367     * @return true
368     * @codeCoverageIgnore See T135576
369     */
370    #[\ReturnTypeWillChange]
371    public function gc( $maxlifetime ) {
372        if ( self::$instance !== $this ) {
373            throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
374        }
375        $this->store->deleteObjectsExpiringBefore( wfTimestampNow() );
376        return true;
377    }
378}