CVE-2024-32982 (published 2024-05-06T15:15Z)

Litestar and Starlite is an Asynchronous Server Gateway Interface (ASGI) framework. Prior to 2.8.3, 2.7.2, and 2.6.4, a Local File Inclusion (LFI) vulnerability has been discovered in the static file serving component of LiteStar. This vulnerability allows attackers to exploit path traversal flaws, enabling unauthorized access to sensitive files outside the designated directories. Such access can lead to the disclosure of sensitive information or potentially compromise the server. The vulnerability is located in the file path handling mechanism within the static content serving function, specifically at `litestar/static_files/base.py`. This vulnerability is fixed in versions 2.8.3, 2.7.2, and 2.6.4.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/litestar-org/litestar
commit_details: repo_url: https://github.com/litestar-org/litestar
author: Jacob Coffee
author_date: 2024-05-06 01:37:34-05:00
msg: Merge pull request from GHSA-83pv-qr33-2vcf

Fix for vulnerability introduced in #739, caused by failure to normalize the path part extracted from URL before serving data from a static directory.

See https://github.com/litestar-org/litestar/security/advisories/GHSA-83pv-qr33-2vcf for specific details.

Co-authored-by: Brian Edgar Ré <brian@192.168.1.4>
Co-authored-by: Peter Schutt <peter.github@proton.me>
repository: repo_url: https://github.com/litestar-org/litestar
repo_name: litestar-org/litestar
description: Production-ready, Light, Flexible and Extensible ASGI API framework | Effortlessly Build Performant APIs
repo_language: Python
owner: litestar-org
file_changes: filename: base.py
change_type: ModificationType.MODIFY
diff: @@ -1,6 +1,7 @@
+# ruff: noqa: PTH118
 from __future__ import annotations
 
-from os.path import commonpath
+import os.path
 from pathlib import Path
 from typing import TYPE_CHECKING, Literal, Sequence
 
@@ -12,7 +13,6 @@ from litestar.status_codes import HTTP_404_NOT_FOUND
 
 __all__ = ("StaticFiles",)
 
-
 if TYPE_CHECKING:
     from litestar.types import Receive, Scope, Send
     from litestar.types.composite_types import PathType
@@ -45,7 +45,9 @@ class StaticFiles:
             headers: Headers that will be sent with every response.
         """
         self.adapter = FileSystemAdapter(file_system)
-        self.directories = tuple(Path(p).resolve() if resolve_symlinks else Path(p) for p in directories)
+        self.directories = tuple(
+            os.path.normpath(Path(p).resolve() if resolve_symlinks else Path(p)) for p in directories
+        )
         self.is_html_mode = is_html_mode
         self.send_as_attachment = send_as_attachment
         self.headers = headers
@@ -55,6 +57,12 @@ class StaticFiles:
     ) -> tuple[Path, FileInfo] | tuple[None, None]:
         """Return the resolved path and a :class:`stat_result <os.stat_result>`.
 
+        .. versionchanged:: 2.8.3
+
+            Prevent `CVE-2024-32982 <https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-32982>`_
+            by ensuring that the resolved path is within the configured directory as part of `advisory
+            GHSA-83pv-qr33-2vcf <https://github.com/advisories/GHSA-83pv-qr33-2vcf>`_.
+
         Args:
             directories: A list of directory paths.
             file_path: A file path to resolve
@@ -66,8 +74,10 @@ class StaticFiles:
         for directory in directories:
             try:
                 joined_path = Path(directory, file_path)
-                file_info = await self.adapter.info(joined_path)
-                if file_info and commonpath([str(directory), file_info["name"], joined_path]) == str(directory):
+                normalized_file_path = os.path.normpath(joined_path)
+                if os.path.commonpath([directory, normalized_file_path]) == str(directory) and (
+                    file_info := await self.adapter.info(joined_path)
+                ):
                     return joined_path, file_info
             except FileNotFoundError:
                 continue

programming_language: Python
filename: test_path_resolution.py
change_type: ModificationType.MODIFY
diff: @@ -360,3 +360,51 @@ app = Litestar(route_handlers=[pathfinder], debug=True)
 
     assert httpx.get("http://127.0.0.1:9999/").text == "None"
     assert httpx.get("http://127.0.0.1:9999/something").text == "/something"
+
+
+@pytest.mark.parametrize(
+    "server_command",
+    [
+        pytest.param(["uvicorn", "app:app", "--port", "9999", "--root-path", "/test"], id="uvicorn"),
+        pytest.param(["hypercorn", "app:app", "--bind", "127.0.0.1:9999", "--root-path", "/test"], id="hypercorn"),
+        pytest.param(["daphne", "app:app", "--port", "9999", "--root-path", "/test"], id="daphne"),
+    ],
+)
+@pytest.mark.xdist_group("live_server_test")
+@pytest.mark.server_integration
+def test_no_path_traversal_from_static_directory(
+    tmp_path: Path, monkeypatch: MonkeyPatch, server_command: List[str], run_server: Callable[[str, List[str]], None]
+) -> None:
+    import http.client
+
+    static = tmp_path / "static"
+    static.mkdir()
+    (static / "index.html").write_text("Hello, World!")
+
+    app = """
+from pathlib import Path
+from litestar import Litestar
+from litestar.static_files import create_static_files_router
+import uvicorn
+
+app = Litestar(
+    route_handlers=[
+        create_static_files_router(path="/static", directories=["static"]),
+    ],
+)
+    """
+
+    def send_request(host: str, port: int, path: str) -> http.client.HTTPResponse:
+        connection = http.client.HTTPConnection(host, port)
+        connection.request("GET", path)
+        resp = connection.getresponse()
+        connection.close()
+        return resp
+
+    run_server(app, server_command)
+
+    response = send_request("127.0.0.1", 9999, "/static/index.html")
+    assert response.status == 200
+
+    response = send_request("127.0.0.1", 9999, "/static/../app.py")
+    assert response.status == 404

programming_language: Python
filename: test_file_serving_resolution.py
change_type: ModificationType.MODIFY
diff: @@ -10,7 +10,7 @@ import pytest
 from typing_extensions import TypeAlias
 
 from litestar import MediaType, Router, get
-from litestar.static_files import StaticFilesConfig, create_static_files_router
+from litestar.static_files import StaticFiles, StaticFilesConfig, create_static_files_router
 from litestar.status_codes import HTTP_200_OK
 from litestar.testing import create_test_client
 from tests.unit.test_static_files.conftest import MakeConfig
@@ -295,3 +295,31 @@ def test_resolve_symlinks(tmp_path: Path, resolve: bool) -> None:
             assert client.get("/test.txt").status_code == 404
         else:
             assert client.get("/test.txt").status_code == 200
+
+
+async def test_staticfiles_get_fs_info_no_access_to_non_static_directory(
+    tmp_path: Path,
+    file_system: FileSystemProtocol,
+) -> None:
+    assets = tmp_path / "assets"
+    assets.mkdir()
+    index = tmp_path / "index.html"
+    index.write_text("content", "utf-8")
+    static_files = StaticFiles(is_html_mode=False, directories=[assets], file_system=file_system)
+    path, info = await static_files.get_fs_info([assets], "../index.html")
+    assert path is None
+    assert info is None
+
+
+async def test_staticfiles_get_fs_info_no_access_to_non_static_file_with_prefix(
+    tmp_path: Path,
+    file_system: FileSystemProtocol,
+) -> None:
+    static = tmp_path / "static"
+    static.mkdir()
+    private_file = tmp_path / "staticsecrets.env"
+    private_file.write_text("content", "utf-8")
+    static_files = StaticFiles(is_html_mode=False, directories=[static], file_system=file_system)
+    path, info = await static_files.get_fs_info([static], "../staticsecrets.env")
+    assert path is None
+    assert info is None

programming_language: Python
filename: test_static_files_validation.py
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,6 @@
-from typing import TYPE_CHECKING, Any, List
+import asyncio
+from pathlib import Path, PosixPath
+from typing import TYPE_CHECKING, Any, List, cast
 
 import pytest
 
@@ -9,7 +11,7 @@ from litestar.status_codes import HTTP_200_OK, HTTP_204_NO_CONTENT, HTTP_405_MET
 from litestar.testing import create_test_client
 
 if TYPE_CHECKING:
-    from pathlib import Path
+    from litestar.static_files import StaticFiles
 
 
 @pytest.mark.parametrize("directories", [[], [""]])
@@ -113,3 +115,37 @@ def test_runtime_validation_of_request_method_create_handler(tmpdir: "Path", met
     with create_test_client(create_static_files_router(path="/static", directories=[tmpdir])) as client:
         response = client.request(method, "/static/test.txt")
         assert response.status_code == expected
+
+
+def test_config_validation_of_path_prevents_directory_traversal(tmpdir: "Path") -> None:
+    # Setup: Create a 'secret.txt' outside the static directory to simulate sensitive file
+    secret_path = Path(tmpdir) / "../secret.txt"
+    secret_path.write_text("This is a secret file.", encoding="utf-8")
+
+    # Setup: Create 'test.txt' inside the static directory
+    test_file_path = Path(tmpdir) / "test.txt"
+    test_file_path.write_text("This is a test file.", encoding="utf-8")
+
+    # Get StaticFiles handler
+    config = StaticFilesConfig(path="/static", directories=[tmpdir])
+    asgi_router = config.to_static_files_app()
+    static_files_handler = cast("StaticFiles", asgi_router.fn)
+
+    # Resolve file path with the StaticFiles handler
+    string_path = Path("../secret.txt").as_posix()
+
+    coroutine = static_files_handler.get_fs_info(directories=static_files_handler.directories, file_path=string_path)
+    resolved_path, fs_info = asyncio.run(coroutine)
+
+    assert resolved_path is None  # Because the resolved path is outside the static directory
+    assert fs_info is None  # Because the file doesn't exist, so there is no info
+
+    # Resolve file path with the StaticFiles handler
+    string_path = Path("test.txt").as_posix()
+
+    coroutine = static_files_handler.get_fs_info(directories=static_files_handler.directories, file_path=string_path)
+    resolved_path, fs_info = asyncio.run(coroutine)
+
+    expected_resolved_path = PosixPath(str(tmpdir / "test.txt"))
+    assert resolved_path == expected_resolved_path  # Because the resolved path is inside the static directory
+    assert fs_info is not None  # Because the file exists, so there is info

programming_language: Python