CVE-2020-15246: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: October is a free, open-source, self-hosted CMS platform based on the Laravel PHP Framework. In October CMS from version 1.0.421 and before version 1.0.469, an attacker can read local files on an October CMS server via a specially crafted request. Issue has been patched in Build 469 (v1.0.469) and v1.1.0.
CWE description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
CVSS v3 base score: 7.5
Published: 2020-11-23T20:15Z
Repository: https://github.com/octobercms/library
Language: PHP
Files changed: src/Exception/SystemException.php, src/Filesystem/Definitions.php, src/Halcyon/Builder.php
Commit date: 2020-09-03 20:54:11 -0600
Commit message: Security fixes for v1.0.469

Temporarily disables SVG uploads
Improves path validation in FileDatasource & reorgs / cleans up Halcyon Builder
Adds XSS filtering to SystemException messages (related https://github.com/octobercms/library/commit/c16fefde03080fe2b3937e565d9cdf7b70291384#diff-4915b631d3234e0c5232490be34effe4)

Diff: diff --git a/src/Exception/SystemException.php b/src/Exception/SystemException.php
index 90ea35a9..c3f1631a 100644
--- a/src/Exception/SystemException.php
+++ b/src/Exception/SystemException.php
@@ -1,12 +1,29 @@
 <?php namespace October\Rain\Exception;
 
+use Exception;
+use October\Rain\Html\HtmlBuilder;
+
 /**
  * This class represents a critical system exception.
  * System exceptions are logged in the error log.
  *
  * @package october\exception
- * @author Alexey Bobkov, Samuel Georges
+ * @author Alexey Bobkov, Samuel Georges, Luke Towers
  */
 class SystemException extends ExceptionBase
 {
+    /**
+     * Override the constructor to escape all messages to protect against potential XSS
+     * from user provided inputs being included in the exception message
+     *
+     * @param string $message Error message.
+     * @param int $code Error code.
+     * @param Exception $previous Previous exception.
+     */
+    public function __construct($message = "", $code = 0, Exception $previous = null)
+    {
+        $message = HtmlBuilder::clean($message);
+
+        parent::__construct($message, $code, $previous);
+    }
 }
diff --git a/src/Filesystem/Definitions.php b/src/Filesystem/Definitions.php
index be5ea41b..931e3552 100644
--- a/src/Filesystem/Definitions.php
+++ b/src/Filesystem/Definitions.php
@@ -101,11 +101,10 @@ class Definitions
             'jpeg',
             'bmp',
             'png',
             'webp',
             'gif',
-            'svg',
             'js',
             'map',
             'ico',
             'css',
             'less',
@@ -161,11 +160,10 @@ class Definitions
             'ico',
             'css',
             'js',
             'woff',
             'woff2',
-            'svg',
             'ttf',
             'eot',
             'json',
             'md',
             'less',
diff --git a/src/Halcyon/Builder.php b/src/Halcyon/Builder.php
index 1f1e7acf..c69f459e 100644
--- a/src/Halcyon/Builder.php
+++ b/src/Halcyon/Builder.php
@@ -140,83 +140,103 @@ class Builder
         $this->datasource = $datasource;
         $this->processor = $processor;
     }
 
     /**
-     * Switches mode to select a single template by its name.
+     * Get the compiled file content representation of the query.
      *
-     * @param  string  $fileName
-     * @return $this
+     * @return string
      */
-    public function whereFileName($fileName)
+    public function toCompiled()
     {
-        $this->selectSingle = $this->model->getFileNameParts($fileName);
-
-        return $this;
+        return $this->processor->processUpdate($this, []);
     }
 
     /**
-     * Set the directory name which the query is targeting.
+     * Get an array with the values of a given column.
      *
-     * @param  string  $dirName
-     * @return $this
+     * @param  string  $column
+     * @param  string  $key
+     * @return array
      */
-    public function from($dirName)
+    public function lists($column, $key = null)
     {
-        $this->from = $dirName;
+        $select = is_null($key) ? [$column] : [$column, $key];
 
-        return $this;
+        if (!is_null($this->cacheMinutes)) {
+            $results = $this->getCached($select);
+        }
+        else {
+            $results = $this->getFresh($select);
+        }
+
+        $collection = new Collection($results);
+
+        return $collection->lists($column, $key);
     }
 
     /**
-     * Set the "offset" value of the query.
+     * Set the "limit" value of the query.
      *
      * @param  int  $value
      * @return $this
      */
-    public function offset($value)
+    public function limit($value)
     {
-        $this->offset = max(0, $value);
+        if ($value >= 0) {
+            $this->limit = $value;
+        }
 
         return $this;
     }
 
     /**
-     * Alias to set the "offset" value of the query.
+     * Alias to set the "limit" value of the query.
      *
      * @param  int  $value
      * @return \October\Rain\Halcyon\Builder|static
      */
-    public function skip($value)
+    public function take($value)
     {
-        return $this->offset($value);
+        return $this->limit($value);
     }
 
     /**
-     * Set the "limit" value of the query.
+     * Set the "offset" value of the query.
      *
      * @param  int  $value
      * @return $this
      */
-    public function limit($value)
+    public function offset($value)
     {
-        if ($value >= 0) {
-            $this->limit = $value;
-        }
+        $this->offset = max(0, $value);
 
         return $this;
     }
 
     /**
-     * Alias to set the "limit" value of the query.
+     * Alias to set the "offset" value of the query.
      *
      * @param  int  $value
      * @return \October\Rain\Halcyon\Builder|static
      */
-    public function take($value)
+    public function skip($value)
     {
-        return $this->limit($value);
+        return $this->offset($value);
+    }
+
+    /**
+     * Set the directory name which the query is targeting.
+     *
+     * @param  string  $dirName
+     * @return $this
+     */
+    public function from($dirName)
+    {
+        $this->from = $dirName;
+
+        return $this;
     }
 
     /**
      * Find a single template by its file name.
      *
@@ -237,50 +257,22 @@ class Builder
     {
         return $this->limit(1)->get()->first();
     }
 
     /**
-     * Execute the query as a "select" statement.
-     *
-     * @param  array  $columns
-     * @return \October\Rain\Halcyon\Collection|static[]
-     */
-    public function get($columns = ['*'])
-    {
-        if (!is_null($this->cacheMinutes)) {
-            $results = $this->getCached($columns);
-        }
-        else {
-            $results = $this->getFresh($columns);
-        }
-
-        $models = $this->getModels($results ?: []);
-
-        return $this->model->newCollection($models);
-    }
-
-    /**
-     * Get an array with the values of a given column.
+     * Switches mode to select a single template by its name.
      *
-     * @param  string  $column
-     * @param  string  $key
-     * @return array
+     * @param  string  $fileName
+     * @return $this
      */
-    public function lists($column, $key = null)
+    public function whereFileName($fileName)
     {
-        $select = is_null($key) ? [$column] : [$column, $key];
-
-        if (!is_null($this->cacheMinutes)) {
-            $results = $this->getCached($select);
-        }
-        else {
-            $results = $this->getFresh($select);
-        }
+        $this->validateFileName($fileName);
 
-        $collection = new Collection($results);
+        $this->selectSingle = $this->model->getFileNameParts($fileName);
 
-        return $collection->lists($column, $key);
+        return $this;
     }
 
     /**
      * Execute the query as a fresh "select" statement.
      *
@@ -319,34 +311,27 @@ class Builder
             'extensions' => $this->extensions
         ]);
     }
 
     /**
-     * Set a model instance for the model being queried.
+     * Execute the query as a "select" statement.
      *
-     * @param  \October\Rain\Halcyon\Model  $model
-     * @return $this
+     * @param  array  $columns
+     * @return \October\Rain\Halcyon\Collection|static[]
      */
-    public function setModel(Model $model)
+    public function get($columns = ['*'])
     {
-        $this->model = $model;
-
-        $this->extensions = $this->model->getAllowedExtensions();
-
-        $this->from($this->model->getObjectTypeDirName());
+        if (!is_null($this->cacheMinutes)) {
+            $results = $this->getCached($columns);
+        }
+        else {
+            $results = $this->getFresh($columns);
+        }
 
-        return $this;
-    }
+        $models = $this->getModels($results ?: []);
 
-    /**
-     * Get the compiled file content representation of the query.
-     *
-     * @return string
-     */
-    public function toCompiled()
-    {
-        return $this->processor->processUpdate($this, []);
+        return $this->model->newCollection($models);
     }
 
     /**
      * Insert a new record into the datasource.
      *
@@ -406,14 +391,13 @@ class Builder
     }
 
     /**
      * Delete a record from the database.
      *
-     * @param  string  $fileName
      * @return int
      */
-    public function delete($fileName = null)
+    public function delete()
     {
         $this->validateFileName();
 
         list($name, $extension) = $this->model->getFileNameParts();
 
@@ -440,10 +424,37 @@ class Builder
             $name,
             $extension
         );
     }
 
+    /**
+     * Set a model instance for the model being queried.
+     *
+     * @param  \October\Rain\Halcyon\Model  $model
+     * @return $this
+     */
+    public function setModel(Model $model)
+    {
+        $this->model = $model;
+
+        $this->extensions = $this->model->getAllowedExtensions();
+
+        $this->from($this->model->getObjectTypeDirName());
+
+        return $this;
+    }
+
+    /**
+     * Get the model instance being queried.
+     *
+     * @return \October\Rain\Halcyon\Model
+     */
+    public function getModel()
+    {
+        return $this->model;
+    }
+
     /**
      * Get the hydrated models.
      *
      * @param  array  $results
      * @return \October\Rain\Halcyon\Model[]
@@ -466,20 +477,10 @@ class Builder
         }
 
         return $models->all();
     }
 
-    /**
-     * Get the model instance being queried.
-     *
-     * @return \October\Rain\Halcyon\Model
-     */
-    public function getModel()
-    {
-        return $this->model;
-    }
-
     //
     // Validation (Hard)
     //
 
     /**
@@ -776,13 +777,12 @@ class Builder
     /**
      * Handle dynamic method calls into the method.
      *
      * @param  string  $method
      * @param  array   $parameters
-     * @return mixed
-     *
      * @throws \BadMethodCallException
+     * @return void
      */
     public function __call($method, $parameters)
     {
         $className = get_class($this);
 

Vulnerable code:  * @author Alexey Bobkov, Samuel Georges
            'svg',
            'svg',
     * Switches mode to select a single template by its name.
     * @param  string  $fileName
     * @return $this
    public function whereFileName($fileName)
        $this->selectSingle = $this->model->getFileNameParts($fileName);

        return $this;
     * Set the directory name which the query is targeting.
     * @param  string  $dirName
     * @return $this
    public function from($dirName)
        $this->from = $dirName;
        return $this;
     * Set the "offset" value of the query.
    public function offset($value)
        $this->offset = max(0, $value);
     * Alias to set the "offset" value of the query.
    public function skip($value)
        return $this->offset($value);
     * Set the "limit" value of the query.
    public function limit($value)
        if ($value >= 0) {
            $this->limit = $value;
        }
     * Alias to set the "limit" value of the query.
    public function take($value)
        return $this->limit($value);
     * Execute the query as a "select" statement.
     *
     * @param  array  $columns
     * @return \October\Rain\Halcyon\Collection|static[]
     */
    public function get($columns = ['*'])
    {
        if (!is_null($this->cacheMinutes)) {
            $results = $this->getCached($columns);
        }
        else {
            $results = $this->getFresh($columns);
        }

        $models = $this->getModels($results ?: []);

        return $this->model->newCollection($models);
    }

    /**
     * Get an array with the values of a given column.
     * @param  string  $column
     * @param  string  $key
     * @return array
    public function lists($column, $key = null)
        $select = is_null($key) ? [$column] : [$column, $key];

        if (!is_null($this->cacheMinutes)) {
            $results = $this->getCached($select);
        }
        else {
            $results = $this->getFresh($select);
        }
        $collection = new Collection($results);
        return $collection->lists($column, $key);
     * Set a model instance for the model being queried.
     * @param  \October\Rain\Halcyon\Model  $model
     * @return $this
    public function setModel(Model $model)
        $this->model = $model;

        $this->extensions = $this->model->getAllowedExtensions();

        $this->from($this->model->getObjectTypeDirName());
        return $this;
    }
    /**
     * Get the compiled file content representation of the query.
     *
     * @return string
     */
    public function toCompiled()
    {
        return $this->processor->processUpdate($this, []);
     * @param  string  $fileName
    public function delete($fileName = null)
    /**
     * Get the model instance being queried.
     *
     * @return \October\Rain\Halcyon\Model
     */
    public function getModel()
    {
        return $this->model;
    }

     * @return mixed
     *
Fixed code: use Exception;
use October\Rain\Html\HtmlBuilder;

 * @author Alexey Bobkov, Samuel Georges, Luke Towers
    /**
     * Override the constructor to escape all messages to protect against potential XSS
     * from user provided inputs being included in the exception message
     *
     * @param string $message Error message.
     * @param int $code Error code.
     * @param Exception $previous Previous exception.
     */
    public function __construct($message = "", $code = 0, Exception $previous = null)
    {
        $message = HtmlBuilder::clean($message);

        parent::__construct($message, $code, $previous);
    }
     * Get the compiled file content representation of the query.
     * @return string
    public function toCompiled()
        return $this->processor->processUpdate($this, []);
     * Get an array with the values of a given column.
     * @param  string  $column
     * @param  string  $key
     * @return array
    public function lists($column, $key = null)
        $select = is_null($key) ? [$column] : [$column, $key];
        if (!is_null($this->cacheMinutes)) {
            $results = $this->getCached($select);
        }
        else {
            $results = $this->getFresh($select);
        }

        $collection = new Collection($results);

        return $collection->lists($column, $key);
     * Set the "limit" value of the query.
    public function limit($value)
        if ($value >= 0) {
            $this->limit = $value;
        }
     * Alias to set the "limit" value of the query.
    public function take($value)
        return $this->limit($value);
     * Set the "offset" value of the query.
    public function offset($value)
        $this->offset = max(0, $value);
     * Alias to set the "offset" value of the query.
    public function skip($value)
        return $this->offset($value);
    }

    /**
     * Set the directory name which the query is targeting.
     *
     * @param  string  $dirName
     * @return $this
     */
    public function from($dirName)
    {
        $this->from = $dirName;

        return $this;
     * Switches mode to select a single template by its name.
     * @param  string  $fileName
     * @return $this
    public function whereFileName($fileName)
        $this->validateFileName($fileName);
        $this->selectSingle = $this->model->getFileNameParts($fileName);
        return $this;
     * Execute the query as a "select" statement.
     * @param  array  $columns
     * @return \October\Rain\Halcyon\Collection|static[]
    public function get($columns = ['*'])
        if (!is_null($this->cacheMinutes)) {
            $results = $this->getCached($columns);
        }
        else {
            $results = $this->getFresh($columns);
        }
        $models = $this->getModels($results ?: []);
        return $this->model->newCollection($models);
    public function delete()
    /**
     * Set a model instance for the model being queried.
     *
     * @param  \October\Rain\Halcyon\Model  $model
     * @return $this
     */
    public function setModel(Model $model)
    {
        $this->model = $model;

        $this->extensions = $this->model->getAllowedExtensions();

        $this->from($this->model->getObjectTypeDirName());

        return $this;
    }

    /**
     * Get the model instance being queried.
     *
     * @return \October\Rain\Halcyon\Model
     */
    public function getModel()
    {
        return $this->model;
    }

     * @return void