From f2a313723afe88df70525f82155a347d859242b9 Mon Sep 17 00:00:00 2001 From: Ryan Cramer Date: Thu, 24 Sep 2020 13:43:26 -0400 Subject: [PATCH] Update files and images fields with the ability to require FileValidatorModule validation or manually whitelisted extensions, with the first being the svg file extension. --- wire/modules/Fieldtype/FieldtypeFile.module | 116 +++++++++++++++++- .../InputfieldFile/InputfieldFile.module | 113 +++++++++++++++-- .../InputfieldImage/InputfieldImage.module | 6 +- 3 files changed, 219 insertions(+), 16 deletions(-) diff --git a/wire/modules/Fieldtype/FieldtypeFile.module b/wire/modules/Fieldtype/FieldtypeFile.module index b1602081..294d47a6 100644 --- a/wire/modules/Fieldtype/FieldtypeFile.module +++ b/wire/modules/Fieldtype/FieldtypeFile.module @@ -1419,6 +1419,85 @@ class FieldtypeFile extends FieldtypeMulti implements ConfigurableModule { } + /** + * Check file extensions for given field and return array of validity information + * + * @param Field|Inputfield $field + * @param array $validateExtensions Extensions to require validation for, or omit for default. + * @return array Returns associative array with the following: + * - `valid` (array): valid extensions, including those that have been whitelisted or are covered by FileValidator modules. + * - `invalid` (array): extensions that are potentially bad and have not been whitelisted or covered by a FileValidator module. + * - `whitelist` (array): previously invalid extensions that have been manually whitelisted. + * - `validators` (array): Associative array of [ 'ext' => [ 'FileValidatorModule' ] ] showing what’s covered by FileValidator modules. + * @throws WireException + * @since 3.0.167 + * + */ + public function getValidFileExtensions($field, array $validateExtensions = array()) { + + if(!$field instanceof Field && !$field instanceof Inputfield) { + throw new WireException("This method requires a Field or Inputfield object"); + } + + if(empty($validateExtensions)) { + $validateExtensions = array('svg'); + } else { + foreach($validateExtensions as $key => $ext) { + $validateExtensions[$key] = strtolower(trim($ext)); + } + } + + $extensions = array(); + $badExtensions = array(); + $whitelistExtensions = array(); + $extensionsStr = $field->get('extensions'); + $okExtensions = $field->get('okExtensions'); + $validators = array(); + + if(!is_array($okExtensions)) $okExtensions = array(); + + $extensionsStr = trim(str_replace(array("\n", ",", "."), ' ', $extensionsStr)); + + foreach(explode(' ', $extensionsStr) as $ext) { + $ext = strtolower(trim($ext)); + if(!strlen($ext)) continue; + $extensions[$ext] = $ext; + } + + foreach($extensions as $ext) { + // check if extension requires a FileValidator + if(!in_array($ext, $validateExtensions)) continue; + + // if extension was manually whitelisted, then accept it as valid + if(in_array($ext, $okExtensions, true)) { + $whitelistExtensions[$ext] = $ext; + continue; + } + + // if a module validates extension then good + $moduleNames = $this->wire()->sanitizer->validateFile("test.$ext", array( + 'dryrun' => true, + 'getArray' => true + )); + + if(count($moduleNames)) { + $validators[$ext] = $moduleNames; + continue; + } + + // if extension has no validator then remove it from valid list and add to the naughty list + unset($extensions[$ext]); + $badExtensions[$ext] = $ext; + } + + return array( + 'valid' => $extensions, // valid extensions, including those that have been whitelisted + 'invalid' => $badExtensions, // extensions that are potentially bad and have not been whitelisted + 'whitelist' => $whitelistExtensions, // previously invalid extensions that have been whitelisted + 'validators' => $validators, // file validators in use indexed by file extension + ); + } + /** * Field config * @@ -1429,7 +1508,9 @@ class FieldtypeFile extends FieldtypeMulti implements ConfigurableModule { public function ___getConfigInputfields(Field $field) { $inputfields = parent::___getConfigInputfields($field); - + $extensionInfo = $this->getValidFileExtensions($field); + $fileValidatorsUrl = 'https://modules.processwire.com/categories/file-validator/'; + // extensions /** @var InputfieldTextarea $f */ $f = $this->modules->get('InputfieldTextarea'); @@ -1438,8 +1519,41 @@ class FieldtypeFile extends FieldtypeMulti implements ConfigurableModule { $f->attr('rows', 3); $f->label = $this->_('Valid File Extensions'); $f->description = $this->_('Enter all file extensions allowed by this upload field. Separate each extension by a space. No periods or commas. This field is not case sensitive.'); // Valid file extensions description + if(count($extensionInfo['invalid']) && !$this->wire()->input->is('POST')) { + foreach($extensionInfo['invalid'] as $ext) { + $error = sprintf( + $this->_('File extension %s must be removed, whitelisted, or have a [file validator](%s) module installed.'), + strtoupper($ext), + $fileValidatorsUrl + ); + $this->error($error, Notice::allowMarkdown | Notice::noGroup); + } + } $inputfields->append($f); + if(count($extensionInfo['invalid']) || count($extensionInfo['whitelist'])) { + /** @var array $okExtensions */ + $badExtensions = array_merge($extensionInfo['invalid'], $extensionInfo['whitelist']); + ksort($badExtensions); + /** @var InputfieldCheckboxes $f Whitelisted file extensions */ + $f = $this->modules->get('InputfieldCheckboxes'); + $f->attr('name', 'okExtensions'); + $f->label = $this->_('File extensions to allow without validation (whitelist)'); + $f->icon = 'warning'; + foreach($badExtensions as $ext) $f->addOption($ext); + $f->description = + sprintf( + $this->_('These file extensions need a [file validator module](%s) installed. Unchecked extensions have been disabled for safety.'), + $fileValidatorsUrl + ) . ' ' . + $this->_('To ignore and allow the file extension without file validation, check the box next to it (not recommended).') . ' ' . + $this->_('If you don’t need an extension, please remove it from your valid file extensions list.'); + $f->attr('value', $extensionInfo['whitelist']); + $inputfields->add($f); + } else { + $field->set('okExtensions', array()); + } + // max files /** @var InputfieldInteger $f */ $f = $this->modules->get('InputfieldInteger'); diff --git a/wire/modules/Inputfield/InputfieldFile/InputfieldFile.module b/wire/modules/Inputfield/InputfieldFile/InputfieldFile.module index 5e99c2e5..df9d958d 100644 --- a/wire/modules/Inputfield/InputfieldFile/InputfieldFile.module +++ b/wire/modules/Inputfield/InputfieldFile/InputfieldFile.module @@ -4,6 +4,7 @@ * An Inputfield for handling file uploads * * @property string $extensions Allowed file extensions, space separated + * @property array $okExtensions File extensions that are whitelisted if any in $extensions are problematic. (3.0.167+) * @property int $maxFiles Maximum number of files allowed * @property int $maxFilesize Maximum file size * @property bool $useTags Whether or not tags are enabled @@ -118,6 +119,14 @@ class InputfieldFile extends Inputfield implements InputfieldItemList, Inputfiel */ protected $itemFieldgroup = null; + /** + * Cached result from FieldtypeFile::getValidFileExtension() + * + * @var array + * + */ + protected $extensionsInfo = array(); + /** * Initialize the InputfieldFile * @@ -128,6 +137,7 @@ class InputfieldFile extends Inputfield implements InputfieldItemList, Inputfiel // note: these two fields originate from FieldtypeFile. // Initializing them here ensures this Inputfield has the values set automatically. $this->set('extensions', ''); + $this->set('okExtensions', array()); // manually whitelisted problematic extensions $this->set('maxFiles', 0); $this->set('maxFilesize', 0); $this->set('useTags', 0); @@ -588,9 +598,8 @@ class InputfieldFile extends Inputfield implements InputfieldItemList, Inputfiel unset($attrs['value']); if(substr($attrs['name'], -1) != ']') $attrs['name'] .= '[]'; - $extensions = $this->extensions; - if($this->unzip && !$this->maxFiles) $extensions .= ' zip'; - $formatExtensions = $this->formatExtensions($extensions); + $extensions = $this->getAllowedExtensions(); + $formatExtensions = $this->formatExtensions(); $chooseLabel = $this->labels['choose-file']; $dragDropLabel = $this->labels['drag-drop']; $attrStr = $this->getAttributesString($attrs); @@ -635,6 +644,14 @@ class InputfieldFile extends Inputfield implements InputfieldItemList, Inputfiel return $out; } + /** + * Render ready + * + * @param Inputfield|null $parent + * @param bool $renderValueMode + * @return bool + * + */ public function renderReady(Inputfield $parent = null, $renderValueMode = false) { /** @var Config $config */ @@ -691,6 +708,12 @@ class InputfieldFile extends Inputfield implements InputfieldItemList, Inputfiel return parent::renderReady($parent, $renderValueMode); } + /** + * Render Inputfield input + * + * @return string + * + */ public function ___render() { if(!$this->extensions) $this->error($this->_('No file extensions are defined for this field.')); $numItems = wireCount($this->value); @@ -704,7 +727,13 @@ class InputfieldFile extends Inputfield implements InputfieldItemList, Inputfiel } return $this->renderList($this->value) . $this->renderUpload($this->value); } - + + /** + * Render Inputfield value + * + * @return string + * + */ public function ___renderValue() { $this->renderValueMode = true; $out = $this->render(); @@ -712,17 +741,25 @@ class InputfieldFile extends Inputfield implements InputfieldItemList, Inputfiel return $out; } + /** + * File added hook + * + * @param Pagefile $pagefile + * @throws WireException + * + */ protected function ___fileAdded(Pagefile $pagefile) { if($this->noUpload) return; + $sanitizer = $this->wire()->sanitizer; - $isValid = $this->wire('sanitizer')->validateFile($pagefile->filename(), array( + $isValid = $sanitizer->validateFile($pagefile->filename(), array( 'pagefile' => $pagefile )); if($isValid === false) { - $errors = $this->wire('sanitizer')->errors('clear array'); + $errors = $sanitizer->errors('clear array'); throw new WireException( - $this->_('File failed validation') . + "$pagefile->basename - " . $this->_('File failed validation') . (count($errors) ? ": " . implode(', ', $errors) : "") ); } else if($isValid === null) { @@ -744,7 +781,15 @@ class InputfieldFile extends Inputfield implements InputfieldItemList, Inputfiel $pagefile->createdUser = $this->wire('user'); $pagefile->modifiedUser = $this->wire('user'); } - + + /** + * Get markup for added file + * + * @param Pagefile $pagefile + * @param int $n + * @return string + * + */ protected function fileAddedGetMarkup(Pagefile $pagefile, $n) { return $this->renderItemWrap($this->renderItem($pagefile, $this->pagefileId($pagefile), $n)); } @@ -1077,7 +1122,7 @@ class InputfieldFile extends Inputfield implements InputfieldItemList, Inputfiel $ul->setExtractArchives(true); } - $ul->setValidExtensions(explode(' ', trim($this->extensions))); + $ul->setValidExtensions($this->getAllowedExtensions(true)); foreach($ul->execute() as $filename) { $this->processInputAddFile($filename); @@ -1194,12 +1239,56 @@ class InputfieldFile extends Inputfield implements InputfieldItemList, Inputfiel /** * Format list of file extensions for output with upload field * - * @param string $extensions + * @param array|string $extensions * @return string * */ - protected function formatExtensions($extensions) { - return $this->wire('sanitizer')->entities(str_replace(' ', ', ', trim($extensions))); + protected function formatExtensions($extensions = '') { + $sanitizer = $this->wire()->sanitizer; + $badExtensions = array(); + if(empty($extensions)) { + $info = $this->getExtensionsInfo(); + $extensions = $info['valid']; + $badExtensions = $info['invalid']; + } else if(is_string($extensions)) { + while(strpos($extensions, ' ') !== false) $extensions = str_replace(' ', ' ', $extensions); + $extensions = explode(' ', trim($extensions)); + } + $out = $sanitizer->entities(implode(', ', $extensions)); + if(count($badExtensions)) { + if($out) $out .= ', '; + $out .= '' . $sanitizer->entities(implode(', ', $badExtensions)) . ''; + } + return $out; + } + + /** + * Get allowed file extensions + * + * @param bool $getArray + * @return array|string + * @since 3.0.167 + * + */ + protected function getAllowedExtensions($getArray = false) { + $info = $this->getExtensionsInfo(); + $extensions = $info['valid']; + if($this->unzip && !$this->maxFiles) if(!in_array('zip', $extensions)) $extensions[] = 'zip'; + return $getArray ? $extensions : implode(' ', $extensions); + } + + /** + * Get extensions info (see FieldtypeFile::getValidFileExtensions) + * + * @return array + * @since 3.0.167 + * + */ + protected function getExtensionsInfo() { + if(empty($this->extensionsInfo)) { + $this->extensionsInfo = $this->wire()->fieldtypes->FieldtypeFile->getValidFileExtensions($this); + } + return $this->extensionsInfo; } /** diff --git a/wire/modules/Inputfield/InputfieldImage/InputfieldImage.module b/wire/modules/Inputfield/InputfieldImage/InputfieldImage.module index 7ee2ea35..8f2c6c0f 100755 --- a/wire/modules/Inputfield/InputfieldImage/InputfieldImage.module +++ b/wire/modules/Inputfield/InputfieldImage/InputfieldImage.module @@ -9,6 +9,7 @@ * Accessible Properties * * @property string $extensions Space separated list of allowed image extensions (default="JPG JPEG GIF PNG") + * @property array $okExtensions Array of manually whitelisted extensions, for instance [ 'SVG' ] must be manually whitelisted if allowed. (default=[]) * @property int|string $maxWidth Max width for uploaded images, larger will be sized down (default='') * @property int|string $maxHeight Max height for uploaded images, larger will be sized down (default='') * @property float $maxSize Maximum number of megapixels for client-side resize, i.e. 1.7 is ~1600x1000, alt. to maxWidth/maxHeight (default=0). @@ -320,8 +321,7 @@ class InputfieldImage extends InputfieldFile implements InputfieldItemList, Inpu if(substr($attrs['name'], -1) != ']') $attrs['name'] .= '[]'; $attrStr = $this->getAttributesString($attrs); - $extensions = $this->extensions; - if($this->unzip && !$this->maxFiles) $extensions .= ' zip'; + $extensions = $this->getAllowedExtensions(); $formatExtensions = $this->formatExtensions($extensions); $chooseLabel = $this->labels['choose-file']; @@ -388,7 +388,7 @@ class InputfieldImage extends InputfieldFile implements InputfieldItemList, Inpu /** @var Pageimage $pagefile */ - if($pagefile->ext() == 'svg') { + if($pagefile->ext() === 'svg') { parent::___fileAdded($pagefile); return; }