AIOHTTP allows for a brute-force leak of internal static filepath components
Description
AIOHTTP is an asynchronous HTTP client/server framework for asyncio and Python. Versions 3.13.2 and below enable an attacker to ascertain the existence of absolute path components through the path normalization logic for static files meant to prevent path traversal. If an application uses web.static() (not recommended for production deployments), it may be possible for an attacker to ascertain the existence of path components. This issue is fixed in version 3.13.3.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
aiohttpPyPI | < 3.13.3 | 3.13.3 |
Affected products
1Patches
1f2a86fd5ac03Reject static URLs that traverse outside static root (#11888) (#11906)
4 files changed · +29 −13
aiohttp/web_urldispatcher.py+9 −9 modified@@ -7,6 +7,7 @@ import inspect import keyword import os +import platform import re import sys import warnings @@ -94,6 +95,7 @@ ) PATH_SEP: Final[str] = re.escape("/") +IS_WINDOWS: Final[bool] = platform.system() == "Windows" _ExpectHandler = Callable[[Request], Awaitable[Optional[StreamResponse]]] _Resolve = Tuple[Optional["UrlMappingMatchInfo"], Set[str]] @@ -651,7 +653,12 @@ def set_options_route(self, handler: Handler) -> None: async def resolve(self, request: Request) -> _Resolve: path = request.rel_url.path_safe method = request.method - if not path.startswith(self._prefix2) and path != self._prefix: + # We normalise here to avoid matches that traverse below the static root. + # e.g. /static/../../../../home/user/webapp/static/ + norm_path = os.path.normpath(path) + if IS_WINDOWS: + norm_path = norm_path.replace("\\", "/") + if not norm_path.startswith(self._prefix2) and norm_path != self._prefix: return None, set() allowed_methods = self._allowed_methods @@ -668,14 +675,7 @@ def __iter__(self) -> Iterator[AbstractRoute]: return iter(self._routes.values()) async def _handle(self, request: Request) -> StreamResponse: - rel_url = request.match_info["filename"] - filename = Path(rel_url) - if filename.anchor: - # rel_url is an absolute name like - # /static/\\machine_name\c$ or /static/D:\path - # where the static dir is totally different - raise HTTPForbidden() - + filename = request.match_info["filename"] unresolved_path = self._directory.joinpath(filename) loop = asyncio.get_running_loop() return await loop.run_in_executor(
tests/test_urldispatch.py+17 −1 modified@@ -1,4 +1,5 @@ import pathlib +import platform import re from collections.abc import Container, Iterable, Mapping, MutableMapping, Sized from typing import NoReturn @@ -1041,7 +1042,22 @@ async def test_405_for_resource_adapter(router) -> None: assert (None, {"HEAD", "GET"}) == ret -async def test_check_allowed_method_for_found_resource(router) -> None: +@pytest.mark.skipif(platform.system() == "Windows", reason="Different path formats") +async def test_static_resource_outside_traversal(router: web.UrlDispatcher) -> None: + """Test relative path traversing outside root does not resolve.""" + static_file = pathlib.Path(aiohttp.__file__) + request_path = "/st" + "/.." * (len(static_file.parts) - 2) + str(static_file) + assert pathlib.Path(request_path).resolve() == static_file + + resource = router.add_static("/st", static_file.parent) + ret = await resource.resolve(make_mocked_request("GET", request_path)) + # Should not resolve, otherwise filesystem information may be leaked. + assert (None, set()) == ret + + +async def test_check_allowed_method_for_found_resource( + router: web.UrlDispatcher, +) -> None: handler = make_handler() resource = router.add_resource("/") resource.add_route("GET", handler)
tests/test_web_sendfile_functional.py+1 −1 modified@@ -638,7 +638,7 @@ async def test_static_file_directory_traversal_attack(aiohttp_client) -> None: url_abspath = "/static/" + str(full_path.resolve()) resp = await client.get(url_abspath) - assert 403 == resp.status + assert resp.status == 404 await resp.release() await client.close()
tests/test_web_urldispatcher.py+2 −2 modified@@ -858,8 +858,8 @@ async def test_static_absolute_url( here = pathlib.Path(__file__).parent app.router.add_static("/static", here) client = await aiohttp_client(app) - resp = await client.get("/static/" + str(file_path.resolve())) - assert resp.status == 403 + async with client.get("/static/" + str(file_path.resolve())) as resp: + assert resp.status == 404 async def test_for_issue_5250(
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
4- github.com/advisories/GHSA-54jq-c3m8-4m76ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-69226ghsaADVISORY
- github.com/aio-libs/aiohttp/commit/f2a86fd5ac0383000d1715afddfa704413f0711eghsax_refsource_MISCWEB
- github.com/aio-libs/aiohttp/security/advisories/GHSA-54jq-c3m8-4m76ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.