CVE-2024-32982 (Python, published 2024-05-06T15:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/litestar-org/litestar
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 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>

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