CVE-2024-32982: Insufficient Information (NVD-CWE-noinfo)
value: 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.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-05-06T15:15Z
Repository: https://github.com/litestar-org/litestar
Language: Python
Files changed: litestar/static_files/base.py, tests/e2e/test_routing/test_path_resolution.py, tests/unit/test_static_files/test_file_serving_resolution.py, tests/unit/test_static_files/test_static_files_validation.py
Commit date: 2024-05-06 08:37:34 +0200
Commit message: 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>
Diff: diff --git a/litestar/static_files/base.py b/litestar/static_files/base.py
index 98276979..7afad1fa 100644
--- a/litestar/static_files/base.py
+++ b/litestar/static_files/base.py
@@ -1,8 +1,9 @@
+# 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
 
 from litestar.enums import ScopeType
 from litestar.exceptions import MethodNotAllowedException, NotFoundException
@@ -10,11 +11,10 @@ from litestar.file_system import FileSystemAdapter
 from litestar.response.file import ASGIFileResponse
 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
     from litestar.types.file_types import FileInfo, FileSystemProtocol
 
@@ -43,20 +43,28 @@ class StaticFiles:
              ``attachment`` or ``inline``
             resolve_symlinks: Resolve symlinks to the directories
             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
 
     async def get_fs_info(
         self, directories: Sequence[PathType], file_path: PathType
     ) -> 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
 
         Returns:
@@ -64,12 +72,14 @@ class StaticFiles:
             :class:`stat_result <os.stat_result>`.
         """
         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
         return None, None
 
diff --git a/tests/e2e/test_routing/test_path_resolution.py b/tests/e2e/test_routing/test_path_resolution.py
index 23d929b1..abea4681 100644
--- a/tests/e2e/test_routing/test_path_resolution.py
+++ b/tests/e2e/test_routing/test_path_resolution.py
@@ -358,5 +358,53 @@ app = Litestar(route_handlers=[pathfinder], debug=True)
 
     run_server(app, server_command)
 
     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
diff --git a/tests/unit/test_static_files/test_file_serving_resolution.py b/tests/unit/test_static_files/test_file_serving_resolution.py
index f204b56d..43552647 100644
--- a/tests/unit/test_static_files/test_file_serving_resolution.py
+++ b/tests/unit/test_static_files/test_file_serving_resolution.py
@@ -8,11 +8,11 @@ from typing import TYPE_CHECKING, Callable
 import brotli
 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
 
 if TYPE_CHECKING:
@@ -293,5 +293,33 @@ def test_resolve_symlinks(tmp_path: Path, resolve: bool) -> None:
         if not resolve:
             linked_dir.unlink()
             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
diff --git a/tests/unit/test_static_files/test_static_files_validation.py b/tests/unit/test_static_files/test_static_files_validation.py
index 56eeda3c..e77b69d0 100644
--- a/tests/unit/test_static_files/test_static_files_validation.py
+++ b/tests/unit/test_static_files/test_static_files_validation.py
@@ -1,17 +1,19 @@
-from typing import TYPE_CHECKING, Any, List
+import asyncio
+from pathlib import Path, PosixPath
+from typing import TYPE_CHECKING, Any, List, cast
 
 import pytest
 
 from litestar import HttpMethod, Litestar, MediaType, get
 from litestar.exceptions import ImproperlyConfiguredException
 from litestar.static_files import StaticFilesConfig, create_static_files_router
 from litestar.status_codes import HTTP_200_OK, HTTP_204_NO_CONTENT, HTTP_405_METHOD_NOT_ALLOWED
 from litestar.testing import create_test_client
 
 if TYPE_CHECKING:
-    from pathlib import Path
+    from litestar.static_files import StaticFiles
 
 
 @pytest.mark.parametrize("directories", [[], [""]])
 @pytest.mark.parametrize("func", [StaticFilesConfig, create_static_files_router])
 def test_config_validation_of_directories(func: Any, directories: List[str]) -> None:
@@ -111,5 +113,39 @@ def test_runtime_validation_of_request_method_create_handler(tmpdir: "Path", met
     path.write_text("content", "utf-8")
 
     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

Vulnerable code: from os.path import commonpath

        self.directories = tuple(Path(p).resolve() if resolve_symlinks else Path(p) for p in directories)
                file_info = await self.adapter.info(joined_path)
                if file_info and commonpath([str(directory), file_info["name"], joined_path]) == str(directory):
from litestar.static_files import StaticFilesConfig, create_static_files_router
from typing import TYPE_CHECKING, Any, List
    from pathlib import Path
Fixed code: # ruff: noqa: PTH118
import os.path
        self.directories = tuple(
            os.path.normpath(Path(p).resolve() if resolve_symlinks else Path(p)) for p in directories
        )
        .. 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>`_.

                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)
                ):


@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
from litestar.static_files import StaticFiles, StaticFilesConfig, create_static_files_router


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
import asyncio
from pathlib import Path, PosixPath
from typing import TYPE_CHECKING, Any, List, cast
    from litestar.static_files import StaticFiles


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