diff --git a/docs/changelog.rst b/docs/changelog.rst index 7f456cc0..e19bdbe4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,6 +3,7 @@ Changelog ========= * Drop Python 3.9 support. +* Fix potential unauthorised file access vulnerability in "autorefesh" mode. See `PR #684 `__ for details, and a reminder that autorefresh mode has always been documented as unsuitable for production use. Thanks Seth Larson for reporting. 6.11.0 (2025-09-18) ------------------- diff --git a/src/whitenoise/base.py b/src/whitenoise/base.py index 07361bf6..b6113be8 100644 --- a/src/whitenoise/base.py +++ b/src/whitenoise/base.py @@ -147,7 +147,7 @@ def candidate_paths_for_url(self, url): for root, prefix in self.directories: if url.startswith(prefix): path = os.path.join(root, url[len(prefix) :]) - if os.path.commonprefix((root, path)) == root: + if self.path_is_child_of(path, root): yield path def find_file_at_path(self, path, url): @@ -184,6 +184,15 @@ def url_is_canonical(url): normalised += "/" return normalised == url + @staticmethod + def path_is_child_of(path, root): + try: + return os.path.commonpath((path, root)) + os.path.sep == root + except ValueError: + # We get a ValueError if `path` and `root` are on different Windows drives: + # https://docs.python.org/3/library/os.path.html#os.path.commonpath + return False + @staticmethod def is_compressed_variant(path, stat_cache=None): if path[-3:] in (".gz", ".br"): diff --git a/tests/test_whitenoise.py b/tests/test_whitenoise.py index 3dbda613..bc243c83 100644 --- a/tests/test_whitenoise.py +++ b/tests/test_whitenoise.py @@ -380,3 +380,22 @@ def test_chunked_file_size_matches_range_with_range_header(): while response.file.read(1): file_size += 1 assert file_size == 14 + + +@pytest.mark.parametrize( + "root,path,expected", + [ + ("/one/two/", "/one/two/three", True), + ("/one/two/", "/one_two/three", False), + # Having different drive letters triggers an exception in `commonpath()` on + # Windows which we should handle gracefully + ("A:/some/path", "B:/another/path", False), + # Relative paths also trigger exceptions (it shouldn't be possible to supply + # these but better to handle all cases) + ("/one/two/", "two/three", False), + ], +) +def test_path_is_child_of(root, path, expected): + root = root.replace("/", os.path.sep) + path = path.replace("/", os.path.sep) + assert WhiteNoise.path_is_child_of(path, root) == expected