Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.67% covered (warning)
77.67%
400 / 515
28.57% covered (danger)
28.57%
4 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
UploadVerification
77.67% covered (warning)
77.67%
400 / 515
28.57% covered (danger)
28.57%
4 / 14
329.96
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 verifyMimeType
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 verifyFile
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
156
 verifyPartialFile
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
90
 verifyExtension
72.50% covered (warning)
72.50%
29 / 40
0.00% covered (danger)
0.00%
0 / 1
9.33
 detectScript
65.96% covered (warning)
65.96%
31 / 47
0.00% covered (danger)
0.00%
0 / 1
26.10
 checkXMLEncodingMismatch
51.61% covered (warning)
51.61%
16 / 31
0.00% covered (danger)
0.00%
0 / 1
32.15
 detectScriptInSvg
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 checkSvgPICallback
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 checkSvgExternalDTD
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 checkSvgScriptCallback
90.84% covered (success)
90.84%
238 / 262
0.00% covered (danger)
0.00%
0 / 1
45.49
 splitXmlNamespace
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 stripXmlNamespace
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 detectVirus
95.92% covered (success)
95.92%
47 / 49
0.00% covered (danger)
0.00%
0 / 1
15
1<?php
2/**
3 * Base class for the backend of file upload.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 * @ingroup Upload
8 */
9
10namespace MediaWiki\Upload;
11
12use MediaWiki\Config\ConfigException;
13use MediaWiki\Config\ServiceOptions;
14use MediaWiki\MainConfigNames;
15use MediaWiki\Media\MediaHandler;
16use MediaWiki\Parser\Sanitizer;
17use MediaWiki\Shell\Shell;
18use Psr\Log\LoggerInterface;
19use Wikimedia\Mime\MimeAnalyzer;
20use Wikimedia\Mime\XmlTypeCheck;
21
22/**
23 * @ingroup Upload
24 *
25 * Service to verify file uploads are safe.
26 *
27 * This is responsible for checks on the file contents themselves. It
28 * is not responsible for on wiki checks like if the user has permission
29 * or if the upload target is protected.
30 *
31 * @author Brian Wolff
32 * @since 1.45
33 */
34class UploadVerification {
35
36    private const SAFE_XML_ENCODINGS = [
37        'UTF-8',
38        'US-ASCII',
39        'ISO-8859-1',
40        'ISO-8859-2',
41        'UTF-16',
42        'UTF-32',
43        'WINDOWS-1250',
44        'WINDOWS-1251',
45        'WINDOWS-1252',
46        'WINDOWS-1253',
47        'WINDOWS-1254',
48        'WINDOWS-1255',
49        'WINDOWS-1256',
50        'WINDOWS-1257',
51        'WINDOWS-1258',
52    ];
53
54    public const CONSTRUCTOR_OPTIONS = [
55        MainConfigNames::VerifyMimeType,
56        MainConfigNames::MimeTypeExclusions,
57        MainConfigNames::DisableUploadScriptChecks,
58        MainConfigNames::Antivirus,
59        MainConfigNames::AntivirusSetup,
60        MainConfigNames::AntivirusRequired
61    ];
62
63    private SvgCssChecker $svgCssChecker;
64
65    public function __construct(
66        private ServiceOptions $config,
67        private MimeAnalyzer $mimeAnalyzer,
68        private LoggerInterface $logger,
69    ) {
70        $config->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
71        $this->svgCssChecker = new SvgCssChecker;
72    }
73
74    /**
75     * Verify the MIME type.
76     *
77     * @note Only checks that it is not an evil MIME.
78     *  The "does it have the correct file extension given its MIME type?" check is in verifyFile.
79     * @param string $mime Representing the MIME
80     * @return array|bool True if the file is verified, an array otherwise
81     */
82    private function verifyMimeType( $mime ) {
83        $verifyMimeType = $this->config->get( MainConfigNames::VerifyMimeType );
84        if ( $verifyMimeType ) {
85            $mimeTypeExclusions = $this->config
86                ->get( MainConfigNames::MimeTypeExclusions );
87            if ( UploadBase::checkFileExtension( $mime, $mimeTypeExclusions ) ) {
88                return [ 'filetype-badmime', $mime ];
89            }
90        }
91
92        return true;
93    }
94
95    /**
96     * Verifies that the upload file is safe
97     *
98     * @note This verifies the contents of the file. It is not
99     *  responsible for verifying if the file has a valid name, is
100     *  too big, meets on wiki permission checks, etc. If you are
101     *  implementing your own upload support, see
102     *  UploadBase::verifyUpload for other necessary checks.
103     *
104     * @param string $path Path to the (temporary) file to check
105     * @param string $ext Final extension of file (UploadBase->mFinalExtension)
106     * @param array $fileProps Result of $mwProps->getPropsFromPath.
107     * FIXME final ext can sometimes be null, but should we require casting to string?
108     * @return array|true True of the file is verified, array otherwise.
109     */
110    public function verifyFile( string $path, string $ext, array $fileProps ) {
111        $config = $this->config;
112        $verifyMimeType = $config->get( MainConfigNames::VerifyMimeType );
113        $disableUploadScriptChecks = $config->get( MainConfigNames::DisableUploadScriptChecks );
114        $status = $this->verifyPartialFile( $path, $ext, $fileProps );
115        if ( $status !== true ) {
116            return $status;
117        }
118
119        $mime = $fileProps['mime'];
120
121        if ( $verifyMimeType ) {
122            # XXX: Missing extension will be caught by validateName() via getTitle()
123            if ( $ext !== '' &&
124                !$this->verifyExtension( $mime, $ext )
125            ) {
126                return [ 'filetype-mime-mismatch', $ext, $mime ];
127            }
128        }
129
130        # check for htmlish code and javascript
131        if ( !$disableUploadScriptChecks ) {
132            if ( $ext === 'svg' || $mime === 'image/svg+xml' ) {
133                $svgStatus = $this->detectScriptInSvg( $path, false );
134                if ( $svgStatus !== false ) {
135                    return $svgStatus;
136                }
137            }
138        }
139
140        $handler = $mime !== null ? MediaHandler::getHandler( $mime ) : null;
141        if ( $handler ) {
142            $handlerStatus = $handler->verifyUpload( $path );
143            if ( !$handlerStatus->isOK() ) {
144                $errors = $handlerStatus->getErrorsArray();
145
146                return reset( $errors );
147            }
148        }
149
150        // TODO: Perhaps we should have a hook here akin to UploadVerifyFile
151        // except that it doesn't pass an UploadBase, and it would run
152        // even when someone is verifying a file not going through UploadBase.
153
154        $this->logger->debug( 'verifyFile: all clear; passing.' );
155
156        return true;
157    }
158
159    /**
160     * A verification routine suitable for partial files
161     *
162     * Runs the deny list checks, but not any checks that may
163     * assume the entire file is present.
164     *
165     * fileProps can be very expensive to calculate, so the calling class is
166     * responsible for caching it.
167     *
168     * @param string $path Path to the (temporary) file to check
169     * @param string $ext Final extension of file (UploadBase->mFinalExtension)
170     * @param array $fileProps Result of $mwProps->getPropsFromPath
171     * @return array|true True, if the file is valid, else an array with error message key.
172     * @phan-return non-empty-array|true
173     */
174    public function verifyPartialFile( string $path, string $ext, array $fileProps ) {
175        $config = $this->config;
176        $disableUploadScriptChecks = $config->get( MainConfigNames::DisableUploadScriptChecks );
177
178        # check MIME type, if desired
179        $mime = $fileProps['file-mime'];
180        $status = $this->verifyMimeType( $mime );
181        $this->logger->debug( 'verifyMimeType: {mime} {status}',
182            [ 'mime' => $mime, 'status' => $status === true ? 'OK' : 'bad' ] );
183        if ( $status !== true ) {
184            return $status;
185        }
186
187        # check for htmlish code and javascript
188        if ( !$disableUploadScriptChecks ) {
189            if ( $this->detectScript( $path, $mime, $ext ) ) {
190                return [ 'uploadscripted' ];
191            }
192            if ( $ext === 'svg' || $mime === 'image/svg+xml' ) {
193                $svgStatus = $this->detectScriptInSvg( $path, true );
194                if ( $svgStatus !== false ) {
195                    return $svgStatus;
196                }
197            }
198        }
199
200        # Scan the uploaded file for viruses
201        $virus = $this->detectVirus( $path );
202        if ( $virus ) {
203            return [ 'uploadvirus', $virus ];
204        }
205
206        return true;
207    }
208
209    /**
210     * Checks if the MIME type of the uploaded file matches the file extension.
211     *
212     * @internal Will become private once UploadBase::verifyExtension is removed
213     * @param string $mime The MIME type of the uploaded file
214     * @param string $extension The filename extension that the file is to be served with
215     * @return bool
216     */
217    public function verifyExtension( $mime, $extension ) {
218        $magic = $this->mimeAnalyzer;
219        $logContext = [ 'mime' => $mime, 'extension' => $extension ];
220
221        if ( !$mime || $mime === 'unknown' || $mime === 'unknown/unknown' ) {
222            if ( !$magic->isRecognizableExtension( $extension ) ) {
223                $this->logger->debug(
224                    'verifyExtension: passing file with unknown detected MIME type; ' .
225                        "unrecognized extension '{extension}', can't verify",
226                    $logContext
227                );
228
229                return true;
230            }
231
232            $this->logger->debug(
233                'verifyExtension: rejecting file with unknown detected MIME type; ' .
234                    'recognized extension "{extension}", so probably invalid file',
235                $logContext
236            );
237            return false;
238        }
239
240        $match = $magic->isMatchingExtension( $extension, $mime );
241
242        if ( $match === null ) {
243            if ( $magic->getMimeTypesFromExtension( $extension ) !== [] ) {
244                $this->logger->debug(
245                    'verifyExtension: No extension known for {mime}, but we know a MIME for {extension}',
246                    $logContext
247                );
248                return false;
249            }
250
251            $this->logger->debug(
252                'verifyExtension: no file extension known for MIME type {mime}, passing file',
253                $logContext
254            );
255            return true;
256        }
257
258        if ( $match ) {
259            $this->logger->debug(
260                'verifyExtension: MIME type {mime} matches extension {extension}, passing file',
261                $logContext
262            );
263
264            /** @todo If it's a bitmap, make sure PHP or ImageMagick resp. can handle it! */
265            return true;
266        }
267
268        $this->logger->debug(
269            'verifyExtension: MIME type {mime} mismatches file extension {extension}, rejecting file',
270            $logContext
271        );
272
273        return false;
274    }
275
276    /**
277     * Heuristic for detecting files that *could* contain JavaScript instructions or
278     * things that may look like HTML to a browser and are thus
279     * potentially harmful. The present implementation will produce false
280     * positives in some situations.
281     *
282     * @internal This is public for back-compat. Some extensions call this, however
283     *  this is probably not the method they want. Instead they should call verifyFile().
284     *  Calling this outside this class should be considered deprecated and the method
285     *  may become private in the future.
286     * @param string|null $file Pathname to the temporary upload file
287     * @param string $mime The MIME type of the file
288     * @param string|null $extension The extension of the file
289     * @return bool True if the file contains something looking like embedded scripts
290     */
291    public function detectScript( $file, $mime, $extension ) {
292        # ugly hack: for text files, always look at the entire file.
293        # For binary field, just check the first K.
294
295        if ( str_starts_with( $mime ?? '', 'text/' ) ) {
296            $chunk = file_get_contents( $file );
297        } else {
298            $fp = fopen( $file, 'rb' );
299            if ( !$fp ) {
300                return false;
301            }
302            $chunk = fread( $fp, 1024 );
303            fclose( $fp );
304        }
305
306        $chunk = strtolower( $chunk );
307
308        if ( !$chunk ) {
309            return false;
310        }
311
312        # decode from UTF-16 if needed (could be used for obfuscation).
313        if ( str_starts_with( $chunk, "\xfe\xff" ) ) {
314            $enc = 'UTF-16BE';
315        } elseif ( str_starts_with( $chunk, "\xff\xfe" ) ) {
316            $enc = 'UTF-16LE';
317        } else {
318            $enc = null;
319        }
320
321        if ( $enc !== null ) {
322            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
323            $chunk = @iconv( $enc, "ASCII//IGNORE", $chunk );
324        }
325
326        $chunk = trim( $chunk );
327
328        /** @todo FIXME: Convert from UTF-16 if necessary! */
329
330        # check for HTML doctype
331        if ( preg_match( "/<!DOCTYPE *X?HTML/i", $chunk ) ) {
332            $this->logger->debug( 'detectScript: found doctype html' );
333            return true;
334        }
335
336        // Some browsers will interpret obscure xml encodings as UTF-8, while
337        // PHP/expat will interpret the given encoding in the xml declaration (T49304)
338        if ( $extension === 'svg' || str_starts_with( $mime ?? '', 'image/svg' ) ) {
339            if ( $this->checkXMLEncodingMismatch( $file ) ) {
340                $this->logger->debug( 'detectScript: found SVG encoding mismatch' );
341                return true;
342            }
343        }
344
345        // Quick check for HTML heuristics in old IE and Safari.
346        //
347        // The exact heuristics IE uses are checked separately via verifyMimeType(), so we
348        // don't need them all here as it can cause many false positives.
349        //
350        // Check for `<script` and such still to forbid script tags and embedded HTML in SVG:
351        $tags = [
352            '<body',
353            '<head',
354            '<html', # also in safari
355            '<script', # also in safari
356        ];
357
358        foreach ( $tags as $tag ) {
359            if ( str_contains( $chunk, $tag ) ) {
360                $this->logger->debug( 'detectScript: found HTML tag "{tag}"', [ 'tag' => $tag ] );
361                return true;
362            }
363        }
364
365        /*
366         * look for JavaScript
367         */
368
369        # resolve entity-refs to look at attributes. may be harsh on big files... cache result?
370        $chunk = Sanitizer::decodeCharReferences( $chunk );
371
372        # look for script-types
373        if ( preg_match( '!type\s*=\s*[\'"]?\s*(?:\w*/)?(?:ecma|java)!im', $chunk ) ) {
374            $this->logger->debug( 'detectScript: found script types' );
375            return true;
376        }
377
378        # look for html-style script-urls
379        if ( preg_match( '!(?:href|src|data)\s*=\s*[\'"]?\s*(?:ecma|java)script:!im', $chunk ) ) {
380            $this->logger->debug( 'detectScript: found HTML-style script URLs' );
381            return true;
382        }
383
384        # look for css-style script-urls
385        if ( preg_match( '!url\s*\(\s*[\'"]?\s*(?:ecma|java)script:!im', $chunk ) ) {
386            $this->logger->debug( 'detectScript: found CSS-style script URLs' );
387            return true;
388        }
389
390        $this->logger->debug( 'detectScript: no scripts found' );
391        return false;
392    }
393
394    /**
395     * Check an allowed list of xml encodings that are known not to be interpreted differently
396     * by the server's xml parser (expat) and some common browsers.
397     *
398     * @param string $file Pathname to the temporary upload file
399     * @return bool True if the file contains an encoding that could be misinterpreted
400     */
401    private function checkXMLEncodingMismatch( $file ) {
402        // https://mimesniff.spec.whatwg.org/#resource-header says browsers
403        // should read the first 1445 bytes. Do 4096 bytes for good measure.
404        // XML Spec says XML declaration if present must be first thing in file
405        // other than BOM
406        $contents = file_get_contents( $file, false, null, 0, 4096 );
407        $encodingRegex = '!encoding[ \t\n\r]*=[ \t\n\r]*[\'"](.*?)[\'"]!si';
408
409        if ( preg_match( "!<\?xml\b(.*?)\?>!si", $contents, $matches ) ) {
410            if ( preg_match( $encodingRegex, $matches[1], $encMatch )
411                && !in_array( strtoupper( $encMatch[1] ), self::SAFE_XML_ENCODINGS )
412            ) {
413                $this->logger->debug(
414                    'checkXMLEncodingMismatch: Found unsafe XML encoding "{encoding}"',
415                    [ 'encoding' => $encMatch[1] ]
416                );
417                return true;
418            }
419        } elseif ( preg_match( "!<\?xml\b!i", $contents ) ) {
420            // Start of XML declaration without an end in the first 4096 bytes
421            // bytes. There shouldn't be a legitimate reason for this to happen.
422            $this->logger->debug( 'checkXMLEncodingMismatch: Unmatched XML declaration start' );
423            return true;
424        } elseif ( str_starts_with( $contents, "\x4C\x6F\xA7\x94" ) ) {
425            // EBCDIC encoded XML
426            $this->logger->debug( 'checkXMLEncodingMismatch: EBCDIC Encoded XML' );
427            return true;
428        }
429
430        // It's possible the file is encoded with multibyte encoding, so re-encode attempt to
431        // detect the encoding in case it specifies an encoding not allowed in self::SAFE_XML_ENCODINGS
432        $attemptEncodings = [ 'UTF-16', 'UTF-16BE', 'UTF-32', 'UTF-32BE' ];
433        foreach ( $attemptEncodings as $encoding ) {
434            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
435            $str = @iconv( $encoding, 'UTF-8', $contents );
436            if ( $str != '' && preg_match( "!<\?xml\b(.*?)\?>!si", $str, $matches ) ) {
437                if ( preg_match( $encodingRegex, $matches[1], $encMatch )
438                    && !in_array( strtoupper( $encMatch[1] ), self::SAFE_XML_ENCODINGS )
439                ) {
440                    $this->logger->debug(
441                        'checkXMLEncodingMismatch: Found unsafe XML encoding "{encoding}"',
442                        [ 'encoding' => $encMatch[1] ]
443                    );
444                    return true;
445                }
446            } elseif ( $str != '' && preg_match( "!<\?xml\b!i", $str ) ) {
447                // Start of XML declaration without an end in the first 4096 bytes
448                // bytes. There shouldn't be a legitimate reason for this to happen.
449                $this->logger->debug( 'checkXMLEncodingMismatch: Unmatched XML declaration start' );
450
451                return true;
452            }
453        }
454
455        return false;
456    }
457
458    /**
459     * Looks for bad SVG files
460     *
461     * @warning This function is only safe for the XML serialization of SVGs.
462     * @param string $filename
463     * @param bool $partial
464     * @return bool|array
465     */
466    private function detectScriptInSvg( $filename, $partial ) {
467        $check = new XmlTypeCheck(
468            $filename,
469            $this->checkSvgScriptCallback( ... ),
470            true,
471            [
472                'processing_instruction_handler' => $this->checkSvgPICallback( ... ),
473                'external_dtd_handler' => $this->checkSvgExternalDTD( ... ),
474            ]
475        );
476        if ( $check->wellFormed !== true ) {
477            // Invalid xml (T60553)
478            // But only when non-partial (T67724)
479            return $partial ? false : [ 'uploadinvalidxml' ];
480        }
481
482        if ( $check->filterMatch ) {
483            return $check->filterMatchType;
484        }
485
486        return false;
487    }
488
489    /**
490     * Callback to filter SVG Processing Instructions.
491     *
492     * @param string $target Processing instruction name
493     * @return bool|array
494     */
495    private function checkSvgPICallback( $target ) {
496        // Don't allow external stylesheets (T59550)
497        if ( preg_match( '/xml-stylesheet/i', $target ) ) {
498            return [ 'upload-scripted-pi-callback' ];
499        }
500
501        return false;
502    }
503
504    /**
505     * Verify that DTD URLs referenced are only the standard DTDs.
506     *
507     * Browsers seem to ignore external DTDs.
508     *
509     * However, just to be on the safe side, only allow DTDs from the SVG standard.
510     *
511     * @param string $type PUBLIC or SYSTEM
512     * @param string $publicId The well-known public identifier for the dtd
513     * @param string $systemId The url for the external dtd
514     * @return bool|array
515     */
516    private function checkSvgExternalDTD( $type, $publicId, $systemId ) {
517        // This doesn't include the XHTML+MathML+SVG doctype since we don't
518        // allow XHTML anyway.
519        static $allowedDTDs = [
520            'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd',
521            'http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd',
522            'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-basic.dtd',
523            'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-tiny.dtd',
524            // https://phabricator.wikimedia.org/T168856
525            'http://www.w3.org/TR/2001/PR-SVG-20010719/DTD/svg10.dtd',
526        ];
527        if ( $type !== 'PUBLIC'
528            || !in_array( $systemId, $allowedDTDs )
529            || !str_starts_with( $publicId, "-//W3C//" )
530        ) {
531            return [ 'upload-scripted-dtd' ];
532        }
533        return false;
534    }
535
536    /**
537     * Callback to XML parsing checking individual tags for evilness
538     * @warning This assumes that the SVG is using the XML serialization.
539     *  It is not safe if the SVG is directly embedded in HTML.
540     * @todo Replace this with a allow list filter!
541     * @param string $element
542     * @param array $attribs
543     * @param string|null $data
544     * @return bool|array
545     */
546    private function checkSvgScriptCallback( $element, $attribs, $data = null ) {
547        [ $namespace, $strippedElement ] = self::splitXmlNamespace( $element );
548
549        $logContext = [
550            'element' => $element,
551            'namespace' => $namespace,
552            'localPart' => $strippedElement
553        ];
554
555        // We specifically don't include:
556        // http://www.w3.org/1999/xhtml (T62771)
557        static $validNamespaces = [
558            '',
559            'adobe:ns:meta/',
560            'http://cipa.jp/exif/1.0/',
561            'http://creativecommons.org/ns#',
562            'http://developer.sonyericsson.com/cell/1.0/',
563            'http://inkscape.sourceforge.net/dtd/sodipodi-0.dtd',
564            'http://iptc.org/std/iptc4xmpcore/1.0/xmlns/',
565            'http://iptc.org/std/iptc4xmpext/2008-02-29/',
566            'http://leica-camera.com/digital-shift-assistant/1.0/',
567            'http://ns.acdsee.com/iptc/1.0/',
568            'http://ns.acdsee.com/regions/',
569            'http://ns.adobe.com/adobeillustrator/10.0/',
570            'http://ns.adobe.com/adobesvgviewerextensions/3.0/',
571            'http://ns.adobe.com/album/1.0/',
572            'http://ns.adobe.com/camera-raw-defaults/1.0/',
573            'http://ns.adobe.com/camera-raw-embedded-lens-profile/1.0/',
574            'http://ns.adobe.com/camera-raw-saved-settings/1.0/',
575            'http://ns.adobe.com/camera-raw-settings/1.0/',
576            'http://ns.adobe.com/creatoratom/1.0/',
577            'http://ns.adobe.com/dicom/',
578            'http://ns.adobe.com/exif/1.0/',
579            'http://ns.adobe.com/exif/1.0/aux/',
580            'http://ns.adobe.com/extensibility/1.0/',
581            'http://ns.adobe.com/flows/1.0/',
582            'http://ns.adobe.com/hdr-gain-map/1.0/',
583            'http://ns.adobe.com/hdr-metadata/1.0/',
584            'http://ns.adobe.com/ix/1.0/',
585            'http://ns.adobe.com/lightroom/1.0/',
586            'http://ns.adobe.com/illustrator/1.0/',
587            'http://ns.adobe.com/imagereplacement/1.0/',
588            'http://ns.adobe.com/pdf/1.3/',
589            'http://ns.adobe.com/pdfx/1.3/',
590            'http://ns.adobe.com/photoshop/1.0/',
591            'http://ns.adobe.com/photoshop/1.0/camera-profile',
592            'http://ns.adobe.com/photoshop/1.0/panorama-profile',
593            'http://ns.adobe.com/raw/1.0/',
594            'http://ns.adobe.com/swf/1.0/',
595            'http://ns.adobe.com/saveforweb/1.0/',
596            'http://ns.adobe.com/tiff/1.0/',
597            'http://ns.adobe.com/variables/1.0/',
598            'http://ns.adobe.com/xap/1.0/',
599            'http://ns.adobe.com/xap/1.0/bj/',
600            'http://ns.adobe.com/xap/1.0/g/',
601            'http://ns.adobe.com/xap/1.0/g/img/',
602            'http://ns.adobe.com/xap/1.0/mm/',
603            'http://ns.adobe.com/xap/1.0/plus/',
604            'http://ns.adobe.com/xap/1.0/rights/',
605            'http://ns.adobe.com/xap/1.0/stype/dimensions#',
606            'http://ns.adobe.com/xap/1.0/stype/font#',
607            'http://ns.adobe.com/xap/1.0/stype/manifestitem#',
608            'http://ns.adobe.com/xap/1.0/stype/resourceevent#',
609            'http://ns.adobe.com/xap/1.0/stype/resourceref#',
610            'http://ns.adobe.com/xap/1.0/stype/version#',
611            'http://ns.adobe.com/xap/1.0/t/pg/',
612            'http://ns.adobe.com/xmp/1.0/dynamicmedia/',
613            'http://ns.adobe.com/xmp/identifier/qual/1.0/',
614            'http://ns.adobe.com/xmp/note/',
615            'http://ns.adobe.com/xmp/stype/area#',
616            'http://ns.apple.com/adjustment-settings/1.0/',
617            'http://ns.apple.com/faceinfo/1.0/',
618            'http://ns.apple.com/hdrgainmap/1.0/',
619            'http://ns.apple.com/pixeldatainfo/1.0/',
620            'http://ns.exiftool.org/1.0/',
621            'http://ns.extensis.com/extensis/1.0/',
622            'http://ns.fastpictureviewer.com/fpv/1.0/',
623            'http://ns.google.com/photos/1.0/audio/',
624            'http://ns.google.com/photos/1.0/camera/',
625            'http://ns.google.com/photos/1.0/container/',
626            'http://ns.google.com/photos/1.0/creations/',
627            'http://ns.google.com/photos/1.0/depthmap/',
628            'http://ns.google.com/photos/1.0/focus/',
629            'http://ns.google.com/photos/1.0/image/',
630            'http://ns.google.com/photos/1.0/panorama/',
631            'http://ns.google.com/photos/dd/1.0/profile/',
632            'http://ns.google.com/videos/1.0/spherical/',
633            'http://ns.idimager.com/ics/1.0/',
634            'http://ns.iview-multimedia.com/mediapro/1.0/',
635            'http://ns.leiainc.com/photos/1.0/image/',
636            'http://ns.microsoft.com/expressionmedia/1.0/',
637            'http://ns.microsoft.com/photo/1.0',
638            'http://ns.microsoft.com/photo/1.1',
639            'http://ns.microsoft.com/photo/1.2/',
640            'http://ns.microsoft.com/photo/1.2/t/region#',
641            'http://ns.microsoft.com/photo/1.2/t/regioninfo#',
642            'http://ns.nikon.com/asteroid/1.0/',
643            'http://ns.nikon.com/nine/1.0/',
644            'http://ns.nikon.com/sdc/1.0/',
645            'http://ns.optimasc.com/dex/1.0/',
646            'http://ns.seal/2024/1.0/',
647            'http://ns.useplus.org/ldf/xmp/1.0/',
648            'http://prismstandard.org/namespaces/basic/2.0/',
649            'http://prismstandard.org/namespaces/pmi/2.2/',
650            'http://prismstandard.org/namespaces/prismusagerights/2.1/',
651            'http://prismstandard.org/namespaces/prl/2.1/',
652            'http://prismstandard.org/namespaces/prm/3.0/',
653            'http://purl.org/dc/elements/1.1/',
654            'http://purl.org/dc/elements/1.1',
655            'http://rs.tdwg.org/dwc/index.htm',
656            'http://schemas.microsoft.com/visio/2003/svgextensions/',
657            'http://sodipodi.sourceforge.net/dtd/sodipodi-0.dtd',
658            'http://taptrix.com/inkpad/svg_extensions',
659            'http://www.digikam.org/ns/1.0/',
660            'http://www.dji.com/drone-dji/1.0/',
661            'http://www.metadataworkinggroup.com/schemas/collections/',
662            'http://www.metadataworkinggroup.com/schemas/keywords/',
663            'http://www.metadataworkinggroup.com/schemas/regions/',
664            'http://web.resource.org/cc/',
665            'http://www.freesoftware.fsf.org/bkchem/cdml',
666            'http://www.inkscape.org/namespaces/inkscape',
667            'http://www.opengis.net/gml',
668            'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
669            'http://www.w3.org/2000/01/rdf-schema#',
670            'http://www.w3.org/2000/svg',
671            'http://www.w3.org/2000/02/svg/testsuite/description/', // T278044
672            'http://www.w3.org/tr/rec-rdf-syntax/',
673            'http://xmp.gettyimages.com/gift/1.0/',
674        ];
675
676        // Inkscape mangles namespace definitions created by Adobe Illustrator.
677        // This is nasty but harmless. (T144827)
678        $isBuggyInkscape = preg_match( '/^&(#38;)*ns_[a-z_]+;$/', $namespace );
679
680        if ( !( $isBuggyInkscape || in_array( $namespace, $validNamespaces ) ) ) {
681            $this->logger->debug(
682                'detectScriptInSvg: Non-SVG namespace "{namespace}" in uploaded file.',
683                $logContext
684            );
685            return [ 'uploadscriptednamespace', $namespace ];
686        }
687
688        // check for elements that can contain javascript
689        if ( $strippedElement === 'script' ) {
690            $this->logger->debug(
691                'detectScriptInSvg: Found script element "{element}" in uploaded file.',
692                $logContext
693            );
694            return [ 'uploaded-script-svg', $strippedElement ];
695        }
696
697        // e.g., <svg xmlns="http://www.w3.org/2000/svg">
698        //  <handler xmlns:ev="http://www.w3.org/2001/xml-events" ev:event="load">alert(1)</handler> </svg>
699        if ( $strippedElement === 'handler' ) {
700            $this->logger->debug(
701                'detectScriptInSvg: Found scriptable element "{element}" in uploaded file.',
702                $logContext
703            );
704            return [ 'uploaded-script-svg', $strippedElement ];
705        }
706
707        // SVG reported in Feb '12 that used xml:stylesheet to generate javascript block
708        if ( $strippedElement === 'stylesheet' ) {
709            $this->logger->debug(
710                'detectScriptInSvg: Found scriptable element "{element}" in uploaded file.',
711                $logContext
712            );
713            return [ 'uploaded-script-svg', $strippedElement ];
714        }
715
716        // Block iframes, in case they pass the namespace check
717        if ( $strippedElement === 'iframe' ) {
718            $this->logger->debug( "detectScriptInSvg: iframe in uploaded file.", $logContext );
719            return [ 'uploaded-script-svg', $strippedElement ];
720        }
721
722        // Check <style> css
723        if ( $strippedElement === 'style' ) {
724            $cssCheck = $this->svgCssChecker->checkStyleTag( $data );
725            if ( $cssCheck !== true ) {
726                $this->logger->debug(
727                    "detectScriptInSvg: hostile CSS in style element. {tag}",
728                    [ 'tag' => $cssCheck[0] ]
729                );
730                return [ 'uploaded-hostile-svg', $cssCheck[0], $cssCheck[1], $cssCheck[2] ];
731            }
732        }
733
734        static $cssAttrs = [ 'font', 'clip-path', 'fill', 'filter', 'marker',
735            'marker-end', 'marker-mid', 'marker-start', 'mask', 'stroke', 'cursor' ];
736
737        foreach ( $attribs as $attrib => $value ) {
738            // If attributeNamespace is '', it is relative to its element's namespace
739            [ $attributeNamespace, $stripped ] = self::splitXmlNamespace( $attrib );
740            $value = strtolower( $value );
741            $attribLogContext = [ 'attrib' => $attrib, 'value' => $value ] + $logContext;
742
743            if ( !(
744                    // Inkscape element's have valid attribs that start with on and are safe, fail all others
745                    // We are assuming here that the SVG will be interpreted
746                    // under XML serialization. This is not safe for SVGs
747                    // embedded directly in HTML.
748                    $namespace === 'http://www.inkscape.org/namespaces/inkscape' &&
749                    $attributeNamespace === ''
750                ) && str_starts_with( $stripped, 'on' )
751            ) {
752                $this->logger->debug(
753                    'detectScriptInSvg: Found event-handler attribute ' .
754                        '{attrib}="{value}" in uploaded file.',
755                    $attribLogContext
756                );
757                return [ 'uploaded-event-handler-on-svg', $attrib, $value ];
758            }
759
760            // Do not allow relative links, or unsafe url schemas.
761            // For <a> tags, only data:, http: and https: and same-document
762            // fragment links are allowed.
763            // For all other tags, only 'data:' and fragments (#) are allowed.
764            if (
765                $stripped === 'href'
766                && $value !== ''
767                && !str_starts_with( $value, 'data:' )
768                && !str_starts_with( $value, '#' )
769                && !( $strippedElement === 'a' && preg_match( '!^https?://!i', $value ) )
770            ) {
771                $this->logger->debug(
772                    'detectScriptInSvg: Found href attribute <{localPart} {attrib}="{value}"> ' .
773                        'in uploaded file.',
774                    $attribLogContext
775                );
776                return [ 'uploaded-href-attribute-svg', $strippedElement, $attrib, $value ];
777            }
778
779            // Only allow 'data:\' targets that should be safe.
780            // This prevents vectors like image/svg, text/xml, application/xml, and text/html, which can contain scripts
781            if ( $stripped === 'href' && strncasecmp( 'data:', $value, 5 ) === 0 ) {
782                // RFC2397 parameters.
783                // This is only slightly slower than (;[\w;]+)*.
784                // phpcs:ignore Generic.Files.LineLength
785                $parameters = '(?>;[a-zA-Z0-9\!#$&\'*+.^_`{|}~-]+=(?>[a-zA-Z0-9\!#$&\'*+.^_`{|}~-]+|"(?>[\0-\x0c\x0e-\x21\x23-\x5b\x5d-\x7f]+|\\\\[\0-\x7f])*"))*(?:;base64)?';
786
787                if ( !preg_match( "!^data:\s*image/(gif|jpeg|jpg|a?png|webp|avif)$parameters,!i", $value ) ) {
788                    $this->logger->debug(
789                        'detectScriptInSvg: Found href with data URI with MIME type that is not ' .
790                        'allowed: <{localPart} {attrib}="{value}"> in uploaded file.',
791                        $attribLogContext
792                    );
793                    return [ 'uploaded-href-unsafe-target-svg', $strippedElement, $attrib, $value ];
794                }
795            }
796
797            // Change href with animate from (http://html5sec.org/#137).
798            if ( $stripped === 'attributename'
799                && $strippedElement === 'animate'
800                && $this->stripXmlNamespace( $value ) === 'href'
801            ) {
802                $this->logger->debug(
803                    'detectScriptInSvg: Found animate that might be changing href using "from": ' .
804                    '<{localPart} {attrib}="{value}"> in uploaded file.',
805                    $attribLogContext
806                );
807                return [ 'uploaded-animate-svg', $strippedElement, $attrib, $value ];
808            }
809
810            // Use set/animate to add event-handler attribute to parent.
811            if ( ( $strippedElement === 'set' || $strippedElement === 'animate' )
812                && $stripped === 'attributename'
813                && str_starts_with( $value, 'on' )
814            ) {
815                $this->logger->debug(
816                    'detectScriptInSvg: Found SVG setting event-handler attribute with ' .
817                    '<{localPart} {attrib}="{value}"> in uploaded file.',
818                    $attribLogContext
819                );
820                return [ 'uploaded-setting-event-handler-svg', $strippedElement, $stripped, $value ];
821            }
822
823            // use set to add href attribute to parent element.
824            if ( $strippedElement === 'set'
825                && $stripped === 'attributename'
826                && str_contains( $value, 'href' )
827            ) {
828                $this->logger->debug(
829                    'detectScriptInSvg: Found SVG setting href attribute "{value}" in uploaded file.',
830                    $attribLogContext
831                );
832                return [ 'uploaded-setting-href-svg' ];
833            }
834
835            // use set to add a remote / data / script target to an element.
836            if ( $strippedElement === 'set'
837                && $stripped === 'to'
838                && preg_match( '!(http|https|data|script):!im', $value )
839            ) {
840                $this->logger->debug(
841                    'detectScriptInSvg: Found SVG setting attribute to "{value}" in uploaded file.',
842                    $attribLogContext
843                );
844                return [ 'uploaded-wrong-setting-svg', $value ];
845            }
846
847            // use handler attribute with remote / data / script.
848            if ( $stripped === 'handler' && preg_match( '!(http|https|data|script):!im', $value ) ) {
849                $this->logger->debug(
850                    'detectScriptInSvg: Found SVG setting handler with remote/data/script ' .
851                        '{attrib}="value" in uploaded file.',
852                    $attribLogContext
853                );
854                return [ 'uploaded-setting-handler-svg', $attrib, $value ];
855            }
856
857            // use CSS styles to bring in remote code.
858            if ( $stripped === 'style'
859                && $this->svgCssChecker->checkStyleAttribute( $value ) !== true
860            ) {
861                $this->logger->debug(
862                    'detectScriptInSvg: Found SVG setting a style with remote url ' .
863                        '{attrib}="{value}" in uploaded file.',
864                    $attribLogContext
865                );
866                return [ 'uploaded-remote-url-svg', $attrib, $value ];
867            }
868
869            // Several attributes can include css, css character escaping isn't allowed.
870            if ( in_array( $stripped, $cssAttrs, true )
871                && $this->svgCssChecker->checkPresentationalAttribute( $value ) !== true
872            ) {
873                $this->logger->debug(
874                    'detectScriptInSvg: Found SVG setting a style with ' .
875                        '{attrib}="{value}" in uploaded file.',
876                    $attribLogContext
877                );
878                return [ 'uploaded-remote-url-svg', $attrib, $value ];
879            }
880
881            // image filters can pull in url, which could be svg that executes scripts.
882            // Only allow url( "#foo" ).
883            // Do not allow url( http://example.com )
884            // TODO: It seems like the line above already does this check.
885            if ( $strippedElement === 'image'
886                && $stripped === 'filter'
887                && preg_match( '!url\s*\(\s*["\']?[^#]!im', $value )
888            ) {
889                $this->logger->debug(
890                    'detectScriptInSvg: Found image filter with URL: ' .
891                        '<{localPart} {attrib}="{value}"> in uploaded file.',
892                    $attribLogContext
893                );
894                return [ 'uploaded-image-filter-svg', $strippedElement, $stripped, $value ];
895            }
896        }
897
898        return false; // No scripts detected
899    }
900
901    /**
902     * Divide the element name passed by the XML parser to the callback into URI and prefix.
903     *
904     * @param string $element
905     * @return array Containing the namespace URI and prefix
906     */
907    private function splitXmlNamespace( $element ) {
908        // 'http://www.w3.org/2000/svg:script' -> [ 'http://www.w3.org/2000/svg', 'script' ]
909        $parts = explode( ':', strtolower( $element ) );
910        $name = array_pop( $parts );
911        $ns = implode( ':', $parts );
912
913        return [ $ns, $name ];
914    }
915
916    /**
917     * @param string $element
918     * @return string
919     */
920    private function stripXmlNamespace( $element ) {
921        // 'http://www.w3.org/2000/svg:script' -> 'script'
922        return self::splitXmlNamespace( $element )[1];
923    }
924
925    /**
926     * Generic wrapper function for a virus scanner program.
927     * This relies on the $wgAntivirus and $wgAntivirusSetup variables.
928     * $wgAntivirusRequired may be used to deny upload if the scan fails.
929     *
930     * @note In most cases, external callers would call verifyFile() to run
931     *  all tests, instead of just doing a virus scan.
932     * @param string $file Pathname to the temporary upload file
933     * @return bool|null|string False if not virus is found, null if the scan fails or is disabled,
934     *   or a string containing feedback from the virus scanner if a virus was found.
935     *   If textual feedback is missing but a virus was found, this function returns true.
936     */
937    public function detectVirus( $file ) {
938        $mainConfig = $this->config;
939        $antivirus = $mainConfig->get( MainConfigNames::Antivirus );
940        $antivirusSetup = $mainConfig->get( MainConfigNames::AntivirusSetup );
941        $antivirusRequired = $mainConfig->get( MainConfigNames::AntivirusRequired );
942        if ( !$antivirus ) {
943            $this->logger->debug( 'detectVirus: virus scanner disabled' );
944            return null;
945        }
946
947        if ( !( $antivirusSetup[$antivirus] ?? false ) ) {
948            throw new ConfigException( "Unknown virus scanner: $antivirus" );
949        }
950
951        # look up scanner configuration
952        $command = $antivirusSetup[$antivirus]['command'];
953        $exitCodeMap = $antivirusSetup[$antivirus]['codemap'];
954        $msgPattern = $antivirusSetup[$antivirus]['messagepattern'] ?? null;
955
956        if ( !str_contains( $command, "%f" ) ) {
957            # simple pattern: append file to scan
958            $command .= " " . Shell::escape( $file );
959        } else {
960            # complex pattern: replace "%f" with file to scan
961            $command = str_replace( "%f", Shell::escape( $file ), $command );
962        }
963
964        $this->logger->debug( 'detectVirus: running virus scan: {command}',
965            [ 'command' => $command ] );
966
967        # execute virus scanner
968        $exitCode = false;
969
970        # NOTE: there's a 50-line workaround to make stderr redirection work on windows, too.
971        #  that does not seem to be worth the pain.
972        #  Ask me (Duesentrieb) about it if it's ever needed.
973        $output = Shell::command()->unsafeCommand( $command )->includeStderr()->execute();
974        $exitCode = $output->getExitCode();
975
976        # map exit code to AV_xxx constants.
977        $mappedCode = $exitCode;
978        if ( $exitCodeMap ) {
979            if ( isset( $exitCodeMap[$exitCode] ) ) {
980                $mappedCode = $exitCodeMap[$exitCode];
981            } elseif ( isset( $exitCodeMap["*"] ) ) {
982                $mappedCode = $exitCodeMap["*"];
983            }
984        }
985
986        # NB: AV_NO_VIRUS is 0, but AV_SCAN_FAILED is false,
987        # so we need the strict equalities === and thus can't use a switch here
988        if ( $mappedCode === AV_SCAN_FAILED ) {
989            # scan failed (code was mapped to false by $exitCodeMap)
990            $this->logger->debug( 'detectVirus: failed to scan {file} (code {exitCode}).',
991                [ 'file' => $file, 'exitCode' => $exitCode ] );
992
993            $output = $antivirusRequired
994                ? wfMessage( 'virus-scanfailed', [ $exitCode ] )->text()
995                : null;
996        } elseif ( $mappedCode === AV_SCAN_ABORTED ) {
997            # scan failed because filetype is unknown (probably immune)
998            $this->logger->debug( 'detectVirus: unsupported file type {file} (code {exitCode}).',
999                [ 'file' => $file, 'exitCode' => $exitCode ] );
1000            $output = null;
1001        } elseif ( $mappedCode === AV_NO_VIRUS ) {
1002            # no virus found
1003            $this->logger->debug( 'detectVirus: file passed virus scan.' );
1004            $output = false;
1005        } else {
1006            $output = trim( $output->getStdout() );
1007
1008            if ( !$output ) {
1009                $output = true; # if there's no output, return true
1010            } elseif ( $msgPattern ) {
1011                $groups = [];
1012                if ( preg_match( $msgPattern, $output, $groups ) && $groups[1] ) {
1013                    $output = $groups[1];
1014                }
1015            }
1016
1017            $this->logger->debug( 'detectVirus: FOUND VIRUS! scanner feedback: {output}',
1018                [ 'output' => $output ] );
1019        }
1020
1021        return $output;
1022    }
1023}