From f98240ad2279c3e97b65eddce40d37948f383416 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston" <nick@koston.org>
Date: Thu, 8 Aug 2024 11:19:28 -0500
Subject: [PATCH] Do not follow symlinks for compressed file variants (#8652)

Co-authored-by: Steve Repsher <steverep@users.noreply.github.com>
(cherry picked from commit b0536ae6babf160105d4025ea87c02b9fa5629f1)
---
 CHANGES/8652.bugfix.rst         |  1 +
 aiohttp/web_fileresponse.py     |  9 ++++++--
 tests/test_web_sendfile.py      | 37 ++++++++++++++++++++-------------
 tests/test_web_urldispatcher.py | 32 ++++++++++++++++++++++++++++
 4 files changed, 63 insertions(+), 16 deletions(-)
 create mode 100644 CHANGES/8652.bugfix.rst

diff --git a/CHANGES/8652.bugfix.rst b/CHANGES/8652.bugfix.rst
new file mode 100644
index 0000000..3a1003e
--- /dev/null
+++ b/CHANGES/8652.bugfix.rst
@@ -0,0 +1 @@
+Fixed incorrectly following symlinks for compressed file variants -- by :user:`steverep`.
diff --git a/aiohttp/web_fileresponse.py b/aiohttp/web_fileresponse.py
index 6496ffa..acb0579 100644
--- a/aiohttp/web_fileresponse.py
+++ b/aiohttp/web_fileresponse.py
@@ -2,6 +2,7 @@ import asyncio
 import mimetypes
 import os
 import pathlib
+from stat import S_ISREG
 from typing import (  # noqa
     IO,
     TYPE_CHECKING,
@@ -136,12 +137,16 @@ class FileResponse(StreamResponse):
         if check_for_gzipped_file:
             gzip_path = filepath.with_name(filepath.name + ".gz")
             try:
-                return gzip_path, gzip_path.stat(), True
+                st = gzip_path.lstat()
+                if S_ISREG(st.st_mode):
+                    return gzip_path, st, True
             except OSError:
                 # Fall through and try the non-gzipped file
                 pass
 
-        return filepath, filepath.stat(), False
+        st = filepath.lstat()
+        if S_ISREG(st.st_mode):
+            return filepath, st, False
 
     async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]:
         loop = asyncio.get_event_loop()
diff --git a/tests/test_web_sendfile.py b/tests/test_web_sendfile.py
index 2817e08..da9e6ae 100644
--- a/tests/test_web_sendfile.py
+++ b/tests/test_web_sendfile.py
@@ -1,10 +1,13 @@
 from pathlib import Path
+from stat import S_IFREG, S_IRUSR, S_IWUSR
 from unittest import mock
 
 from aiohttp import hdrs
 from aiohttp.test_utils import make_mocked_coro, make_mocked_request
 from aiohttp.web_fileresponse import FileResponse
 
+MOCK_MODE = S_IFREG | S_IRUSR | S_IWUSR
+
 
 def test_using_gzip_if_header_present_and_file_available(loop) -> None:
     request = make_mocked_request(
@@ -12,8 +15,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.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"
@@ -33,14 +37,16 @@ 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.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"
     filepath.with_name.return_value = gz_filepath
-    filepath.stat.return_value.st_size = 1024
-    filepath.stat.return_value.st_mtime_ns = 1603733507222449291
+    filepath.lstat.return_value.st_size = 1024
+    filepath.lstat.return_value.st_mtime_ns = 1603733507222449291
+    filepath.lstat.return_value.st_mode = MOCK_MODE
 
     file_sender = FileResponse(filepath)
     file_sender._path = filepath
@@ -56,13 +62,14 @@ def test_gzip_if_header_not_present_and_file_not_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.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"
     filepath.with_name.return_value = gz_filepath
-    filepath.stat.return_value.st_size = 1024
-    filepath.stat.return_value.st_mtime_ns = 1603733507222449291
+    filepath.lstat.return_value.st_size = 1024
+    filepath.lstat.return_value.st_mtime_ns = 1603733507222449291
+    filepath.lstat.return_value.st_mode = MOCK_MODE
 
     file_sender = FileResponse(filepath)
     file_sender._path = filepath
@@ -80,13 +87,14 @@ 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"
     filepath.with_name.return_value = gz_filepath
-    filepath.stat.return_value.st_size = 1024
-    filepath.stat.return_value.st_mtime_ns = 1603733507222449291
+    filepath.lstat.return_value.st_size = 1024
+    filepath.lstat.return_value.st_mtime_ns = 1603733507222449291
+    filepath.lstat.return_value.st_mode = MOCK_MODE
 
     file_sender = FileResponse(filepath)
     file_sender._path = filepath
@@ -103,8 +111,9 @@ def test_status_controlled_by_user(loop) -> None:
 
     filepath = mock.create_autospec(Path, spec_set=True)
     filepath.name = "logo.png"
-    filepath.stat.return_value.st_size = 1024
-    filepath.stat.return_value.st_mtime_ns = 1603733507222449291
+    filepath.lstat.return_value.st_size = 1024
+    filepath.lstat.return_value.st_mtime_ns = 1603733507222449291
+    filepath.lstat.return_value.st_mode = MOCK_MODE
 
     file_sender = FileResponse(filepath, status=203)
     file_sender._path = filepath
diff --git a/tests/test_web_urldispatcher.py b/tests/test_web_urldispatcher.py
index 0441890..62cb0de 100644
--- a/tests/test_web_urldispatcher.py
+++ b/tests/test_web_urldispatcher.py
@@ -440,6 +440,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: pathlib.Path, aiohttp_client: AiohttpClient
 ) -> None:
-- 
2.33.0

