Skip to content

Commit 4f834b6

Browse files
authored
Handle 403 and 404 issues in FileResponse class (#8538)
1 parent 7108d64 commit 4f834b6

File tree

6 files changed

+138
-32
lines changed

6 files changed

+138
-32
lines changed

CHANGES/8182.bugfix.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Adjusted ``FileResponse`` to check file existence and access when preparing the response -- by :user:`steverep`.
2+
3+
The :py:class:`~aiohttp.web.FileResponse` class was modified to respond with
4+
403 Forbidden or 404 Not Found as appropriate. Previously, it would cause a
5+
server error if the path did not exist or could not be accessed. Checks for
6+
existence, non-regular files, and permissions were expected to be done in the
7+
route handler. For static routes, this now permits a compressed file to exist
8+
without its uncompressed variant and still be served. In addition, this
9+
changes the response status for files without read permission to 403, and for
10+
non-regular files from 404 to 403 for consistency.

aiohttp/web_fileresponse.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sys
55
from contextlib import suppress
66
from mimetypes import MimeTypes
7+
from stat import S_ISREG
78
from types import MappingProxyType
89
from typing import (
910
IO,
@@ -22,6 +23,8 @@
2223
from .helpers import ETAG_ANY, ETag, must_be_empty_body
2324
from .typedefs import LooseHeaders, PathLike
2425
from .web_exceptions import (
26+
HTTPForbidden,
27+
HTTPNotFound,
2528
HTTPNotModified,
2629
HTTPPartialContent,
2730
HTTPPreconditionFailed,
@@ -177,13 +180,22 @@ def _get_file_path_stat_encoding(
177180
return file_path, file_path.stat(), None
178181

179182
async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]:
180-
loop = asyncio.get_event_loop()
183+
loop = asyncio.get_running_loop()
181184
# Encoding comparisons should be case-insensitive
182185
# https://www.rfc-editor.org/rfc/rfc9110#section-8.4.1
183186
accept_encoding = request.headers.get(hdrs.ACCEPT_ENCODING, "").lower()
184-
file_path, st, file_encoding = await loop.run_in_executor(
185-
None, self._get_file_path_stat_encoding, accept_encoding
186-
)
187+
try:
188+
file_path, st, file_encoding = await loop.run_in_executor(
189+
None, self._get_file_path_stat_encoding, accept_encoding
190+
)
191+
except FileNotFoundError:
192+
self.set_status(HTTPNotFound.status_code)
193+
return await super().prepare(request)
194+
195+
# Forbid special files like sockets, pipes, devices, etc.
196+
if not S_ISREG(st.st_mode):
197+
self.set_status(HTTPForbidden.status_code)
198+
return await super().prepare(request)
187199

188200
etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}"
189201
last_modified = st.st_mtime
@@ -320,7 +332,12 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter
320332
if count == 0 or must_be_empty_body(request.method, self.status):
321333
return await super().prepare(request)
322334

323-
fobj = await loop.run_in_executor(None, file_path.open, "rb")
335+
try:
336+
fobj = await loop.run_in_executor(None, file_path.open, "rb")
337+
except PermissionError:
338+
self.set_status(HTTPForbidden.status_code)
339+
return await super().prepare(request)
340+
324341
if start: # be aware that start could be None or int=0 here.
325342
offset = start
326343
else:

aiohttp/web_urldispatcher.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -690,10 +690,7 @@ def _resolve_path_to_response(self, unresolved_path: Path) -> StreamResponse:
690690
except PermissionError as error:
691691
raise HTTPForbidden() from error
692692

693-
# Not a regular file or does not exist.
694-
if not file_path.is_file():
695-
raise HTTPNotFound()
696-
693+
# Return the file response, which handles all other checks.
697694
return FileResponse(file_path, chunk_size=self._chunk_size)
698695

699696
def _directory_as_html(self, dir_path: Path) -> str:

tests/test_web_sendfile.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from pathlib import Path
2+
from stat import S_IFREG, S_IRUSR, S_IWUSR
23
from typing import Any
34
from unittest import mock
45

56
from aiohttp import hdrs
67
from aiohttp.test_utils import make_mocked_coro, make_mocked_request
78
from aiohttp.web_fileresponse import FileResponse
89

10+
MOCK_MODE = S_IFREG | S_IRUSR | S_IWUSR
11+
912

1013
def test_using_gzip_if_header_present_and_file_available(loop: Any) -> None:
1114
request = make_mocked_request(
@@ -18,6 +21,7 @@ def test_using_gzip_if_header_present_and_file_available(loop: Any) -> None:
1821
gz_filepath = mock.create_autospec(Path, spec_set=True)
1922
gz_filepath.stat.return_value.st_size = 1024
2023
gz_filepath.stat.return_value.st_mtime_ns = 1603733507222449291
24+
gz_filepath.stat.return_value.st_mode = MOCK_MODE
2125

2226
filepath = mock.create_autospec(Path, spec_set=True)
2327
filepath.name = "logo.png"
@@ -39,12 +43,14 @@ def test_gzip_if_header_not_present_and_file_available(loop: Any) -> None:
3943
gz_filepath = mock.create_autospec(Path, spec_set=True)
4044
gz_filepath.stat.return_value.st_size = 1024
4145
gz_filepath.stat.return_value.st_mtime_ns = 1603733507222449291
46+
gz_filepath.stat.return_value.st_mode = MOCK_MODE
4247

4348
filepath = mock.create_autospec(Path, spec_set=True)
4449
filepath.name = "logo.png"
4550
filepath.with_suffix.return_value = gz_filepath
4651
filepath.stat.return_value.st_size = 1024
4752
filepath.stat.return_value.st_mtime_ns = 1603733507222449291
53+
filepath.stat.return_value.st_mode = MOCK_MODE
4854

4955
file_sender = FileResponse(filepath)
5056
file_sender._path = filepath
@@ -67,6 +73,7 @@ def test_gzip_if_header_not_present_and_file_not_available(loop: Any) -> None:
6773
filepath.with_suffix.return_value = gz_filepath
6874
filepath.stat.return_value.st_size = 1024
6975
filepath.stat.return_value.st_mtime_ns = 1603733507222449291
76+
filepath.stat.return_value.st_mode = MOCK_MODE
7077

7178
file_sender = FileResponse(filepath)
7279
file_sender._path = filepath
@@ -91,6 +98,7 @@ def test_gzip_if_header_present_and_file_not_available(loop: Any) -> None:
9198
filepath.with_suffix.return_value = gz_filepath
9299
filepath.stat.return_value.st_size = 1024
93100
filepath.stat.return_value.st_mtime_ns = 1603733507222449291
101+
filepath.stat.return_value.st_mode = MOCK_MODE
94102

95103
file_sender = FileResponse(filepath)
96104
file_sender._path = filepath
@@ -109,6 +117,7 @@ def test_status_controlled_by_user(loop: Any) -> None:
109117
filepath.name = "logo.png"
110118
filepath.stat.return_value.st_size = 1024
111119
filepath.stat.return_value.st_mtime_ns = 1603733507222449291
120+
filepath.stat.return_value.st_mode = MOCK_MODE
112121

113122
file_sender = FileResponse(filepath, status=203)
114123
file_sender._path = filepath

tests/test_web_sendfile_functional.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def hello_txt(request, tmp_path_factory) -> pathlib.Path:
4040
"br": txt.with_suffix(f"{txt.suffix}.br"),
4141
"bzip2": txt.with_suffix(f"{txt.suffix}.bz2"),
4242
}
43-
hello[None].write_bytes(HELLO_AIOHTTP)
43+
# Uncompressed file is not actually written to test it is not required.
4444
hello["gzip"].write_bytes(gzip.compress(HELLO_AIOHTTP))
4545
hello["br"].write_bytes(brotli.compress(HELLO_AIOHTTP))
4646
hello["bzip2"].write_bytes(bz2.compress(HELLO_AIOHTTP))

tests/test_web_urldispatcher.py

Lines changed: 95 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import asyncio
22
import functools
3+
import os
34
import pathlib
5+
import socket
46
import sys
5-
from typing import Generator, Optional
6-
from unittest import mock
7-
from unittest.mock import MagicMock
7+
from stat import S_IFIFO, S_IMODE
8+
from typing import Any, Generator, Optional
89

910
import pytest
1011
import yarl
@@ -445,6 +446,56 @@ def mock_is_dir(self: pathlib.Path) -> bool:
445446
assert r.status == 403
446447

447448

449+
@pytest.mark.skipif(
450+
sys.platform.startswith("win32"), reason="Cannot remove read access on Windows"
451+
)
452+
async def test_static_file_without_read_permission(
453+
tmp_path: pathlib.Path, aiohttp_client: AiohttpClient
454+
) -> None:
455+
"""Test static file without read permission receives forbidden response."""
456+
my_file = tmp_path / "my_file.txt"
457+
my_file.write_text("secret")
458+
my_file.chmod(0o000)
459+
460+
app = web.Application()
461+
app.router.add_static("/", str(tmp_path))
462+
client = await aiohttp_client(app)
463+
464+
r = await client.get(f"/{my_file.name}")
465+
assert r.status == 403
466+
467+
468+
async def test_static_file_with_mock_permission_error(
469+
monkeypatch: pytest.MonkeyPatch,
470+
tmp_path: pathlib.Path,
471+
aiohttp_client: AiohttpClient,
472+
) -> None:
473+
"""Test static file with mock permission errors receives forbidden response."""
474+
my_file = tmp_path / "my_file.txt"
475+
my_file.write_text("secret")
476+
my_readable = tmp_path / "my_readable.txt"
477+
my_readable.write_text("info")
478+
479+
real_open = pathlib.Path.open
480+
481+
def mock_open(self: pathlib.Path, *args: Any, **kwargs: Any) -> Any:
482+
if my_file.samefile(self):
483+
raise PermissionError()
484+
return real_open(self, *args, **kwargs)
485+
486+
monkeypatch.setattr("pathlib.Path.open", mock_open)
487+
488+
app = web.Application()
489+
app.router.add_static("/", str(tmp_path))
490+
client = await aiohttp_client(app)
491+
492+
# Test the mock only applies to my_file, then test the permission error.
493+
r = await client.get(f"/{my_readable.name}")
494+
assert r.status == 200
495+
r = await client.get(f"/{my_file.name}")
496+
assert r.status == 403
497+
498+
448499
async def test_access_symlink_loop(
449500
tmp_path: pathlib.Path, aiohttp_client: AiohttpClient
450501
) -> None:
@@ -464,32 +515,54 @@ async def test_access_symlink_loop(
464515

465516

466517
async def test_access_special_resource(
467-
tmp_path: pathlib.Path, aiohttp_client: AiohttpClient
518+
tmp_path_factory: pytest.TempPathFactory, aiohttp_client: AiohttpClient
468519
) -> None:
469-
# Tests the access to a resource that is neither a file nor a directory.
470-
# Checks that if a special resource is accessed (f.e. named pipe or UNIX
471-
# domain socket) then 404 HTTP status returned.
520+
"""Test access to non-regular files is forbidden using a UNIX domain socket."""
521+
if not getattr(socket, "AF_UNIX", None):
522+
pytest.skip("UNIX domain sockets not supported")
523+
524+
tmp_path = tmp_path_factory.mktemp("special")
525+
my_special = tmp_path / "sock"
526+
my_socket = socket.socket(socket.AF_UNIX)
527+
my_socket.bind(str(my_special))
528+
assert my_special.is_socket()
529+
472530
app = web.Application()
531+
app.router.add_static("/", str(tmp_path))
473532

474-
with mock.patch("pathlib.Path.__new__") as path_constructor:
475-
special = MagicMock()
476-
special.is_dir.return_value = False
477-
special.is_file.return_value = False
533+
client = await aiohttp_client(app)
534+
r = await client.get(f"/{my_special.name}")
535+
assert r.status == 403
536+
my_socket.close()
478537

479-
path = MagicMock()
480-
path.joinpath.side_effect = lambda p: (special if p == "special" else path)
481-
path.resolve.return_value = path
482-
special.resolve.return_value = special
483538

484-
path_constructor.return_value = path
539+
async def test_access_mock_special_resource(
540+
monkeypatch: pytest.MonkeyPatch,
541+
tmp_path: pathlib.Path,
542+
aiohttp_client: AiohttpClient,
543+
) -> None:
544+
"""Test access to non-regular files is forbidden using a mock FIFO."""
545+
my_special = tmp_path / "my_special"
546+
my_special.touch()
547+
548+
real_result = my_special.stat()
549+
real_stat = pathlib.Path.stat
550+
551+
def mock_stat(self: pathlib.Path) -> os.stat_result:
552+
s = real_stat(self)
553+
if os.path.samestat(s, real_result):
554+
mock_mode = S_IFIFO | S_IMODE(s.st_mode)
555+
s = os.stat_result([mock_mode] + list(s)[1:])
556+
return s
485557

486-
# Register global static route:
487-
app.router.add_static("/", str(tmp_path), show_index=True)
488-
client = await aiohttp_client(app)
558+
monkeypatch.setattr("pathlib.Path.stat", mock_stat)
489559

490-
# Request the root of the static directory.
491-
r = await client.get("/special")
492-
assert r.status == 403
560+
app = web.Application()
561+
app.router.add_static("/", str(tmp_path))
562+
client = await aiohttp_client(app)
563+
564+
r = await client.get(f"/{my_special.name}")
565+
assert r.status == 403
493566

494567

495568
async def test_partially_applied_handler(aiohttp_client: AiohttpClient) -> None:

0 commit comments

Comments
 (0)