File CVE-2025-69226-brute-force-leak-static-elements.patch of Package python-aiohttp.42478

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.6.0/aiohttp/web_urldispatcher.py
===================================================================
--- aiohttp-3.6.0.orig/aiohttp/web_urldispatcher.py
+++ aiohttp-3.6.0/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
@@ -76,6 +77,7 @@ _Resolve = Tuple[Optional[AbstractMatchI
 
 html_escape = functools.partial(html.escape, quote=True)
 
+IS_WINDOWS = platform.system() == "Windows"
 
 class AbstractResource(Sized, Iterable['AbstractRoute']):
 
@@ -586,8 +588,14 @@ class StaticResource(PrefixResource):
         path = request.rel_url.raw_path
         method = request.method
         allowed_methods = set(self._routes)
-        if not path.startswith(self._prefix):
-            return None, set()
+
+        # 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._prefix):
+             return None, set()
 
         if method not in allowed_methods:
             return None, allowed_methods
@@ -604,14 +612,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()
             filepath = self._directory.joinpath(filename).resolve()
             if not self._follow_symlinks:
                 filepath.relative_to(self._directory)
Index: aiohttp-3.6.0/tests/test_urldispatch.py
===================================================================
--- aiohttp-3.6.0.orig/tests/test_urldispatch.py
+++ aiohttp-3.6.0/tests/test_urldispatch.py
@@ -1,5 +1,6 @@
 import os
 import pathlib
+import platform
 import re
 from collections.abc import Container, Iterable, Mapping, MutableMapping, Sized
 from urllib.parse import unquote
@@ -978,7 +979,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.6.0/tests/test_web_sendfile_functional.py
===================================================================
--- aiohttp-3.6.0.orig/tests/test_web_sendfile_functional.py
+++ aiohttp-3.6.0/tests/test_web_sendfile_functional.py
@@ -319,6 +319,33 @@ def test_static_route_path_existence_che
         web.StaticResource("/", nodirectory)
 
 
+async def test_static_file_directory_traversal_attack(aiohttp_client):
+    dirname = pathlib.Path(__file__).parent
+    relpath = "../README.rst"
+    full_path = dirname / relpath
+    assert full_path.is_file()
+
+    app = web.Application()
+    app.router.add_static("/static", dirname)
+    client = await aiohttp_client(app)
+
+    resp = await client.get("/static/" + relpath)
+    assert 404 == resp.status
+    resp.release()
+
+    url_relpath2 = "/static/dir/../" + relpath
+    resp = await client.get(url_relpath2)
+    assert 404 == resp.status
+    resp.release()
+
+    url_abspath = "/static/" + str(full_path.resolve())
+    resp = await client.get(url_abspath)
+    assert resp.status == 404
+    resp.release()
+
+    await client.close()
+
+
 async def test_static_file_huge(aiohttp_client, tmpdir) -> None:
     filename = 'huge_data.unknown_mime_type'
 
Index: aiohttp-3.6.0/tests/test_web_urldispatcher.py
===================================================================
--- aiohttp-3.6.0.orig/tests/test_web_urldispatcher.py
+++ aiohttp-3.6.0/tests/test_web_urldispatcher.py
@@ -374,7 +374,7 @@ async def test_access_special_resource(t
 
         # 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) -> None:
@@ -580,5 +580,5 @@ async def test_static_absolute_url(aioht
     here = pathlib.Path(__file__).parent
     app.router.add_static('/static', here)
     client = await aiohttp_client(app)
-    resp = await client.get('/static/' + str(fname))
-    assert resp.status == 403
+    async with client.get("/static/" + str(fname)) as resp:
+        assert resp.status == 404
openSUSE Build Service is sponsored by