File CVE-2025-69226-brute-force-leak-static-elements.patch of Package python-aiohttp.42491
From f2a86fd5ac0383000d1715afddfa704413f0711e Mon Sep 17 00:00:00 2001
From: Sam Bull <git@sambull.org>
Date: Sat, 3 Jan 2026 01:55:05 +0000
Subject: [PATCH] Reject static URLs that traverse outside static root (#11888)
(#11906)
(cherry picked from commit 63961fa77fa2443109f25c3d8ab94772d3878626)
Co-authored-by: J. Nick Koston <nick@koston.org>
---
aiohttp/web_urldispatcher.py | 18 +++++++++---------
tests/test_urldispatch.py | 18 +++++++++++++++++-
tests/test_web_sendfile_functional.py | 2 +-
tests/test_web_urldispatcher.py | 4 ++--
4 files changed, 29 insertions(+), 13 deletions(-)
Index: aiohttp-3.9.3/aiohttp/web_urldispatcher.py
===================================================================
--- aiohttp-3.9.3.orig/aiohttp/web_urldispatcher.py
+++ aiohttp-3.9.3/aiohttp/web_urldispatcher.py
@@ -7,6 +7,7 @@ import html
import inspect
import keyword
import os
+import platform
import re
import warnings
from contextlib import contextmanager
@@ -88,6 +89,7 @@ ROUTE_RE: Final[Pattern[str]] = re.compi
)
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]]
@@ -647,7 +649,12 @@ class StaticResource(PrefixResource):
path = request.rel_url.raw_path
method = request.method
allowed_methods = set(self._routes)
- 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()
if method not in allowed_methods:
@@ -663,14 +670,8 @@ class StaticResource(PrefixResource):
return iter(self._routes.values())
async def _handle(self, request: Request) -> StreamResponse:
- rel_url = request.match_info["filename"]
+ filename = request.match_info["filename"]
try:
- 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()
unresolved_path = self._directory.joinpath(filename)
if self._follow_symlinks:
normalized_path = Path(os.path.normpath(unresolved_path))
Index: aiohttp-3.9.3/tests/test_urldispatch.py
===================================================================
--- aiohttp-3.9.3.orig/tests/test_urldispatch.py
+++ aiohttp-3.9.3/tests/test_urldispatch.py
@@ -1,4 +1,5 @@
import pathlib
+import platform
import re
from collections.abc import Container, Iterable, Mapping, MutableMapping, Sized
from urllib.parse import unquote
@@ -966,7 +967,22 @@ async def test_405_for_resource_adapter(
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)
Index: aiohttp-3.9.3/tests/test_web_sendfile_functional.py
===================================================================
--- aiohttp-3.9.3.orig/tests/test_web_sendfile_functional.py
+++ aiohttp-3.9.3/tests/test_web_sendfile_functional.py
@@ -543,7 +543,7 @@ async def test_static_file_directory_tra
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()
Index: aiohttp-3.9.3/tests/test_web_urldispatcher.py
===================================================================
--- aiohttp-3.9.3.orig/tests/test_web_urldispatcher.py
+++ aiohttp-3.9.3/tests/test_web_urldispatcher.py
@@ -497,7 +497,7 @@ async def test_access_special_resource(
# Request the root of the static directory.
r = await client.get("/special")
- assert r.status == 403
+ assert r.status == 404
async def test_partially_applied_handler(aiohttp_client: AiohttpClient) -> None:
@@ -732,8 +732,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(