Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
158 / 158
100.00% covered (success)
100.00%
12 / 12
CRAP
100.00% covered (success)
100.00%
1 / 1
PhpSessionSerializer
100.00% covered (success)
100.00%
158 / 158
100.00% covered (success)
100.00%
12 / 12
59
100.00% covered (success)
100.00%
1 / 1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSerializeHandler
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 encode
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 decode
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 serializeValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 unserializeValue
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 encodePhp
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 decodePhp
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
10
 encodePhpBinary
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 decodePhpBinary
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
9
 encodePhpSerialize
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 decodePhpSerialize
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Wikimedia\PhpSessionSerializer
4 *
5 * Copyright (C) 2015 Brad Jorsch <bjorsch@wikimedia.org>
6 *
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License along
18 * with this program; if not, write to the Free Software Foundation, Inc.,
19 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 * http://www.gnu.org/copyleft/gpl.html
21 *
22 * @file
23 * @author Brad Jorsch <bjorsch@wikimedia.org>
24 */
25
26namespace Wikimedia;
27
28use DomainException;
29use Exception;
30use InvalidArgumentException;
31use Psr\Log\LoggerInterface;
32use UnexpectedValueException;
33
34/**
35 * Provides for encoding and decoding session arrays to PHP's serialization
36 * formats.
37 *
38 * Supported formats are:
39 * - php
40 * - php_binary
41 * - php_serialize
42 *
43 * WDDX is not supported, since it breaks on all sorts of things.
44 */
45class PhpSessionSerializer {
46    /** @var LoggerInterface */
47    protected static $logger;
48
49    /**
50     * Set the logger to which to log
51     * @param LoggerInterface $logger The logger
52     */
53    public static function setLogger( LoggerInterface $logger ) {
54        self::$logger = $logger;
55    }
56
57    /**
58     * Try to set session.serialize_handler to a supported format
59     *
60     * This may change the format even if the current format is also supported.
61     *
62     * @return string Format set
63     * @throws DomainException
64     */
65    public static function setSerializeHandler() {
66        $formats = [
67            'php_serialize',
68            'php',
69            'php_binary',
70        ];
71
72        // First, try php_serialize since that's the only one that doesn't suck in some way.
73        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
74        @ini_set( 'session.serialize_handler', 'php_serialize' );
75        if ( ini_get( 'session.serialize_handler' ) === 'php_serialize' ) {
76            return 'php_serialize';
77        }
78
79        // Next, just use the current format if it's supported.
80        $format = ini_get( 'session.serialize_handler' );
81        if ( in_array( $format, $formats, true ) ) {
82            return $format;
83        }
84
85        // Last chance, see if any of our supported formats are accepted.
86        foreach ( $formats as $format ) {
87            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
88            @ini_set( 'session.serialize_handler', $format );
89            if ( ini_get( 'session.serialize_handler' ) === $format ) {
90                return $format;
91            }
92        }
93
94        throw new DomainException(
95            'Failed to set serialize handler to a supported format.' .
96                ' Supported formats are: ' . implode( ', ', $formats ) . '.'
97        );
98    }
99
100    /**
101     * Encode a session array to a string, using the format in session.serialize_handler
102     * @param array $data Session data
103     * @return string|null Encoded string, or null on failure
104     * @throws DomainException
105     */
106    public static function encode( array $data ) {
107        $format = ini_get( 'session.serialize_handler' );
108        if ( !is_string( $format ) ) {
109            throw new UnexpectedValueException(
110                'Could not fetch the value of session.serialize_handler'
111            );
112        }
113        switch ( $format ) {
114            case 'php':
115                return self::encodePhp( $data );
116
117            case 'php_binary':
118                return self::encodePhpBinary( $data );
119
120            case 'php_serialize':
121                return self::encodePhpSerialize( $data );
122
123            default:
124                throw new DomainException( "Unsupported format \"$format\"" );
125        }
126    }
127
128    /**
129     * Decode a session string to an array, using the format in session.serialize_handler
130     * @param string $data Session data. Use the same caution in passing
131     *   user-controlled data here that you would to PHP's unserialize function.
132     * @return array|null Data, or null on failure
133     * @throws DomainException
134     * @throws InvalidArgumentException
135     */
136    public static function decode( $data ) {
137        if ( !is_string( $data ) ) {
138            throw new InvalidArgumentException( '$data must be a string' );
139        }
140
141        $format = ini_get( 'session.serialize_handler' );
142        if ( !is_string( $format ) ) {
143            throw new UnexpectedValueException(
144                'Could not fetch the value of session.serialize_handler'
145            );
146        }
147        switch ( $format ) {
148            case 'php':
149                return self::decodePhp( $data );
150
151            case 'php_binary':
152                return self::decodePhpBinary( $data );
153
154            case 'php_serialize':
155                return self::decodePhpSerialize( $data );
156
157            default:
158                throw new DomainException( "Unsupported format \"$format\"" );
159        }
160    }
161
162    /**
163     * Serialize a value with error logging
164     * @param mixed $value
165     * @return string|null
166     */
167    private static function serializeValue( $value ) {
168        try {
169            return serialize( $value );
170        } catch ( Exception $ex ) {
171            self::$logger->error( 'Value serialization failed: ' . $ex->getMessage() );
172            return null;
173        }
174    }
175
176    /**
177     * Unserialize a value with error logging
178     * @param string &$string On success, the portion used is removed
179     * @return array ( bool $success, mixed $value )
180     */
181    private static function unserializeValue( &$string ) {
182        $error = null;
183        set_error_handler( static function ( $errno, $errstr ) use ( &$error ) {
184            $error = $errstr;
185            return true;
186        } );
187        $ret = unserialize( $string );
188        restore_error_handler();
189
190        if ( $error !== null ) {
191            self::$logger->error( 'Value unserialization failed: ' . $error );
192            return [ false, null ];
193        }
194
195        $serialized = serialize( $ret );
196        $l = strlen( $serialized );
197        if ( substr( $string, 0, $l ) !== $serialized ) {
198            self::$logger->error(
199                'Value unserialization failed: read value does not match original string'
200            );
201            return [ false, null ];
202        }
203
204        $string = substr( $string, $l );
205        return [ true, $ret ];
206    }
207
208    /**
209     * Encode a session array to a string in 'php' format
210     * @note Generally you'll use self::encode() instead of this method.
211     * @param array $data Session data
212     * @return string|null Encoded string, or null on failure
213     */
214    public static function encodePhp( array $data ) {
215        $ret = '';
216        foreach ( $data as $key => $value ) {
217            // @phan-suppress-next-line PhanTypeMismatchArgumentInternal
218            if ( strcmp( $key, intval( $key ) ) === 0 ) {
219                self::$logger->warning( "Ignoring unsupported integer key \"$key\"" );
220                continue;
221            }
222            if ( strcspn( $key, '|!' ) !== strlen( $key ) ) {
223                self::$logger->error( "Serialization failed: Key with unsupported characters \"$key\"" );
224                return null;
225            }
226            $v = self::serializeValue( $value );
227            if ( $v === null ) {
228                return null;
229            }
230            $ret .= "$key|$v";
231        }
232        return $ret;
233    }
234
235    /**
236     * Decode a session string in 'php' format to an array
237     * @note Generally you'll use self::decode() instead of this method.
238     * @param string $data Session data. Use the same caution in passing
239     *   user-controlled data here that you would to PHP's unserialize function.
240     * @return array|null Data, or null on failure
241     * @throws InvalidArgumentException
242     */
243    public static function decodePhp( $data ) {
244        if ( !is_string( $data ) ) {
245            throw new InvalidArgumentException( '$data must be a string' );
246        }
247
248        $ret = [];
249        while ( $data !== '' && $data !== false ) {
250            $i = strpos( $data, '|' );
251            if ( $i === false ) {
252                if ( substr( $data, -1 ) !== '!' ) {
253                    self::$logger->warning( 'Ignoring garbage at end of string' );
254                }
255                break;
256            }
257
258            $key = substr( $data, 0, $i );
259            $data = substr( $data, $i + 1 );
260
261            if ( strpos( $key, '!' ) !== false ) {
262                self::$logger->warning( "Decoding found a key with unsupported characters: \"$key\"" );
263            }
264
265            if ( $data === '' || $data === false ) {
266                self::$logger->error( 'Unserialize failed: unexpected end of string' );
267                return null;
268            }
269
270            [ $ok, $value ] = self::unserializeValue( $data );
271            if ( !$ok ) {
272                return null;
273            }
274            $ret[$key] = $value;
275        }
276        return $ret;
277    }
278
279    /**
280     * Encode a session array to a string in 'php_binary' format
281     * @note Generally you'll use self::encode() instead of this method.
282     * @param array $data Session data
283     * @return string|null Encoded string, or null on failure
284     */
285    public static function encodePhpBinary( array $data ) {
286        $ret = '';
287        foreach ( $data as $key => $value ) {
288            // @phan-suppress-next-line PhanTypeMismatchArgumentInternal
289            if ( strcmp( $key, intval( $key ) ) === 0 ) {
290                self::$logger->warning( "Ignoring unsupported integer key \"$key\"" );
291                continue;
292            }
293            $l = strlen( $key );
294            if ( $l > 127 ) {
295                self::$logger->warning( "Ignoring overlong key \"$key\"" );
296                continue;
297            }
298            $v = self::serializeValue( $value );
299            if ( $v === null ) {
300                return null;
301            }
302            $ret .= chr( $l ) . $key . $v;
303        }
304        return $ret;
305    }
306
307    /**
308     * Decode a session string in 'php_binary' format to an array
309     * @note Generally you'll use self::decode() instead of this method.
310     * @param string $data Session data. Use the same caution in passing
311     *   user-controlled data here that you would to PHP's unserialize function.
312     * @return array|null Data, or null on failure
313     * @throws InvalidArgumentException
314     */
315    public static function decodePhpBinary( $data ) {
316        if ( !is_string( $data ) ) {
317            throw new InvalidArgumentException( '$data must be a string' );
318        }
319
320        $ret = [];
321        while ( $data !== '' && $data !== false ) {
322            $l = ord( $data[0] );
323            if ( strlen( $data ) < ( $l & 127 ) + 1 ) {
324                self::$logger->error( 'Unserialize failed: unexpected end of string' );
325                return null;
326            }
327
328            // "undefined" marker
329            if ( $l > 127 ) {
330                $data = substr( $data, ( $l & 127 ) + 1 );
331                continue;
332            }
333
334            $key = substr( $data, 1, $l );
335            $data = substr( $data, $l + 1 );
336            if ( $data === '' || $data === false ) {
337                self::$logger->error( 'Unserialize failed: unexpected end of string' );
338                return null;
339            }
340
341            [ $ok, $value ] = self::unserializeValue( $data );
342            if ( !$ok ) {
343                return null;
344            }
345            $ret[$key] = $value;
346        }
347        return $ret;
348    }
349
350    /**
351     * Encode a session array to a string in 'php_serialize' format
352     * @note Generally you'll use self::encode() instead of this method.
353     * @param array $data Session data
354     * @return string|null Encoded string, or null on failure
355     */
356    public static function encodePhpSerialize( array $data ) {
357        try {
358            return serialize( $data );
359        } catch ( Exception $ex ) {
360            self::$logger->error( 'PHP serialization failed: ' . $ex->getMessage() );
361            return null;
362        }
363    }
364
365    /**
366     * Decode a session string in 'php_serialize' format to an array
367     * @note Generally you'll use self::decode() instead of this method.
368     * @param string $data Session data. Use the same caution in passing
369     *   user-controlled data here that you would to PHP's unserialize function.
370     * @return array|null Data, or null on failure
371     * @throws InvalidArgumentException
372     */
373    public static function decodePhpSerialize( $data ) {
374        if ( !is_string( $data ) ) {
375            throw new InvalidArgumentException( '$data must be a string' );
376        }
377
378        $error = null;
379        set_error_handler( static function ( $errno, $errstr ) use ( &$error ) {
380            $error = $errstr;
381            return true;
382        } );
383        $ret = unserialize( $data );
384        restore_error_handler();
385
386        if ( $error !== null ) {
387            self::$logger->error( 'PHP unserialization failed: ' . $error );
388            return null;
389        }
390
391        // PHP strangely allows non-arrays to session_decode(), even though
392        // that breaks $_SESSION. Let's not do that.
393        if ( !is_array( $ret ) ) {
394            self::$logger->error( 'PHP unserialization failed (value was not an array)' );
395            return null;
396        }
397
398        return $ret;
399    }
400
401}
402
403PhpSessionSerializer::setLogger( new \Psr\Log\NullLogger() ); // @codeCoverageIgnore