CVE-2016-1505: DEPRECATED: Pathname Traversal and Equivalence Errors (CWE-21)
value: The filesystem storage backend in Radicale before 1.1 on Windows allows remote attackers to read or write to arbitrary files via a crafted path, as demonstrated by /c:/file/ignore.
CWE description: This category has been deprecated. It was originally used for organizing weaknesses involving file names, which enabled access to files outside of a restricted directory (path traversal) or to perform operations on files that would otherwise be restricted (path equivalence). Consider using either the File Handling Issues category (CWE-1219) or the class Use of Incorrectly-Resolved Name or Reference (CWE-706).
CVSS v3 base score: 10
Published: 2016-02-03T18:59Z
Repository: https://github.com/Unrud/Radicale
Language: Python
Files changed: radicale/pathutils.py, radicale/storage/filesystem.py, radicale/storage/multifilesystem.py
Commit date: 2015-12-24 14:39:29 +0100
Commit message: Convert paths safely to file system paths
With the old implementation on Windows a path like
"/c:/file/ignore" got converted to "c:\file" and
allowed access to files outside of FOLDER

Diff: diff --git a/radicale/pathutils.py b/radicale/pathutils.py
index 5a0e0c8..c3e623b 100644
--- a/radicale/pathutils.py
+++ b/radicale/pathutils.py
@@ -18,12 +18,15 @@
 """
 Helper functions for working with paths
 
 """
 
+import os
 import posixpath
 
+from . import log
+
 
 def sanitize_path(path):
     """Make absolute (with leading slash) to prevent access to other data.
        Preserves an potential trailing slash."""
     trailing_slash = "/" if path.endswith("/") else ""
@@ -32,6 +35,38 @@ def sanitize_path(path):
     for part in path.split("/"):
         if not part or part in (".", ".."):
             continue
         new_path = posixpath.join(new_path, part)
     trailing_slash = "" if new_path.endswith("/") else trailing_slash
-    return new_path + trailing_slash
\ No newline at end of file
+    return new_path + trailing_slash
+
+
+def is_safe_filesystem_path_component(path):
+    """Checks if path is a single component of a local filesystem path
+       and is safe to join"""
+    if not path:
+        return False
+    drive, _ = os.path.splitdrive(path)
+    if drive:
+        return False
+    head, _ = os.path.split(path)
+    if head:
+        return False
+    if path in (os.curdir, os.pardir):
+        return False
+    return True
+
+
+def path_to_filesystem(path, base_folder):
+    """Converts path to a local filesystem path relative to base_folder
+        in a secure manner or raises ValueError."""
+    sane_path = sanitize_path(path).strip("/")
+    safe_path = base_folder
+    if not sane_path:
+        return safe_path
+    for part in sane_path.split("/"):
+        if not is_safe_filesystem_path_component(part):
+            log.LOGGER.debug("Can't translate path safely to filesystem: %s",
+                             path)
+            raise ValueError("Unsafe path")
+        safe_path = os.path.join(safe_path, part)
+    return safe_path
\ No newline at end of file
diff --git a/radicale/storage/filesystem.py b/radicale/storage/filesystem.py
index a45e8eb..b3e06d2 100644
--- a/radicale/storage/filesystem.py
+++ b/radicale/storage/filesystem.py
@@ -26,11 +26,12 @@ import os
 import posixpath
 import json
 import time
 import sys
 from contextlib import contextmanager
-from .. import config, ical
+
+from .. import config, ical, pathutils
 
 
 FOLDER = os.path.expanduser(config.get("storage", "filesystem_folder"))
 FILESYSTEM_ENCODING = sys.getfilesystemencoding()
 
@@ -61,63 +62,65 @@ def open(path, mode="r"):
 
 
 class Collection(ical.Collection):
     """Collection stored in a flat ical file."""
     @property
-    def _path(self):
+    def _filesystem_path(self):
         """Absolute path of the file at local ``path``."""
-        return os.path.join(FOLDER, self.path.replace("/", os.sep))
+        return pathutils.path_to_filesystem(self.path, FOLDER)
 
     @property
     def _props_path(self):
         """Absolute path of the file storing the collection properties."""
-        return self._path + ".props"
+        return self._filesystem_path + ".props"
 
     def _create_dirs(self):
         """Create folder storing the collection if absent."""
-        if not os.path.exists(os.path.dirname(self._path)):
-            os.makedirs(os.path.dirname(self._path))
+        if not os.path.exists(os.path.dirname(self._filesystem_path)):
+            os.makedirs(os.path.dirname(self._filesystem_path))
 
     def save(self, text):
         self._create_dirs()
-        with open(self._path, "w") as fd:
+        with open(self._filesystem_path, "w") as fd:
             fd.write(text)
 
     def delete(self):
-        os.remove(self._path)
+        os.remove(self._filesystem_path)
         os.remove(self._props_path)
 
     @property
     def text(self):
         try:
-            with open(self._path) as fd:
+            with open(self._filesystem_path) as fd:
                 return fd.read()
         except IOError:
             return ""
 
     @classmethod
     def children(cls, path):
-        abs_path = os.path.join(FOLDER, path.replace("/", os.sep))
-        _, directories, files = next(os.walk(abs_path))
+        filesystem_path = pathutils.path_to_filesystem(path, FOLDER)
+        _, directories, files = next(os.walk(filesystem_path))
         for filename in directories + files:
             rel_filename = posixpath.join(path, filename)
             if cls.is_node(rel_filename) or cls.is_leaf(rel_filename):
                 yield cls(rel_filename)
 
     @classmethod
     def is_node(cls, path):
-        abs_path = os.path.join(FOLDER, path.replace("/", os.sep))
-        return os.path.isdir(abs_path)
+        filesystem_path = pathutils.path_to_filesystem(path, FOLDER)
+        return os.path.isdir(filesystem_path)
 
     @classmethod
     def is_leaf(cls, path):
-        abs_path = os.path.join(FOLDER, path.replace("/", os.sep))
-        return os.path.isfile(abs_path) and not abs_path.endswith(".props")
+        filesystem_path = pathutils.path_to_filesystem(path, FOLDER)
+        return (os.path.isfile(filesystem_path) and not
+                filesystem_path.endswith(".props"))
 
     @property
     def last_modified(self):
-        modification_time = time.gmtime(os.path.getmtime(self._path))
+        modification_time = \
+            time.gmtime(os.path.getmtime(self._filesystem_path))
         return time.strftime("%a, %d %b %Y %H:%M:%S +0000", modification_time)
 
     @property
     @contextmanager
     def props(self):
diff --git a/radicale/storage/multifilesystem.py b/radicale/storage/multifilesystem.py
index 30f8c2d..fe5637d 100644
--- a/radicale/storage/multifilesystem.py
+++ b/radicale/storage/multifilesystem.py
@@ -28,17 +28,18 @@ import time
 import sys
 
 from . import filesystem
 from .. import ical
 from .. import log
+from .. import pathutils
 
 
 class Collection(filesystem.Collection):
     """Collection stored in several files per calendar."""
     def _create_dirs(self):
-        if not os.path.exists(self._path):
-            os.makedirs(self._path)
+        if not os.path.exists(self._filesystem_path):
+            os.makedirs(self._filesystem_path)
 
     @property
     def headers(self):
         return (
             ical.Header("PRODID:-//Radicale//NONSGML Radicale Server//EN"),
@@ -50,36 +51,37 @@ class Collection(filesystem.Collection):
             text = ical.serialize(
                 self.tag, self.headers, [component] + self.timezones)
             name = (
                 component.name if sys.version_info[0] >= 3 else
                 component.name.encode(filesystem.FILESYSTEM_ENCODING))
-            path = os.path.join(self._path, name)
-            with filesystem.open(path, "w") as fd:
+            filesystem_path = os.path.join(self._filesystem_path, name)
+            with filesystem.open(filesystem_path, "w") as fd:
                 fd.write(text)
 
     def delete(self):
-        shutil.rmtree(self._path)
+        shutil.rmtree(self._filesystem_path)
         os.remove(self._props_path)
 
     def remove(self, name):
-        if os.path.exists(os.path.join(self._path, name)):
-            os.remove(os.path.join(self._path, name))
+        filesystem_path = os.path.join(self._filesystem_path, name)
+        if os.path.exists(filesystem_path):
+            os.remove(filesystem_path)
 
     @property
     def text(self):
         components = (
             ical.Timezone, ical.Event, ical.Todo, ical.Journal, ical.Card)
         items = set()
         try:
-            filenames = os.listdir(self._path)
+            filenames = os.listdir(self._filesystem_path)
         except (OSError, IOError) as e:
             log.LOGGER.info('Error while reading collection %r: %r'
-                            % (self._path, e))
+                            % (self._filesystem_path, e))
             return ""
 
         for filename in filenames:
-            path = os.path.join(self._path, filename)
+            path = os.path.join(self._filesystem_path, filename)
             try:
                 with filesystem.open(path) as fd:
                     items.update(self._parse(fd.read(), components))
             except (OSError, IOError) as e:
                 log.LOGGER.warning('Error while reading item %r: %r'
@@ -88,19 +90,23 @@ class Collection(filesystem.Collection):
         return ical.serialize(
             self.tag, self.headers, sorted(items, key=lambda x: x.name))
 
     @classmethod
     def is_node(cls, path):
-        path = os.path.join(filesystem.FOLDER, path.replace("/", os.sep))
-        return os.path.isdir(path) and not os.path.exists(path + ".props")
+        filesystem_path = pathutils.path_to_filesystem(path,
+                                                       filesystem.FOLDER)
+        return (os.path.isdir(filesystem_path) and
+                not os.path.exists(filesystem_path + ".props"))
 
     @classmethod
     def is_leaf(cls, path):
-        path = os.path.join(filesystem.FOLDER, path.replace("/", os.sep))
-        return os.path.isdir(path) and os.path.exists(path + ".props")
+        filesystem_path = pathutils.path_to_filesystem(path,
+                                                       filesystem.FOLDER)
+        return (os.path.isdir(filesystem_path) and
+                os.path.exists(path + ".props"))
 
     @property
     def last_modified(self):
         last = max([
-            os.path.getmtime(os.path.join(self._path, filename))
-            for filename in os.listdir(self._path)] or [0])
+            os.path.getmtime(os.path.join(self._filesystem_path, filename))
+            for filename in os.listdir(self._filesystem_path)] or [0])
         return time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime(last))

Vulnerable code:     return new_path + trailing_slash
from .. import config, ical
    def _path(self):
        return os.path.join(FOLDER, self.path.replace("/", os.sep))
        return self._path + ".props"
        if not os.path.exists(os.path.dirname(self._path)):
            os.makedirs(os.path.dirname(self._path))
        with open(self._path, "w") as fd:
        os.remove(self._path)
            with open(self._path) as fd:
        abs_path = os.path.join(FOLDER, path.replace("/", os.sep))
        _, directories, files = next(os.walk(abs_path))
        abs_path = os.path.join(FOLDER, path.replace("/", os.sep))
        return os.path.isdir(abs_path)
        abs_path = os.path.join(FOLDER, path.replace("/", os.sep))
        return os.path.isfile(abs_path) and not abs_path.endswith(".props")
        modification_time = time.gmtime(os.path.getmtime(self._path))
        if not os.path.exists(self._path):
            os.makedirs(self._path)
            path = os.path.join(self._path, name)
            with filesystem.open(path, "w") as fd:
        shutil.rmtree(self._path)
        if os.path.exists(os.path.join(self._path, name)):
            os.remove(os.path.join(self._path, name))
            filenames = os.listdir(self._path)
                            % (self._path, e))
            path = os.path.join(self._path, filename)
        path = os.path.join(filesystem.FOLDER, path.replace("/", os.sep))
        return os.path.isdir(path) and not os.path.exists(path + ".props")
        path = os.path.join(filesystem.FOLDER, path.replace("/", os.sep))
        return os.path.isdir(path) and os.path.exists(path + ".props")
            os.path.getmtime(os.path.join(self._path, filename))
            for filename in os.listdir(self._path)] or [0])
Fixed code: import os
from . import log

    return new_path + trailing_slash


def is_safe_filesystem_path_component(path):
    """Checks if path is a single component of a local filesystem path
       and is safe to join"""
    if not path:
        return False
    drive, _ = os.path.splitdrive(path)
    if drive:
        return False
    head, _ = os.path.split(path)
    if head:
        return False
    if path in (os.curdir, os.pardir):
        return False
    return True


def path_to_filesystem(path, base_folder):
    """Converts path to a local filesystem path relative to base_folder
        in a secure manner or raises ValueError."""
    sane_path = sanitize_path(path).strip("/")
    safe_path = base_folder
    if not sane_path:
        return safe_path
    for part in sane_path.split("/"):
        if not is_safe_filesystem_path_component(part):
            log.LOGGER.debug("Can't translate path safely to filesystem: %s",
                             path)
            raise ValueError("Unsafe path")
        safe_path = os.path.join(safe_path, part)
    return safe_path

from .. import config, ical, pathutils
    def _filesystem_path(self):
        return pathutils.path_to_filesystem(self.path, FOLDER)
        return self._filesystem_path + ".props"
        if not os.path.exists(os.path.dirname(self._filesystem_path)):
            os.makedirs(os.path.dirname(self._filesystem_path))
        with open(self._filesystem_path, "w") as fd:
        os.remove(self._filesystem_path)
            with open(self._filesystem_path) as fd:
        filesystem_path = pathutils.path_to_filesystem(path, FOLDER)
        _, directories, files = next(os.walk(filesystem_path))
        filesystem_path = pathutils.path_to_filesystem(path, FOLDER)
        return os.path.isdir(filesystem_path)
        filesystem_path = pathutils.path_to_filesystem(path, FOLDER)
        return (os.path.isfile(filesystem_path) and not
                filesystem_path.endswith(".props"))
        modification_time = \
            time.gmtime(os.path.getmtime(self._filesystem_path))
from .. import pathutils
        if not os.path.exists(self._filesystem_path):
            os.makedirs(self._filesystem_path)
            filesystem_path = os.path.join(self._filesystem_path, name)
            with filesystem.open(filesystem_path, "w") as fd:
        shutil.rmtree(self._filesystem_path)
        filesystem_path = os.path.join(self._filesystem_path, name)
        if os.path.exists(filesystem_path):
            os.remove(filesystem_path)
            filenames = os.listdir(self._filesystem_path)
                            % (self._filesystem_path, e))
            path = os.path.join(self._filesystem_path, filename)
        filesystem_path = pathutils.path_to_filesystem(path,
                                                       filesystem.FOLDER)
        return (os.path.isdir(filesystem_path) and
                not os.path.exists(filesystem_path + ".props"))
        filesystem_path = pathutils.path_to_filesystem(path,
                                                       filesystem.FOLDER)
        return (os.path.isdir(filesystem_path) and
                os.path.exists(path + ".props"))
            os.path.getmtime(os.path.join(self._filesystem_path, filename))
            for filename in os.listdir(self._filesystem_path)] or [0])