CVE-2023-28458: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: pretalx 2.3.1 before 2.3.2 allows path traversal in HTML export (a non-default feature). Organizers can trigger the overwriting (with the standard pretalx 404 page content) of an arbitrary file.
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: 4.3
Published: 2023-04-20T21:15Z
Repository: https://github.com/pretalx/pretalx
Language: Python
Files changed: src/pretalx/agenda/management/commands/export_schedule_html.py
Commit date: 2023-03-07 13:04:03 +0100
Commit message: Fix path traversal in static HTML export

This vulnerability was discovered by Sonar,
https://www.sonarsource.com/, who provided a detailled description of
the vulnerability, and also the fix as it is applied here.

The fix will be backported to the last release, v2.3.1, and released as
v2.3.2. A blog post will be published on pretalx.com.

Diff: diff --git a/src/pretalx/agenda/management/commands/export_schedule_html.py b/src/pretalx/agenda/management/commands/export_schedule_html.py
index 03196058..1910f712 100644
--- a/src/pretalx/agenda/management/commands/export_schedule_html.py
+++ b/src/pretalx/agenda/management/commands/export_schedule_html.py
@@ -113,11 +113,13 @@ def dump_content(destination, path, getter):
     logging.debug(path)
     content = getter(path)
     if path.endswith("/"):
         path = path + "index.html"
 
-    path = Path(destination) / path.lstrip("/")
+    path = (Path(destination) / path.lstrip("/")).resolve()
+    if not Path(destination) in path.parents:
+        raise CommandError("Path traversal detected, aborting.")
     path.parent.mkdir(parents=True, exist_ok=True)
 
     with open(path, "wb") as f:
         f.write(content)
     return content
@@ -129,10 +131,18 @@ def get_mediastatic_content(url):
     elif url.startswith(settings.MEDIA_URL):
         local_path = settings.MEDIA_ROOT / url[len(settings.MEDIA_URL) :]
     else:
         raise FileNotFoundError()
 
+    # Prevent directory traversal, make sure the path is inside the media or static root
+    local_path = local_path.resolve(strict=True)
+    if not any(
+        path in local_path.parents
+        for path in (settings.MEDIA_ROOT, settings.STATIC_ROOT)
+    ):
+        raise FileNotFoundError()
+
     with open(local_path, "rb") as f:
         return f.read()
 
 
 def export_event(event, destination):

Vulnerable code:     path = Path(destination) / path.lstrip("/")
Fixed code:     path = (Path(destination) / path.lstrip("/")).resolve()
    if not Path(destination) in path.parents:
        raise CommandError("Path traversal detected, aborting.")
    # Prevent directory traversal, make sure the path is inside the media or static root
    local_path = local_path.resolve(strict=True)
    if not any(
        path in local_path.parents
        for path in (settings.MEDIA_ROOT, settings.STATIC_ROOT)
    ):
        raise FileNotFoundError()