Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
23.53% |
16 / 68 |
|
12.50% |
1 / 8 |
CRAP | |
0.00% |
0 / 1 |
DbStorage | |
23.53% |
16 / 68 |
|
12.50% |
1 / 8 |
615.55 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
preprocessNestedSqlArray | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
preprocessSqlArray | |
70.00% |
7 / 10 |
|
0.00% |
0 / 1 |
5.68 | |||
hasUnescapedSQL | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
30 | |||
getFieldRegexFragment | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
validateOptions | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
240 | |||
validate | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
calcUpdates | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
5.03 |
1 | <?php |
2 | |
3 | namespace Flow\Data\Storage; |
4 | |
5 | use Flow\Data\ObjectManager; |
6 | use Flow\Data\ObjectStorage; |
7 | use Flow\Data\Utils\RawSql; |
8 | use Flow\DbFactory; |
9 | use Flow\Exception\DataModelException; |
10 | use 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 | */ |
19 | abstract 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 | } |