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