CVE-2023-40587: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: Pyramid is an open source Python web framework. A path traversal vulnerability in Pyramid versions 2.0.0 and 2.0.1 impacts users of Python 3.11 that are using a Pyramid static view with a full filesystem path and have a `index.html` file that is located exactly one directory above the location of the static view's file system path. No further path traversal exists, and the only file that could be disclosed accidentally is `index.html`. Pyramid version 2.0.2 rejects any path that contains a null-byte out of caution. While valid in directory/file names, we would strongly consider it a mistake to use null-bytes in naming files/directories. Secondly, Python 3.11, and 3.12 has fixed the underlying issue in `os.path.normpath` to no longer truncate on the first `0x00` found, returning the behavior to pre-3.11 Python, un an as of yet unreleased version. Fixes will be available in:Python 3.12.0rc2 and 3.11.5. Some workarounds are available. Use a version of Python 3 that is not affected, downgrade to Python 3.10 series temporarily, or wait until Python 3.11.5 is released and upgrade to the latest version of Python 3.11 series.
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: 5.3
Published: 2023-08-25T21:15Z
Repository: https://github.com/Pylons/pyramid
Language: Python
Files changed: src/pyramid/static.py, tests/fixtures/index.html, tests/test_static.py
Commit date: 2023-08-24 23:42:43 -0600
Commit message: fix: reject NUL character as path element

See: https://github.com/Pylons/pyramid/security/advisories/GHSA-j8g2-6fc7-q8f8

Diff: diff --git a/src/pyramid/static.py b/src/pyramid/static.py
index 5363c1671..e2a5e68d3 100644
--- a/src/pyramid/static.py
+++ b/src/pyramid/static.py
@@ -258,16 +258,16 @@ def _add_vary(response, option):
     if not any(x.lower() == option.lower() for x in vary):
         vary.append(option)
     response.vary = vary
 
 
-_seps = {'/', os.sep}
+_invalid_element_chars = {'/', os.sep, '\x00'}
 
 
-def _contains_slash(item):
-    for sep in _seps:
-        if sep in item:
+def _contains_invalid_element_char(item):
+    for invalid_element_char in _invalid_element_chars:
+        if invalid_element_char in item:
             return True
 
 
 _has_insecure_pathelement = {'..', '.', ''}.intersection
 
@@ -277,11 +277,11 @@ def _secure_path(path_tuple):
     if _has_insecure_pathelement(path_tuple):
         # belt-and-suspenders security; this should never be true
         # unless someone screws up the traversal_path code
         # (request.subpath is computed via traversal_path too)
         return None
-    if any([_contains_slash(item) for item in path_tuple]):
+    if any([_contains_invalid_element_char(item) for item in path_tuple]):
         return None
     encoded = '/'.join(path_tuple)  # will be unicode
     return encoded
 
 
diff --git a/tests/fixtures/index.html b/tests/fixtures/index.html
new file mode 100644
index 000000000..a37df5790
--- /dev/null
+++ b/tests/fixtures/index.html
@@ -0,0 +1 @@
+<h1>DON'T GO HERE</h1>
diff --git a/tests/test_static.py b/tests/test_static.py
index 3fc6586e9..29de0b870 100644
--- a/tests/test_static.py
+++ b/tests/test_static.py
@@ -102,10 +102,23 @@ class Test_static_view_use_subpath_False(unittest.TestCase):
         context = DummyContext()
         from pyramid.httpexceptions import HTTPNotFound
 
         self.assertRaises(HTTPNotFound, inst, context, request)
 
+    def test_oob_nul_char(self):
+        import os
+
+        inst = self._makeOne(f'{os.getcwd()}/tests/fixtures/static')
+        dds = '..\x00/'
+        request = self._makeRequest(
+            {'PATH_INFO': f'/{dds}'}
+        )
+        context = DummyContext()
+        from pyramid.httpexceptions import HTTPNotFound
+
+        self.assertRaises(HTTPNotFound, inst, context, request)
+
     def test_resource_doesnt_exist(self):
         inst = self._makeOne('tests:fixtures/static')
         request = self._makeRequest({'PATH_INFO': '/notthere'})
         context = DummyContext()
         from pyramid.httpexceptions import HTTPNotFound

Vulnerable code: _seps = {'/', os.sep}
def _contains_slash(item):
    for sep in _seps:
        if sep in item:
    if any([_contains_slash(item) for item in path_tuple]):
Fixed code: _invalid_element_chars = {'/', os.sep, '\x00'}
def _contains_invalid_element_char(item):
    for invalid_element_char in _invalid_element_chars:
        if invalid_element_char in item:
    if any([_contains_invalid_element_char(item) for item in path_tuple]):
<h1>DON'T GO HERE</h1>
    def test_oob_nul_char(self):
        import os

        inst = self._makeOne(f'{os.getcwd()}/tests/fixtures/static')
        dds = '..\x00/'
        request = self._makeRequest(
            {'PATH_INFO': f'/{dds}'}
        )
        context = DummyContext()
        from pyramid.httpexceptions import HTTPNotFound

        self.assertRaises(HTTPNotFound, inst, context, request)