Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
37.29% covered (danger)
37.29%
66 / 177
25.00% covered (danger)
25.00%
4 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
XmlTypeCheck
37.50% covered (danger)
37.50%
66 / 176
25.00% covered (danger)
25.00%
4 / 16
1196.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 newFromFilename
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newFromString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRootElement
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateFromInput
56.25% covered (warning)
56.25%
9 / 16
0.00% covered (danger)
0.00%
0 / 1
5.34
 readNext
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
1.02
 validate
66.67% covered (warning)
66.67%
26 / 39
0.00% covered (danger)
0.00%
0 / 1
32.37
 getAttributesArray
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
4.68
 expandNS
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
2.86
 elementOpen
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 elementClose
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
3.33
 elementData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 processingInstructionHandler
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 dtdHandler
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
156
 checkDTDIsSafe
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 parseDTD
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
210
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
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. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace Wikimedia\Mime;
22
23use Exception;
24use XMLReader;
25
26/**
27 * XML syntax and type checker.
28 *
29 * Since MediaWiki 1.24.2, this uses XMLReader instead of xml_parse, which gives us
30 * more control over the expansion of XML entities. When passed to the
31 * callback, entities will be fully expanded, but may report the XML is
32 * invalid if expanding the entities are likely to cause a DoS.
33 *
34 * @newable
35 * @since 1.12.0
36 * @ingroup Mime
37 */
38class XmlTypeCheck {
39    /**
40     * @var bool|null Will be set to true or false to indicate whether the file is
41     * well-formed XML. Note that this doesn't check schema validity.
42     */
43    public $wellFormed = null;
44
45    /**
46     * @var bool Will be set to true if the optional element filter returned
47     * a match at some point.
48     */
49    public $filterMatch = false;
50
51    /**
52     * Will contain the type of filter hit if the optional element filter returned
53     * a match at some point.
54     * @var mixed
55     */
56    public $filterMatchType = false;
57
58    /**
59     * @var string Name of the document's root element, including any namespace
60     * as an expanded URL.
61     */
62    public $rootElement = '';
63
64    /**
65     * @var string[] A stack of strings containing the data of each xml element as it's processed.
66     * Append data to the top string of the stack, then pop off the string and process it when the
67     * element is closed.
68     */
69    protected $elementData = [];
70
71    /**
72     * @var array A stack of element names and attributes, as we process them.
73     */
74    protected $elementDataContext = [];
75
76    /**
77     * @var int Current depth of the data stack.
78     */
79    protected $stackDepth = 0;
80
81    /** @var callable|null */
82    protected $filterCallback;
83
84    /**
85     * @var array Additional parsing options
86     */
87    private $parserOptions = [
88        'processing_instruction_handler' => null,
89        'external_dtd_handler' => '',
90        'dtd_handler' => '',
91        'require_safe_dtd' => true
92    ];
93
94    /**
95     * Allow filtering an XML file.
96     *
97     * Filters should return either true or a string to indicate something
98     * is wrong with the file. $this->filterMatch will store if the
99     * file failed validation (true = failed validation).
100     * $this->filterMatchType will contain the validation error.
101     * $this->wellFormed will contain whether the xml file is well-formed.
102     *
103     * @note If multiple filters are hit, only one of them will have the
104     *  result stored in $this->filterMatchType.
105     *
106     * @param string $input a filename or string containing the XML element
107     * @param callable|null $filterCallback (optional)
108     *        Function to call to do additional custom validity checks from the
109     *        SAX element handler event. This gives you access to the element
110     *        namespace, name, attributes, and text contents.
111     *        Filter should return a truthy value describing the error.
112     * @param bool $isFile (optional) indicates if the first parameter is a
113     *        filename (default, true) or if it is a string (false)
114     * @param array $options list of additional parsing options:
115     *        processing_instruction_handler: Callback for xml_set_processing_instruction_handler
116     *        external_dtd_handler: Callback for the url of external dtd subset
117     *        dtd_handler: Callback given the full text of the <!DOCTYPE declaration.
118     *        require_safe_dtd: Only allow non-recursive entities in internal dtd (default true)
119     */
120    public function __construct( $input, $filterCallback = null, $isFile = true, $options = [] ) {
121        $this->filterCallback = $filterCallback;
122        $this->parserOptions = array_merge( $this->parserOptions, $options );
123        $this->validateFromInput( $input, $isFile );
124    }
125
126    /**
127     * Alternative constructor: from filename
128     *
129     * @param string $fname the filename of an XML document
130     * @param callable|null $filterCallback (optional)
131     *        Function to call to do additional custom validity checks from the
132     *        SAX element handler event. This gives you access to the element
133     *        namespace, name, and attributes, but not to text contents.
134     *        Filter should return 'true' to toggle on $this->filterMatch
135     * @return XmlTypeCheck
136     */
137    public static function newFromFilename( $fname, $filterCallback = null ) {
138        return new self( $fname, $filterCallback, true );
139    }
140
141    /**
142     * Alternative constructor: from string
143     *
144     * @param string $string a string containing an XML element
145     * @param callable|null $filterCallback (optional)
146     *        Function to call to do additional custom validity checks from the
147     *        SAX element handler event. This gives you access to the element
148     *        namespace, name, and attributes, but not to text contents.
149     *        Filter should return 'true' to toggle on $this->filterMatch
150     * @return XmlTypeCheck
151     */
152    public static function newFromString( $string, $filterCallback = null ) {
153        return new self( $string, $filterCallback, false );
154    }
155
156    /**
157     * Get the root element. Simple accessor to $rootElement
158     *
159     * @return string
160     */
161    public function getRootElement() {
162        return $this->rootElement;
163    }
164
165    /**
166     * @param string $xml
167     * @param bool $isFile
168     */
169    private function validateFromInput( $xml, $isFile ) {
170        $reader = new XMLReader();
171        if ( $isFile ) {
172            $s = $reader->open( $xml, null, LIBXML_NOERROR | LIBXML_NOWARNING );
173        } else {
174            $s = $reader->XML( $xml, null, LIBXML_NOERROR | LIBXML_NOWARNING );
175        }
176        if ( $s !== true ) {
177            // Couldn't open the XML
178            $this->wellFormed = false;
179        } else {
180            // phpcs:ignore Generic.PHP.NoSilencedErrors -- suppress deprecation per T268847
181            $oldDisable = @libxml_disable_entity_loader( true );
182            $reader->setParserProperty( XMLReader::SUBST_ENTITIES, true );
183            try {
184                $this->validate( $reader );
185            } catch ( Exception $e ) {
186                // Calling this malformed, because we didn't parse the whole
187                // thing. Maybe just an external entity refernce.
188                $this->wellFormed = false;
189                $reader->close();
190                // phpcs:ignore Generic.PHP.NoSilencedErrors
191                @libxml_disable_entity_loader( $oldDisable );
192                throw $e;
193            }
194            $reader->close();
195            // phpcs:ignore Generic.PHP.NoSilencedErrors
196            @libxml_disable_entity_loader( $oldDisable );
197        }
198    }
199
200    private function readNext( XMLReader $reader ): bool {
201        set_error_handler( function ( $line, $file ) {
202            $this->wellFormed = false;
203            return true;
204        } );
205        $ret = $reader->read();
206        restore_error_handler();
207        return $ret;
208    }
209
210    private function validate( XMLReader $reader ) {
211        // First, move through anything that isn't an element, and
212        // handle any processing instructions with the callback
213        do {
214            if ( !$this->readNext( $reader ) ) {
215                // Hit the end of the document before any elements
216                $this->wellFormed = false;
217                return;
218            }
219            if ( $reader->nodeType === XMLReader::PI ) {
220                $this->processingInstructionHandler( $reader->name, $reader->value );
221            }
222            if ( $reader->nodeType === XMLReader::DOC_TYPE ) {
223                $this->dtdHandler( $reader );
224            }
225        } while ( $reader->nodeType != XMLReader::ELEMENT );
226
227        // Process the rest of the document
228        do {
229            switch ( $reader->nodeType ) {
230                case XMLReader::ELEMENT:
231                    $name = $this->expandNS(
232                        $reader->name,
233                        $reader->namespaceURI
234                    );
235                    if ( $this->rootElement === '' ) {
236                        $this->rootElement = $name;
237                    }
238                    $empty = $reader->isEmptyElement;
239                    $attrs = $this->getAttributesArray( $reader );
240                    $this->elementOpen( $name, $attrs );
241                    if ( $empty ) {
242                        $this->elementClose();
243                    }
244                    break;
245
246                case XMLReader::END_ELEMENT:
247                    $this->elementClose();
248                    break;
249
250                case XMLReader::WHITESPACE:
251                case XMLReader::SIGNIFICANT_WHITESPACE:
252                case XMLReader::CDATA:
253                case XMLReader::TEXT:
254                    $this->elementData( $reader->value );
255                    break;
256
257                case XMLReader::ENTITY_REF:
258                    // Unexpanded entity (maybe external?),
259                    // don't send to the filter (xml_parse didn't)
260                    break;
261
262                case XMLReader::COMMENT:
263                    // Don't send to the filter (xml_parse didn't)
264                    break;
265
266                case XMLReader::PI:
267                    // Processing instructions can happen after the header too
268                    $this->processingInstructionHandler(
269                        $reader->name,
270                        $reader->value
271                    );
272                    break;
273                case XMLReader::DOC_TYPE:
274                    // We should never see a doctype after first
275                    // element.
276                    $this->wellFormed = false;
277                    break;
278                default:
279                    // One of DOC, ENTITY, END_ENTITY,
280                    // NOTATION, or XML_DECLARATION
281                    // xml_parse didn't send these to the filter, so we won't.
282            }
283        } while ( $this->readNext( $reader ) );
284
285        if ( $this->stackDepth !== 0 ) {
286            $this->wellFormed = false;
287        } elseif ( $this->wellFormed === null ) {
288            $this->wellFormed = true;
289        }
290    }
291
292    /**
293     * Get all of the attributes for an XMLReader's current node
294     * @param XMLReader $r
295     * @return array of attributes
296     */
297    private function getAttributesArray( XMLReader $r ) {
298        $attrs = [];
299        while ( $r->moveToNextAttribute() ) {
300            if ( $r->namespaceURI === 'http://www.w3.org/2000/xmlns/' ) {
301                // XMLReader treats xmlns attributes as normal
302                // attributes, while xml_parse doesn't
303                continue;
304            }
305            $name = $this->expandNS( $r->name, $r->namespaceURI );
306            $attrs[$name] = $r->value;
307        }
308        return $attrs;
309    }
310
311    /**
312     * @param string $name element or attribute name, maybe with a full or short prefix
313     * @param string $namespaceURI
314     * @return string the name prefixed with namespaceURI
315     */
316    private function expandNS( $name, $namespaceURI ) {
317        if ( $namespaceURI ) {
318            $parts = explode( ':', $name );
319            $localname = array_pop( $parts );
320            return "$namespaceURI:$localname";
321        }
322        return $name;
323    }
324
325    /**
326     * @param string $name
327     * @param array $attribs
328     */
329    private function elementOpen( $name, $attribs ) {
330        $this->elementDataContext[] = [ $name, $attribs ];
331        $this->elementData[] = '';
332        $this->stackDepth++;
333    }
334
335    private function elementClose() {
336        [ $name, $attribs ] = array_pop( $this->elementDataContext );
337        $data = array_pop( $this->elementData );
338        $this->stackDepth--;
339        $callbackReturn = false;
340
341        if ( is_callable( $this->filterCallback ) ) {
342            $callbackReturn = ( $this->filterCallback )( $name, $attribs, $data );
343        }
344        if ( $callbackReturn ) {
345            // Filter hit!
346            $this->filterMatch = true;
347            $this->filterMatchType = $callbackReturn;
348        }
349    }
350
351    /**
352     * @param string $data
353     */
354    private function elementData( $data ) {
355        // Collect any data here, and we'll run the callback in elementClose
356        $this->elementData[ $this->stackDepth - 1 ] .= trim( $data );
357    }
358
359    /**
360     * @param string $target
361     * @param string $data
362     */
363    private function processingInstructionHandler( $target, $data ) {
364        $callbackReturn = false;
365        if ( $this->parserOptions['processing_instruction_handler'] ) {
366            // @phan-suppress-next-line PhanTypeInvalidCallable false positive
367            $callbackReturn = $this->parserOptions['processing_instruction_handler'](
368                $target,
369                $data
370            );
371        }
372        if ( $callbackReturn ) {
373            // Filter hit!
374            $this->filterMatch = true;
375            $this->filterMatchType = $callbackReturn;
376        }
377    }
378
379    /**
380     * Handle coming across a <!DOCTYPE declaration.
381     *
382     * @param XMLReader $reader Reader currently pointing at DOCTYPE node.
383     */
384    private function dtdHandler( XMLReader $reader ) {
385        $externalCallback = $this->parserOptions['external_dtd_handler'];
386        $generalCallback = $this->parserOptions['dtd_handler'];
387        $checkIfSafe = $this->parserOptions['require_safe_dtd'];
388        if ( !$externalCallback && !$generalCallback && !$checkIfSafe ) {
389            return;
390        }
391        $dtd = $reader->readOuterXml();
392        $callbackReturn = false;
393
394        if ( $generalCallback ) {
395            $callbackReturn = $generalCallback( $dtd );
396        }
397        if ( $callbackReturn ) {
398            // Filter hit!
399            $this->filterMatch = true;
400            $this->filterMatchType = $callbackReturn;
401            $callbackReturn = false;
402        }
403
404        $parsedDTD = $this->parseDTD( $dtd );
405        if ( $externalCallback && isset( $parsedDTD['type'] ) ) {
406            $callbackReturn = $externalCallback(
407                $parsedDTD['type'],
408                $parsedDTD['publicid'] ?? null,
409                $parsedDTD['systemid'] ?? null
410            );
411        }
412        if ( $callbackReturn ) {
413            // Filter hit!
414            $this->filterMatch = true;
415            $this->filterMatchType = $callbackReturn;
416        }
417
418        if ( $checkIfSafe && isset( $parsedDTD['internal'] ) &&
419            !$this->checkDTDIsSafe( $parsedDTD['internal'] )
420        ) {
421            $this->wellFormed = false;
422        }
423    }
424
425    /**
426     * Check if the internal subset of the DTD is safe.
427     *
428     * We whitelist an extremely restricted subset of DTD features.
429     *
430     * Safe is defined as:
431     *  * Only contains entity definitions (e.g. No <!ATLIST )
432     *  * Entity definitions are not "system" entities
433     *  * Entity definitions are not "parameter" (i.e. %) entities
434     *  * Entity definitions do not reference other entities except &amp;
435     *    and quotes. Entity aliases (where the entity contains only
436     *    another entity are allowed)
437     *  * Entity references aren't overly long (>255 bytes).
438     *  * <!ATTLIST svg xmlns:xlink CDATA #FIXED "http://www.w3.org/1999/xlink">
439     *    allowed if matched exactly for compatibility with graphviz
440     *  * Comments.
441     *
442     * @param string $internalSubset The internal subset of the DTD
443     * @return bool true if safe.
444     */
445    private function checkDTDIsSafe( $internalSubset ) {
446        $res = preg_match(
447            '/^(?:\s*<!ENTITY\s+\S+\s+' .
448                '(?:"(?:&[^"%&;]{1,64};|(?:[^"%&]|&amp;|&quot;){0,255})"' .
449                '|\'(?:&[^\'%&;]{1,64};|(?:[^\'%&]|&amp;|&apos;){0,255})\')\s*>' .
450                '|\s*<!--(?:[^-]|-[^-])*-->' .
451                '|\s*<!ATTLIST svg xmlns:xlink CDATA #FIXED ' .
452                '"http:\/\/www.w3.org\/1999\/xlink">)*\s*$/',
453            $internalSubset
454        );
455
456        return (bool)$res;
457    }
458
459    /**
460     * Parse DTD into parts.
461     *
462     * If there is an error parsing the dtd, sets wellFormed to false.
463     *
464     * @param string $dtd
465     * @return array Possibly containing keys publicid, systemid, type and internal.
466     */
467    private function parseDTD( $dtd ) {
468        $m = [];
469        $res = preg_match(
470            '/^<!DOCTYPE\s*\S+\s*' .
471            '(?:(?P<typepublic>PUBLIC)\s*' .
472                '(?:"(?P<pubquote>[^"]*)"|\'(?P<pubapos>[^\']*)\')' . // public identifer
473                '\s*"(?P<pubsysquote>[^"]*)"|\'(?P<pubsysapos>[^\']*)\'' . // system identifier
474            '|(?P<typesystem>SYSTEM)\s*' .
475                '(?:"(?P<sysquote>[^"]*)"|\'(?P<sysapos>[^\']*)\')' .
476            ')?\s*' .
477            '(?:\[\s*(?P<internal>.*)\])?\s*>$/s',
478            $dtd,
479            $m
480        );
481        if ( !$res ) {
482            $this->wellFormed = false;
483            return [];
484        }
485        $parsed = [];
486        foreach ( $m as $field => $value ) {
487            if ( $value === '' || is_numeric( $field ) ) {
488                continue;
489            }
490            switch ( $field ) {
491                case 'typepublic':
492                case 'typesystem':
493                    $parsed['type'] = $value;
494                    break;
495                case 'pubquote':
496                case 'pubapos':
497                    $parsed['publicid'] = $value;
498                    break;
499                case 'pubsysquote':
500                case 'pubsysapos':
501                case 'sysquote':
502                case 'sysapos':
503                    $parsed['systemid'] = $value;
504                    break;
505                case 'internal':
506                    $parsed['internal'] = $value;
507                    break;
508            }
509        }
510        return $parsed;
511    }
512}
513
514/** @deprecated class alias since 1.43 */
515class_alias( XmlTypeCheck::class, 'XmlTypeCheck' );