diff --git a/CHANGES/13580.bugfix.rst b/CHANGES/13580.bugfix.rst new file mode 100644 index 00000000000..4a903a93b5d --- /dev/null +++ b/CHANGES/13580.bugfix.rst @@ -0,0 +1,5 @@ +Fixed :attr:`aiohttp.web.BaseRequest.http_range` rejecting ``Range`` headers whose +range unit was not spelled in lowercase. Range unit names are case-insensitive per +:rfc:`9110#section-14.1.1`, so ``Range: Bytes=0-3`` is valid and no longer causes +:class:`aiohttp.web.FileResponse` to return ``416 Range Not Satisfiable`` +-- by :user:`LALITH0110`. diff --git a/CHANGES/13584.bugfix.rst b/CHANGES/13584.bugfix.rst new file mode 120000 index 00000000000..402ff87b6a1 --- /dev/null +++ b/CHANGES/13584.bugfix.rst @@ -0,0 +1 @@ +13580.bugfix.rst \ No newline at end of file diff --git a/aiohttp/web_request.py b/aiohttp/web_request.py index 5f426d743c7..ff52aa39f04 100644 --- a/aiohttp/web_request.py +++ b/aiohttp/web_request.py @@ -640,8 +640,12 @@ def http_range(self) -> "slice[int, int, int]": start, end = None, None if rng is not None: try: + # Range unit names are case-insensitive (RFC 9110 sec. 14.1.1). + # re.ASCII is retained alongside re.IGNORECASE so that case + # folding stays ASCII-only: without it "byte\u017f=0-3" (LATIN + # SMALL LETTER LONG S) would also match. pattern = r"^bytes=(\d*)-(\d*)$" - start, end = re.findall(pattern, rng, re.ASCII)[0] + start, end = re.findall(pattern, rng, re.ASCII | re.IGNORECASE)[0] except IndexError: # pattern was not found in header raise ValueError("range not in acceptable format") diff --git a/tests/test_web_request.py b/tests/test_web_request.py index d1b6f553a99..76d5599f78e 100644 --- a/tests/test_web_request.py +++ b/tests/test_web_request.py @@ -316,6 +316,26 @@ def test_range_non_ascii() -> None: req.http_range +@pytest.mark.parametrize("unit", ["bytes", "Bytes", "BYTES", "bYtEs"]) +def test_range_to_slice_unit_case_insensitive(unit: str) -> None: + # Range unit names are case-insensitive (RFC 9110 sec. 14.1.1). + req = make_mocked_request( + "GET", "/", headers=CIMultiDict([("RANGE", f"{unit}=0-499")]) + ) + assert isinstance(req.http_range, slice) + assert req.http_range.start == 0 and req.http_range.stop == 500 + + +def test_range_unit_non_ascii_case_fold() -> None: + # ſ = LATIN SMALL LETTER LONG S, which case-folds to "s" under Unicode + # rules. Matching is ASCII-only, so this is not a valid spelling of "bytes". + req = make_mocked_request( + "GET", "/", headers=CIMultiDict([("RANGE", "byteſ=0-499")]) + ) + with pytest.raises(ValueError, match="range not in acceptable format"): + req.http_range + + def test_non_keepalive_on_http10() -> None: req = make_mocked_request("GET", "/", version=HttpVersion(1, 0)) assert not req.keep_alive