diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000..430717f5 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,3 @@ +Release type: patch + +- Prevent output paths from escaping the configured output directory. diff --git a/pelican/tests/test_utils.py b/pelican/tests/test_utils.py index aad07555..12f35658 100644 --- a/pelican/tests/test_utils.py +++ b/pelican/tests/test_utils.py @@ -1008,6 +1008,10 @@ class TestSanitisedJoin(unittest.TestCase): ): # (.*?:)? accounts for Windows root utils.sanitised_join("/foo/bar", "/test") + def test_reject_sibling_prefix(self): + with self.assertRaises(RuntimeError): + utils.sanitised_join("/foo/bar", "../barley/test") + def test_pass_deep_subpaths(self): self.assertEqual( utils.sanitised_join("/foo/bar", "test"), diff --git a/pelican/utils.py b/pelican/utils.py index 90d1a3fd..9a0ab00e 100644 --- a/pelican/utils.py +++ b/pelican/utils.py @@ -49,12 +49,23 @@ logger = logging.getLogger(__name__) def sanitised_join(base_directory: str, *parts: str) -> str: - joined = posixize_path(os.path.abspath(os.path.join(base_directory, *parts))) - base = posixize_path(os.path.abspath(base_directory)) - if not joined.startswith(base): + joined_path = os.path.abspath(os.path.join(base_directory, *parts)) + base_path = os.path.abspath(base_directory) + real_joined_path = os.path.realpath(joined_path) + real_base_path = os.path.realpath(base_path) + try: + is_within_base = ( + os.path.commonpath((real_base_path, real_joined_path)) == real_base_path + ) + except ValueError: + # On Windows, paths on different drives have no common path. + is_within_base = False + + if not is_within_base: + joined = posixize_path(joined_path) raise RuntimeError(f"Attempted to break out of output directory to {joined}") - return joined + return posixize_path(joined_path) def strftime(date: datetime.datetime, date_format: str) -> str: