Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
23.53% covered (danger)
23.53%
16 / 68
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
DbStorage
23.53% covered (danger)
23.53%
16 / 68
12.50% covered (danger)
12.50%
1 / 8
615.55
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 preprocessNestedSqlArray
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 preprocessSqlArray
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
5.68
 hasUnescapedSQL
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 getFieldRegexFragment
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateOptions
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
240
 validate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 calcUpdates
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
1<?php
2
3namespace Flow\Data\Storage;
4
5use Flow\Data\ObjectManager;
6use Flow\Data\ObjectStorage;
7use Flow\Data\Utils\RawSql;
8use Flow\DbFactory;
9use Flow\Exception\DataModelException;
10use Flow\Model\UUID;
11
12/**
13 * Base class for all ObjectStorage implementers
14 * which use a database as the backing store.
15 *
16 * Includes some utility methods for database management and
17 * SQL security.
18 */
19abstract class DbStorage implements ObjectStorage {
20    /**
21     * @var DbFactory
22     */
23    protected $dbFactory;
24
25    /**
26     * The revision columns allowed to be updated
27     *
28     * @var string[]|true Allow of selective columns to allow, or true to allow
29     *   everything
30     */
31    protected $allowedUpdateColumns = true;
32
33    /**
34     * This is to prevent 'Update not allowed on xxx' error during moderation when
35     * * old cache is not purged and still holds obsolete deleted column
36     * * old cache is not purged and doesn't have the newly added column
37     *
38     * @var string[] Array of columns to ignore
39     */
40    protected $obsoleteUpdateColumns = [];
41
42    /**
43     * @param DbFactory $dbFactory
44     */
45    public function __construct( DbFactory $dbFactory ) {
46        $this->dbFactory = $dbFactory;
47    }
48
49    /**
50     * Runs preprocessSqlArray on each element of an array.
51     *
52     * @param array $outer The array to check
53     * @return array Preprocessed SQL array.
54     * @throws DataModelException
55     */
56    protected function preprocessNestedSqlArray( array $outer ) {
57        foreach ( $outer as $i => $data ) {
58            if ( !is_array( $data ) ) {
59                throw new DataModelException( "Unexpected non-array in nested SQL array" );
60            }
61            $outer[$i] = $this->preprocessSqlArray( $data );
62        }
63        return $outer;
64    }
65
66    /**
67     * At the moment, does three things:
68     * 1. Finds UUID objects and returns their database representation.
69     * 2. Checks for unarmoured raw SQL and errors out if it exists.
70     * 3. Finds armoured raw SQL and expands it out.
71     *
72     * @param array $data Query conditions for IDatabase::select
73     * @return array query conditions escaped for use
74     * @throws DataModelException
75     */
76    protected function preprocessSqlArray( array $data ) {
77        // Assuming that all databases have the same escaping settings.
78        $db = $this->dbFactory->getDB( DB_REPLICA );
79
80        $data = UUID::convertUUIDs( $data, 'binary' );
81
82        foreach ( $data as $key => $value ) {
83            if ( $value instanceof RawSql ) {
84                $data[$key] = $value->getSQL( $db );
85            } elseif ( is_numeric( $key ) ) {
86                throw new DataModelException( "Unescaped raw SQL found in " . __METHOD__, 'process-data' );
87            } elseif ( !preg_match( '/^[A-Za-z0-9\._]+$/', $key ) ) {
88                throw new DataModelException( "Dangerous SQL field name '$key' found in " . __METHOD__, 'process-data' );
89            }
90        }
91
92        return $data;
93    }
94
95    /**
96     * Internal security function which checks a row object
97     * (for inclusion as a condition or a row for insert/update)
98     * for any numeric keys (= raw SQL), or field names with
99     * potentially unsafe characters.
100     *
101     * @param array $row The row to check.
102     * @return bool True if raw SQL is found
103     */
104    protected function hasUnescapedSQL( array $row ) {
105        foreach ( $row as $key => $value ) {
106            if ( $value instanceof RawSql ) {
107                // Specifically allowed SQL
108                continue;
109            }
110
111            if ( is_numeric( $key ) ) {
112                return true;
113            }
114
115            if ( !preg_match( '/^' . $this->getFieldRegexFragment() . '$/', $key ) ) {
116                return true;
117            }
118        }
119
120        return false;
121    }
122
123    /**
124     * Returns a regular expression fragment suitable for matching a valid
125     * SQL field name, and hopefully no injection attacks
126     * @return string Regular expression fragment
127     */
128    protected function getFieldRegexFragment() {
129        return '\s*[A-Za-z0-9\._]+\s*';
130    }
131
132    /**
133     * Internal security function to check an options array for
134     * SQL injection and other funkiness
135     * @todo Currently only supports LIMIT, OFFSET and ORDER BY
136     * @param array $options An options array passed to a query.
137     * @return bool
138     */
139    protected function validateOptions( $options ) {
140        static $validUnaryOptions = [
141            'UNIQUE',
142            'EXPLAIN',
143        ];
144
145        $fieldRegex = $this->getFieldRegexFragment();
146
147        foreach ( $options as $key => $value ) {
148            if ( is_numeric( $key ) && in_array( strtoupper( $value ), $validUnaryOptions ) ) {
149                continue;
150            } elseif ( is_numeric( $key ) ) {
151                wfDebug( __METHOD__ . ": Unrecognised unary operator $value\n" );
152                return false;
153            }
154
155            if ( $key === 'LIMIT' ) {
156                // LIMIT is one or two integers, separated by a comma.
157                if ( !preg_match( '/^\d+(,\d+)?$/', $value ) ) {
158                    wfDebug( __METHOD__ . ": Invalid LIMIT $value\n" );
159                    return false;
160                }
161            } elseif ( $key === 'ORDER BY' ) {
162                // ORDER BY is a list of field names with ASC / DESC afterwards
163                if ( is_string( $value ) ) {
164                    $value = explode( ',', $value );
165                }
166                $orderByRegex = "/^\s*$fieldRegex\s*(ASC|DESC)?\s*$/i";
167
168                foreach ( $value as $orderByField ) {
169                    if ( !preg_match( $orderByRegex, $orderByField ) ) {
170                        wfDebug( __METHOD__ . ": invalid ORDER BY field $orderByField\n" );
171                        return false;
172                    }
173                }
174            } elseif ( $key === 'OFFSET' ) {
175                // OFFSET is just an integer
176                if ( !is_numeric( $value ) ) {
177                    wfDebug( __METHOD__ . ": non-numeric offset $value\n" );
178                    return false;
179                }
180            } elseif ( $key === 'GROUP BY' ) {
181                if ( !preg_match( "/^{$fieldRegex}(,{$fieldRegex})+$/", $value ) ) {
182                    wfDebug( __METHOD__ . ": invalid GROUP BY field\n" );
183                }
184            } else {
185                wfDebug( __METHOD__ . ": Unknown option $key\n" );
186                return false;
187            }
188        }
189
190        // Everything passes
191        return true;
192    }
193
194    /**
195     * @inheritDoc
196     */
197    public function validate( array $row ) {
198        return true;
199    }
200
201    /**
202     * Calculates the DB updates to be performed to update data from $old to
203     * $new.
204     *
205     * @param array $old
206     * @param array $new
207     * @return array
208     * @throws DataModelException
209     */
210    public function calcUpdates( array $old, array $new ) {
211        $changeSet = ObjectManager::calcUpdatesWithoutValidation( $old, $new );
212
213        foreach ( $this->obsoleteUpdateColumns as $val ) {
214            // Need to use array_key_exists to check null value
215            if ( array_key_exists( $val, $changeSet ) ) {
216                unset( $changeSet[$val] );
217            }
218        }
219
220        if ( is_array( $this->allowedUpdateColumns ) ) {
221            $extra = array_diff( array_keys( $changeSet ), $this->allowedUpdateColumns );
222            if ( $extra ) {
223                throw new DataModelException( 'Update not allowed on: ' . implode( ', ', $extra ), 'process-data' );
224            }
225        }
226
227        return $changeSet;
228    }
229}