diff --git a/litestar/static_files/base.py b/litestar/static_files/base.py index 9827697933..3099b667e4 100644 --- a/litestar/static_files/base.py +++ b/litestar/static_files/base.py @@ -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 `. + .. versionchanged:: 2.7.2 + + Prevent `CVE-2024-32982 `_ + by ensuring that the resolved path is within the configured directory as part of `advisory + 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 diff --git a/tests/e2e/test_routing/test_path_resolution.py b/tests/e2e/test_routing/test_path_resolution.py index 23d929b115..abea4681c0 100644 --- a/tests/e2e/test_routing/test_path_resolution.py +++ b/tests/e2e/test_routing/test_path_resolution.py @@ -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 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 f204b56d78..435526470c 100644 --- a/tests/unit/test_static_files/test_file_serving_resolution.py +++ b/tests/unit/test_static_files/test_file_serving_resolution.py @@ -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 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 56eeda3c19..e77b69d0ac 100644 --- a/tests/unit/test_static_files/test_static_files_validation.py +++ b/tests/unit/test_static_files/test_static_files_validation.py @@ -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