In aiohttp, compressed files as symlinks are not protected from path traversal
Description
aiohttp is an asynchronous HTTP client/server framework for asyncio and Python. In versions on the 3.10 branch prior to version 3.10.2, static routes which contain files with compressed variants (.gz or .br extension) are vulnerable to path traversal outside the root directory if those variants are symbolic links. The server protects static routes from path traversal outside the root directory when follow_symlinks=False (default). It does this by resolving the requested URL to an absolute path and then checking that path relative to the root. However, these checks are not performed when looking for compressed variants in the FileResponse class, and symbolic links are then automatically followed when performing the Path.stat() and Path.open() to send the file. Version 3.10.2 contains a patch for the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
aiohttpPyPI | >= 3.10.0b1, < 3.10.2 | 3.10.2 |
Affected products
1Patches
1ce2e97588145[PR #8652/b0536ae6 backport][3.10] Do not follow symlinks for compressed file variants (#8653)
4 files changed · +44 −8
aiohttp/web_fileresponse.py+4 −1 modified@@ -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
CHANGES/8652.bugfix.rst+1 −0 added@@ -0,0 +1 @@ +Fixed incorrectly following symlinks for compressed file variants -- by :user:`steverep`.
tests/test_web_sendfile.py+7 −7 modified@@ -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"
tests/test_web_urldispatcher.py+32 −0 modified@@ -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:
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
7- github.com/advisories/GHSA-jwhx-xcg6-8xhjghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-42367ghsaADVISORY
- github.com/aio-libs/aiohttp/blob/e0ff5246e1d29b7710ab1a2bbc972b48169f1c05/aiohttp/web_fileresponse.pyghsax_refsource_MISCWEB
- github.com/aio-libs/aiohttp/blob/e0ff5246e1d29b7710ab1a2bbc972b48169f1c05/aiohttp/web_urldispatcher.pyghsax_refsource_MISCWEB
- github.com/aio-libs/aiohttp/commit/ce2e9758814527589b10759a20783fb03b98339fghsax_refsource_MISCWEB
- github.com/aio-libs/aiohttp/pull/8653ghsax_refsource_MISCWEB
- github.com/aio-libs/aiohttp/security/advisories/GHSA-jwhx-xcg6-8xhjghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.