Repository: pallets/werkzeug

Issue:
Absolute path traversal in SharedDataMiddleware on Windows
SharedDataMiddleware [applies various path sanitisation methods](https://github.com/pallets/werkzeug/blob/b81aa0f18c7336a0421b569d8a5dab8d5c0d7517/src/werkzeug/middleware/shared_data.py#L200-L207) to the incoming paths.

However these "only" handle relative and unix-absolute paths, absolute Windows paths (with drive names) don't get cleaned up properly:
```python
import os
from werkzeug.middleware.shared_data import SharedDataMiddleware
from werkzeug.serving import run_simple

def null(environ, start_response):
    start_response('404 NOT FOUND', [('Content-Type', 'text/plain')])
    yield b'not found'

app = SharedDataMiddleware(null, {
    '/static': os.path.join(os.path.dirname(__file__), 'static'),
})

if __name__ == '__main__':
    run_simple('localhost', 5000, app)
```
run script on a windows machine and access http://localhost:5000/static/c:/windows/win.ini

Expected: 404, as when trying to do unix absolute path traversal (sequences of slashes) or relative path traversal (`..` path segments).

Observed: the machine's win.ini is returned.


Discussion:

Resolving patch:
diff --git a/src/werkzeug/middleware/shared_data.py b/src/werkzeug/middleware/shared_data.py
--- a/src/werkzeug/middleware/shared_data.py
+++ b/src/werkzeug/middleware/shared_data.py
@@ -22,6 +22,7 @@
 from ..filesystem import get_filesystem_encoding
 from ..http import http_date
 from ..http import is_resource_modified
+from ..security import safe_join
 from ..wsgi import get_path_info
 from ..wsgi import wrap_file
 
@@ -149,7 +150,7 @@ def loader(path):
             if path is None:
                 return None, None
 
-            path = posixpath.join(package_path, path)
+            path = safe_join(package_path, path)
 
             if not provider.has_resource(path):
                 return None, None
@@ -170,7 +171,7 @@ def loader(path):
     def get_directory_loader(self, directory):
         def loader(path):
             if path is not None:
-                path = os.path.join(directory, path)
+                path = safe_join(directory, path)
             else:
                 path = directory
 
@@ -192,19 +193,11 @@ def generate_etag(self, mtime, file_size, real_filename):
         )
 
     def __call__(self, environ, start_response):
-        cleaned_path = get_path_info(environ)
+        path = get_path_info(environ)
 
         if PY2:
-            cleaned_path = cleaned_path.encode(get_filesystem_encoding())
+            path = path.encode(get_filesystem_encoding())
 
-        # sanitize the path for non unix systems
-        cleaned_path = cleaned_path.strip("/")
-
-        for sep in os.sep, os.altsep:
-            if sep and sep != "/":
-                cleaned_path = cleaned_path.replace(sep, "/")
-
-        path = "/" + "/".join(x for x in cleaned_path.split("/") if x and x != "..")
         file_loader = None
 
         for search_path, loader in self.exports:
diff --git a/src/werkzeug/security.py b/src/werkzeug/security.py
--- a/src/werkzeug/security.py
+++ b/src/werkzeug/security.py
@@ -222,20 +222,28 @@ def check_password_hash(pwhash, password):
 
 
 def safe_join(directory, *pathnames):
-    """Safely join `directory` and one or more untrusted `pathnames`.  If this
-    cannot be done, this function returns ``None``.
+    """Safely join zero or more untrusted path components to a base
+    directory to avoid escaping the base directory.
 
-    :param directory: the base directory.
-    :param pathnames: the untrusted pathnames relative to that directory.
+    :param directory: The trusted base directory.
+    :param pathnames: The untrusted path components relative to the
+        base directory.
+    :return: A safe path, otherwise ``None``.
     """
     parts = [directory]
+
     for filename in pathnames:
         if filename != "":
             filename = posixpath.normpath(filename)
-        for sep in _os_alt_seps:
-            if sep in filename:
-                return None
-        if os.path.isabs(filename) or filename == ".." or filename.startswith("../"):
+
+        if (
+            any(sep in filename for sep in _os_alt_seps)
+            or os.path.isabs(filename)
+            or filename == ".."
+            or filename.startswith("../")
+        ):
             return None
+
         parts.append(filename)
+
     return posixpath.join(*parts)


Test patch: