CVE-2024-27306 (Python, published 2024-04-18T15:15Z)
Description: value: aiohttp is an asynchronous HTTP client/server framework for asyncio and Python. A XSS vulnerability exists on index pages for static file handling. This vulnerability is fixed in 3.9.4. We have always recommended using a reverse proxy server (e.g. nginx) for serving static files. Users following the recommendation are unaffected. Other users can disable `show_index` if unable to upgrade.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/aio-libs/aiohttp
Files changed:
CHANGES/8317.bugfix.rst
aiohttp/web_urldispatcher.py
tests/test_web_urldispatcher.py
Commit message: Escape filenames and paths in HTML when generating index pages (#8317) (#8319)

Co-authored-by: J. Nick Koston <nick@koston.org>
(cherry picked from commit ffbc43233209df302863712b511a11bdb6001b0f)

Vulnerable code:
        index_of = f"Index of /{relative_path_to_dir}"
            file_url = self._prefix + "/" + rel_path
                '<li><a href="{url}">{name}</a></li>'.format(
                    url=file_url, name=file_name
                )
    "show_index,status,prefix,data",
        pytest.param(False, 403, "/", None, id="index_forbidden"),
            b"<html>\n<head>\n<title>Index of /.</title>\n"
            b"</head>\n<body>\n<h1>Index of /.</h1>\n<ul>\n"
            b'<li><a href="/my_dir">my_dir/</a></li>\n'
            b'<li><a href="/my_file">my_file</a></li>\n'
            b"</ul>\n</body>\n</html>",
            id="index_root",
            b"<html>\n<head>\n<title>Index of /.</title>\n"
            b"</head>\n<body>\n<h1>Index of /.</h1>\n<ul>\n"
            b'<li><a href="/static/my_dir">my_dir/</a></li>\n'
            b'<li><a href="/static/my_file">my_file</a></li>\n'
            b"</ul>\n</body>\n</html>",
    async with await client.get(prefix) as r:

Fixed code:
Escaped filenames in static view -- by :user:`bdraco`.
import functools
import html
html_escape = functools.partial(html.escape, quote=True)

        index_of = f"Index of /{html_escape(relative_path_to_dir)}"
            quoted_file_url = _quote_path(f"{self._prefix}/{rel_path}")
                f'<li><a href="{quoted_file_url}">{html_escape(file_name)}</a></li>'
import sys
    "show_index,status,prefix,request_path,data",
        pytest.param(False, 403, "/", "/", None, id="index_forbidden"),
            "/",
            b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
            b' /.</h1>\n<ul>\n<li><a href="/my_dir">my_dir/</a></li>\n<li><a href="/my_file">'
            b"my_file</a></li>\n</ul>\n</body>\n</html>",
            "/static",
            b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
            b' /.</h1>\n<ul>\n<li><a href="/static/my_dir">my_dir/</a></li>\n<li><a href="'
            b'/static/my_file">my_file</a></li>\n</ul>\n</body>\n</html>',
        pytest.param(
            True,
            200,
            "/static",
            "/static/my_dir",
            b"<html>\n<head>\n<title>Index of /my_dir</title>\n</head>\n<body>\n<h1>"
            b'Index of /my_dir</h1>\n<ul>\n<li><a href="/static/my_dir/my_file_in_dir">'
            b"my_file_in_dir</a></li>\n</ul>\n</body>\n</html>",
            id="index_subdir",
        ),
    request_path: str,
    async with await client.get(request_path) as r:
        assert r.status == status

        if data:
            assert r.headers["Content-Type"] == "text/html; charset=utf-8"
            read_ = await r.read()
            assert read_ == data


@pytest.mark.internal  # Dependent on filesystem
@pytest.mark.skipif(
    not sys.platform.startswith("linux"),
    reason="Invalid filenames on some filesystems (like Windows)",
)
@pytest.mark.parametrize(
    "show_index,status,prefix,request_path,data",
    [
        pytest.param(False, 403, "/", "/", None, id="index_forbidden"),
        pytest.param(
            True,
            200,
            "/",
            "/",
            b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
            b' /.</h1>\n<ul>\n<li><a href="/%3Cimg%20src=0%20onerror=alert(1)%3E.dir">&l'
            b't;img src=0 onerror=alert(1)&gt;.dir/</a></li>\n<li><a href="/%3Cimg%20sr'
            b'c=0%20onerror=alert(1)%3E.txt">&lt;img src=0 onerror=alert(1)&gt;.txt</a></l'
            b"i>\n</ul>\n</body>\n</html>",
        ),
        pytest.param(
            True,
            200,
            "/static",
            "/static",
            b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
            b' /.</h1>\n<ul>\n<li><a href="/static/%3Cimg%20src=0%20onerror=alert(1)%3E.'
            b'dir">&lt;img src=0 onerror=alert(1)&gt;.dir/</a></li>\n<li><a href="/stat'
            b'ic/%3Cimg%20src=0%20onerror=alert(1)%3E.txt">&lt;img src=0 onerror=alert(1)&'
            b"gt;.txt</a></li>\n</ul>\n</body>\n</html>",
            id="index_static",
        ),
        pytest.param(
            True,
            200,
            "/static",
            "/static/<img src=0 onerror=alert(1)>.dir",
            b"<html>\n<head>\n<title>Index of /&lt;img src=0 onerror=alert(1)&gt;.dir</t"
            b"itle>\n</head>\n<body>\n<h1>Index of /&lt;img src=0 onerror=alert(1)&gt;.di"
            b'r</h1>\n<ul>\n<li><a href="/static/%3Cimg%20src=0%20onerror=alert(1)%3E.di'
            b'r/my_file_in_dir">my_file_in_dir</a></li>\n</ul>\n</body>\n</html>',
            id="index_subdir",
        ),
    ],
)
async def test_access_root_of_static_handler_xss(
    tmp_path: pathlib.Path,
    aiohttp_client: AiohttpClient,
    show_index: bool,
    status: int,
    prefix: str,
    request_path: str,
    data: Optional[bytes],
) -> None:
    # Tests the operation of static file server.
    # Try to access the root of static file server, and make
    # sure that correct HTTP statuses are returned depending if we directory
    # index should be shown or not.
    # Ensure that html in file names is escaped.
    # Ensure that links are url quoted.
    my_file = tmp_path / "<img src=0 onerror=alert(1)>.txt"
    my_dir = tmp_path / "<img src=0 onerror=alert(1)>.dir"
    my_dir.mkdir()
    my_file_in_dir = my_dir / "my_file_in_dir"

    with my_file.open("w") as fw:
        fw.write("hello")

    with my_file_in_dir.open("w") as fw:
        fw.write("world")

    app = web.Application()

    # Register global static route:
    app.router.add_static(prefix, str(tmp_path), show_index=show_index)
    client = await aiohttp_client(app)

    # Request the root of the static directory.
    async with await client.get(request_path) as r: