CVE-2024-32982
Description
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.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
litestarPyPI | >= 2.8.0, < 2.8.3 | 2.8.3 |
starlitePyPI | >= 1.37.0, < 1.51.16 | 1.51.16 |
litestarPyPI | >= 2.7.0, < 2.7.2 | 2.7.2 |
litestarPyPI | >= 2.0.0, < 2.6.4 | 2.6.4 |
Patches
5743d8ec2680f9c1db97c1f2a8b8b9990c72857e706e7effdMerge pull request from GHSA-83pv-qr33-2vcf
4 files changed · +130 −8
litestar/static_files/base.py+15 −5 modified@@ -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 @@ __all__ = ("StaticFiles",) - if TYPE_CHECKING: from litestar.types import Receive, Scope, Send from litestar.types.composite_types import PathType @@ -45,7 +45,9 @@ def __init__( 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 @@ async def get_fs_info( ) -> 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 @@ async def get_fs_info( 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
tests/e2e/test_routing/test_path_resolution.py+48 −0 modified@@ -360,3 +360,51 @@ async def pathfinder(path: Optional[Path]) -> str: 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
tests/unit/test_static_files/test_file_serving_resolution.py+29 −1 modified@@ -10,7 +10,7 @@ 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
tests/unit/test_static_files/test_static_files_validation.py+38 −2 modified@@ -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.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
a07b79b84d87Merge pull request from GHSA-83pv-qr33-2vcf
4 files changed · +42 −14
docs/release-notes/changelog.rst+6 −0 modified@@ -1,6 +1,12 @@ 1.x Changelog ============= +1.51.15 +------- + +* Fix a security issue ([CVE-2024-32982](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-32982) where static files could allow users the ability to escape the configured static files directory + and read arbitrary files on the filesystem. + 1.51.11 -------
pyproject.toml+14 −7 modified@@ -2,14 +2,21 @@ name = "starlite" version = "1.51.15" description = "Performant, light and flexible ASGI API Framework" -authors = ["Na'aman Hirschfeld <nhirschfeld@gmail.com>"] +authors = [ + "Janek Nouvertné <provinzkraut@posteo.de>", + "Cody Fincher <cody.fincher@gmail.com>", + "Peter Schutt <peter.github@proton.me>", + "Jacob Coffee <jacob@z7x.org>", + "Na'aman Hirschfeld <nhirschfeld@gmail.com>", +] maintainers = [ - "Cody Fincher <cody@litestar.dev>", - "Janek Nouvertné <provinzkraut@litestar.dev", - "Jacob Coffee <jacob@litestar.dev", - "Peter Schutt <peter@litestar.dev>", - "Visakh Unnikrishnan <guacs@litestar.dev>", - "Alc <alc@litestar.dev>" + "Litestar Developers <hello@litestar.dev>", + "Cody Fincher <cody@litestar.dev>", + "Jacob Coffee <jacob@litestar.dev>", + "Janek Nouvertné <provinzkraut@litestar.dev>", + "Peter Schutt <peter@litestar.dev>", + "Visakh Unnikrishnan <guacs@litestar.dev>", + "Alc <alc@litestar.dev>" ] license = "MIT" readme = "README.md"
starlite/static_files/base.py+9 −7 modified@@ -1,4 +1,4 @@ -from os.path import commonpath, join +import os.path from typing import TYPE_CHECKING, Literal, Sequence, Tuple, Union from starlite.enums import ScopeType @@ -35,7 +35,7 @@ def __init__( ``attachment`` or ``inline`` """ self.adapter = FileSystemAdapter(file_system) - self.directories = directories + self.directories = [os.path.normpath(d) for d in directories] self.is_html_mode = is_html_mode self.send_as_attachment = send_as_attachment @@ -54,9 +54,11 @@ async def get_fs_info( """ for directory in directories: try: - joined_path = join(directory, file_path) # noqa: PL118 - file_info = await self.adapter.info(joined_path) - if file_info and commonpath([str(directory), file_info["name"], joined_path]) == str(directory): + joined_path = os.path.join(directory, file_path) + 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 @@ -78,7 +80,7 @@ async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> No split_path = scope["path"].split("/") filename = split_path[-1] - joined_path = join(*split_path) # noqa: PL118 + joined_path = os.path.join(*split_path) # noqa: PL118 resolved_path, fs_info = await self.get_fs_info(directories=self.directories, file_path=joined_path) content_disposition_type: Literal["inline", "attachment"] = ( "attachment" if self.send_as_attachment else "inline" @@ -87,7 +89,7 @@ async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> No if self.is_html_mode and fs_info and fs_info["type"] == "directory": filename = "index.html" resolved_path, fs_info = await self.get_fs_info( - directories=self.directories, file_path=join(resolved_path or joined_path, filename) + directories=self.directories, file_path=os.path.join(resolved_path or joined_path, filename) ) if fs_info and fs_info["type"] == "file":
tests/static_files/test_file_serving_resolution.py+13 −0 modified@@ -1,5 +1,6 @@ import gzip import mimetypes +from pathlib import Path from typing import TYPE_CHECKING import brotli @@ -8,6 +9,7 @@ from starlite import MediaType, get from starlite.config import StaticFilesConfig +from starlite.static_files import StaticFiles from starlite.status_codes import HTTP_200_OK from starlite.testing import create_test_client from starlite.utils.file import BaseLocalFileSystem @@ -194,3 +196,14 @@ def test_static_files_content_disposition(tmpdir: "Path", send_as_attachment: bo response = client.get("/static/static_part/static/test.txt") assert response.status_code == HTTP_200_OK assert response.headers["content-disposition"].startswith(disposition) + + +async def test_staticfiles_get_fs_info_no_access_to_non_static_directory(tmp_path: Path,) -> 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=["static"], file_system=BaseLocalFileSystem()) + path, info = await static_files.get_fs_info([assets], "../index.html") + assert path is None + assert info is None
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-83pv-qr33-2vcfghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-32982ghsaADVISORY
- github.com/litestar-org/litestar/blob/main/litestar/static_files/base.pynvdWEB
- github.com/litestar-org/litestar/commit/57e706e7effdc182fc9a2af5981bc88afb21851bnvdWEB
- github.com/litestar-org/litestar/commit/a07b79b84d8717bec5ac4d4674c1e4920ba9c813ghsaWEB
- github.com/litestar-org/litestar/security/advisories/GHSA-83pv-qr33-2vcfnvdWEB
News mentions
0No linked articles in our index yet.