From f98240ad2279c3e97b65eddce40d37948f383416 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Aug 2024 11:19:28 -0500 Subject: [PATCH] Do not follow symlinks for compressed file variants (#8652) Co-authored-by: Steve Repsher (cherry picked from commit b0536ae6babf160105d4025ea87c02b9fa5629f1) --- CHANGES/8652.bugfix.rst | 1 + aiohttp/web_fileresponse.py | 5 ++++- tests/test_web_sendfile.py | 14 +++++++------- tests/test_web_urldispatcher.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 CHANGES/8652.bugfix.rst diff --git a/CHANGES/8652.bugfix.rst b/CHANGES/8652.bugfix.rst new file mode 100644 index 00000000000..3a1003e50ad --- /dev/null +++ b/CHANGES/8652.bugfix.rst @@ -0,0 +1 @@ +Fixed incorrectly following symlinks for compressed file variants -- by :user:`steverep`. diff --git a/aiohttp/web_fileresponse.py b/aiohttp/web_fileresponse.py index d8bbbe08993..0c23e375d25 100644 --- a/aiohttp/web_fileresponse.py +++ b/aiohttp/web_fileresponse.py @@ -177,7 +177,10 @@ def _get_file_path_stat_encoding( compressed_path = file_path.with_suffix(file_path.suffix + file_extension) with suppress(OSError): - return compressed_path, compressed_path.stat(), file_encoding + # Do not follow symlinks and ignore any non-regular files. + st = compressed_path.lstat() + if S_ISREG(st.st_mode): + return compressed_path, st, file_encoding # Fallback to the uncompressed file return file_path, file_path.stat(), None diff --git a/tests/test_web_sendfile.py b/tests/test_web_sendfile.py index 0ba2861c391..58a46ec602c 100644 --- a/tests/test_web_sendfile.py +++ b/tests/test_web_sendfile.py @@ -18,9 +18,9 @@ def test_using_gzip_if_header_present_and_file_available(loop) -> None: ) gz_filepath = mock.create_autospec(Path, spec_set=True) - gz_filepath.stat.return_value.st_size = 1024 - gz_filepath.stat.return_value.st_mtime_ns = 1603733507222449291 - gz_filepath.stat.return_value.st_mode = MOCK_MODE + gz_filepath.lstat.return_value.st_size = 1024 + gz_filepath.lstat.return_value.st_mtime_ns = 1603733507222449291 + gz_filepath.lstat.return_value.st_mode = MOCK_MODE filepath = mock.create_autospec(Path, spec_set=True) filepath.name = "logo.png" @@ -40,9 +40,9 @@ def test_gzip_if_header_not_present_and_file_available(loop) -> None: request = make_mocked_request("GET", "http://python.org/logo.png", headers={}) gz_filepath = mock.create_autospec(Path, spec_set=True) - gz_filepath.stat.return_value.st_size = 1024 - gz_filepath.stat.return_value.st_mtime_ns = 1603733507222449291 - gz_filepath.stat.return_value.st_mode = MOCK_MODE + gz_filepath.lstat.return_value.st_size = 1024 + gz_filepath.lstat.return_value.st_mtime_ns = 1603733507222449291 + gz_filepath.lstat.return_value.st_mode = MOCK_MODE filepath = mock.create_autospec(Path, spec_set=True) filepath.name = "logo.png" @@ -90,7 +90,7 @@ def test_gzip_if_header_present_and_file_not_available(loop) -> None: ) gz_filepath = mock.create_autospec(Path, spec_set=True) - gz_filepath.stat.side_effect = OSError(2, "No such file or directory") + gz_filepath.lstat.side_effect = OSError(2, "No such file or directory") filepath = mock.create_autospec(Path, spec_set=True) filepath.name = "logo.png" diff --git a/tests/test_web_urldispatcher.py b/tests/test_web_urldispatcher.py index de44ea0648c..3a45b9355f5 100644 --- a/tests/test_web_urldispatcher.py +++ b/tests/test_web_urldispatcher.py @@ -520,6 +520,38 @@ async def test_access_symlink_loop( assert r.status == 404 +async def test_access_compressed_file_as_symlink( + tmp_path: pathlib.Path, aiohttp_client: AiohttpClient +) -> None: + """Test that compressed file variants as symlinks are ignored.""" + private_file = tmp_path / "private.txt" + private_file.write_text("private info") + www_dir = tmp_path / "www" + www_dir.mkdir() + gz_link = www_dir / "file.txt.gz" + gz_link.symlink_to(f"../{private_file.name}") + + app = web.Application() + app.router.add_static("/", www_dir) + client = await aiohttp_client(app) + + # Symlink should be ignored; response reflects missing uncompressed file. + resp = await client.get(f"/{gz_link.stem}", auto_decompress=False) + assert resp.status == 404 + resp.release() + + # Again symlin is ignored, and then uncompressed is served. + txt_file = gz_link.with_suffix("") + txt_file.write_text("public data") + resp = await client.get(f"/{txt_file.name}") + assert resp.status == 200 + assert resp.headers.get("Content-Encoding") is None + assert resp.content_type == "text/plain" + assert await resp.text() == "public data" + resp.release() + await client.close() + + async def test_access_special_resource( tmp_path_factory: pytest.TempPathFactory, aiohttp_client: AiohttpClient ) -> None: