From 44bbdb83bdb752400cb91acf4b82424ca5e07eff Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Tue, 29 Sep 2026 09:45:22 +0200 Subject: [PATCH 1/3] Resolve same-scheme redirects without an authority against the URL (#13855) ## What do these changes do? A redirect `Location` that has the scheme of the current URL but no `//`, such as `http:/path` or `http:path`, is now joined with the current URL, as browsers and the WHATWG URL Standard resolve it. Previously only a `Location` without a scheme was joined, so `http:/example.com` was taken as an absolute URL without a host and failed with `InvalidUrlRedirectClientError`. This is needed for aio-libs/yarl#1937, where yarl reads `http:example.com/` as `http://example.com/` in its default WHATWG mode. Without the join, aiohttp would follow `Location: http:/example.com` to the host `example.com`, while a browser stays on the current host. `Location: http:///example.com` is left as it was (still rejected): browsers read it as the host `example.com`, which `URL.join()` does not do. The check for `//` normalizes the Location as URL parsers do (surrounding C0 controls and spaces stripped, tabs and newlines dropped) and reads a backslash like a slash, as browsers do for http and https. `http:/example.com` is removed from the invalid URL test data: a request to it is invalid with the released yarl but goes to `example.com` with yarl#1937. As a redirect it now resolves on both. ## Are there changes in behavior for the user? Yes. `Location: http:/path`, `http:path` and `http:/` now redirect to that path on the current host instead of raising `InvalidUrlRedirectClientError`. ## Is it a substantial burden for the maintainers to support this? No, it is one condition in the redirect handling. ## Related issue number Needed by aio-libs/yarl#1937, whose "Aiohttp tests" job fails on `http:/example.com` without it. ## Checklist - [x] I think the code is well written - [x] Unit tests for the changes exist - [ ] Documentation reflects the changes: N/A, no documented behavior changes - [ ] If you provide code modification, please add yourself to `CONTRIBUTORS.txt`: N/A, already listed - [x] Add a new news fragment into the `CHANGES/` folder Drafted with Claude Code (Claude Opus 5.5); reviewed by @asvetlov.
Agent run details (optional, for reviewers) Tests: `AIOHTTP_NO_EXTENSIONS=1 pytest tests -n auto`, 4796 passed, 60 skipped, 14 xfailed, both with yarl master (f761c37) and with the yarl#1937 branch. Lint: black, isort, codespell passed; flake8 (E, F, W) passed when run directly, because the local pre-commit flake8 hook fails to load `flake8-requirements` (`pkg_resources` missing); mypy on `aiohttp/client.py` reports the same unrelated errors as on master.
--- CHANGES/13855.bugfix.rst | 5 ++++ aiohttp/client.py | 22 ++++++++++++++++- tests/test_client_functional.py | 43 ++++++++++++++++++++++++++++++--- tests/test_client_session.py | 22 +++++++++++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 CHANGES/13855.bugfix.rst diff --git a/CHANGES/13855.bugfix.rst b/CHANGES/13855.bugfix.rst new file mode 100644 index 00000000000..bd894fc960e --- /dev/null +++ b/CHANGES/13855.bugfix.rst @@ -0,0 +1,5 @@ +Resolved a redirect ``Location`` with the scheme of the current URL but +without ``//``, such as ``http:/path`` or ``http:path``, against the +current URL, as browsers do, instead of treating it as an absolute URL +without a host +-- by :user:`asvetlov`. diff --git a/aiohttp/client.py b/aiohttp/client.py index 9e2112791c1..0bcffd81c5e 100644 --- a/aiohttp/client.py +++ b/aiohttp/client.py @@ -168,6 +168,22 @@ from typing import Unpack +# URL parsers strip leading and trailing C0 control characters and spaces and +# drop tabs and newlines before splitting a URL. +_C0_CONTROL_OR_SPACE = "".join(map(chr, range(0x21))) +_REMOVE_TAB_OR_NEWLINE = str.maketrans("", "", "\t\n\r") + + +def _has_no_authority(location: str, scheme: str) -> bool: + """Tell if a URL with a scheme has no "//" after the scheme. + + Browsers read a backslash like a slash there for http and https. + """ + location = location.strip(_C0_CONTROL_OR_SPACE).translate(_REMOVE_TAB_OR_NEWLINE) + rest = location[len(scheme) + 1 : len(scheme) + 3] + return rest[:1] not in ("/", "\\") or rest[1:] not in ("/", "\\") + + class _RequestOptions(TypedDict, total=False): params: Query data: Any @@ -834,7 +850,11 @@ async def _request( await req._body.close() resp.close() raise NonHttpUrlRedirectClientError(r_url) - elif not scheme: + elif not scheme or ( + scheme == url.scheme and _has_no_authority(r_url, scheme) + ): + # "http:/path" or "http:path" is a reference to + # the current URL, as browsers resolve it. parsed_redirect_url = url.join(parsed_redirect_url) try: diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py index fedb42d2390..18432214756 100644 --- a/tests/test_client_functional.py +++ b/tests/test_client_functional.py @@ -1006,6 +1006,38 @@ async def handler_ok(request: web.Request) -> web.Response: assert resp.url.path == "/ok" +@pytest.mark.parametrize( + ("location", "path", "query"), + ( + ("http:/ok", "/ok", {}), + ("http:ok", "/ok", {}), + ("http:/ok?a=b", "/ok", {"a": "b"}), + ("http:/", "/", {}), + ), +) +async def test_redirect_same_scheme_without_authority( + aiohttp_client: AiohttpClient, location: str, path: str, query: dict[str, str] +) -> None: + async def handler_redirect(request: web.Request) -> web.Response: + return web.Response(status=301, headers={"Location": location}) + + async def handler_ok(request: web.Request) -> web.Response: + assert dict(request.query) == query + return web.Response(status=200) + + app = web.Application() + app.router.add_route("GET", path, handler_ok) + app.router.add_route("GET", "/redirect", handler_redirect) + client = await aiohttp_client(app) + + async with client.get("/redirect") as resp: + assert resp.status == 200 + assert resp.url.host == "127.0.0.1" + assert resp.url.path == path + assert dict(resp.url.query) == query + assert len(resp.history) == 1 + + async def test_history(aiohttp_client: AiohttpClient) -> None: async def handler_redirect(request: web.Request) -> web.Response: return web.Response(status=301, headers={"Location": "/ok"}) @@ -3213,7 +3245,12 @@ async def handler_redirect(request: web.Request) -> web.Response: INVALID_URL_WITH_ERROR_MESSAGE_YARL_ORIGIN = ( # # yarl.URL.origin raises ValueError ("http:/", "http:///"), - ("http:/example.com", "http:///example.com"), + ("http:///example.com", "http:///example.com"), +) + +# A redirect to "http:/" resolves against the current URL, see +# test_redirect_same_scheme_without_authority(). +INVALID_REDIRECT_URL_WITH_ERROR_MESSAGE_YARL_ORIGIN = ( ("http:///example.com", "http:///example.com"), ) @@ -3259,7 +3296,7 @@ async def test_invalid_and_non_http_url( ( *( (url, message, InvalidUrlRedirectClientError) - for (url, message) in INVALID_URL_WITH_ERROR_MESSAGE_YARL_ORIGIN + for (url, message) in INVALID_REDIRECT_URL_WITH_ERROR_MESSAGE_YARL_ORIGIN + INVALID_URL_WITH_ERROR_MESSAGE_YARL_NEW ), *( @@ -3294,7 +3331,7 @@ async def generate_redirecting_response(request: web.Request) -> web.Response: ( *( (url, message, InvalidUrlRedirectClientError) - for (url, message) in INVALID_URL_WITH_ERROR_MESSAGE_YARL_ORIGIN + for (url, message) in INVALID_REDIRECT_URL_WITH_ERROR_MESSAGE_YARL_ORIGIN + INVALID_URL_WITH_ERROR_MESSAGE_YARL_NEW ), *( diff --git a/tests/test_client_session.py b/tests/test_client_session.py index 3851416320d..e37373472e3 100644 --- a/tests/test_client_session.py +++ b/tests/test_client_session.py @@ -1729,3 +1729,25 @@ async def test_netrc_auth_host_not_in_netrc(auth_server: TestServer) -> None: text = await resp.text() # Should not have auth since the host is not in netrc assert text == "no_auth" + + +@pytest.mark.parametrize( + ("location", "expected"), + ( + ("http:/ok", True), + ("http:ok", True), + ("http:", True), + ("http:/", True), + ("http://example.com/", False), + ("http:///example.com", False), + (" http:///example.com", False), + ("\x00http:///example.com", False), + ("http:/\t/example.com", False), + ("http:/\n/example.com", False), + ("http:\\\\example.com", False), + ("http:\\/example.com", False), + ("http:/\\example.com", False), + ), +) +def test_has_no_authority(location: str, expected: bool) -> None: + assert client._has_no_authority(location, "http") is expected From ce960dd17e34f143762c14c32b9e14435ea7ce11 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Tue, 29 Sep 2026 10:58:33 +0200 Subject: [PATCH 2/3] Reject absolute-form targets without an authority before parsing (#13858) ## What do these changes do? Follow-up to #13855 for aio-libs/yarl#1937. With that change, yarl's default WHATWG mode reads a host from `http:host/p`, `http:/host/p` and `http:///host/p`, as browsers do. The Python and Cython request parsers passed an absolute-form request target to `URL()` and rejected it only if yarl found no host. They now first check that the target has `//` followed by a non-empty authority, so `GET http:///protected` and `GET http:/protected` stay rejected as RFC 9110 requires, whatever yarl version is installed. The client tests used `http:///example.com` and `http:///path` as URLs without a host. They now use `http://` where an invalid URL is needed, and `http:///example.com` is dropped from the invalid URL test data. With yarl#1937 it is `http://example.com/`, and as a redirect it leads to `example.com`, as in browsers. ## Are there changes in behavior for the user? No with the released yarl, where such targets were already rejected. With yarl#1937, the server keeps rejecting absolute-form targets without an authority instead of reading a host from them. ## Is it a substantial burden for the maintainers to support this? No, it is one string check shared by both parsers. ## Related issue number Follow-up to #13855; needed by aio-libs/yarl#1937. ## Checklist - [x] I think the code is well written - [x] Unit tests for the changes exist - [ ] Documentation reflects the changes: N/A, no documented behavior changes - [ ] If you provide code modification, please add yourself to `CONTRIBUTORS.txt`: N/A, already listed - [x] Add a new news fragment into the `CHANGES/` folder Drafted with Claude Code (Claude Opus 5.5); reviewed by @asvetlov.
Agent run details (optional, for reviewers) Tests with the Cython extensions built (llhttp generated, `make cythonize`, `build_ext --inplace`): `pytest tests -n auto`, 5246 passed, 22 skipped, 17 xfailed, both with yarl master (f761c37) and with the yarl#1937 branch (8d0f111). Pure-Python run of `test_http_parser.py`, `test_client_functional.py` and `test_client_session.py` with the yarl#1937 branch: 884 passed. Lint: black, isort, codespell passed. The local pre-commit flake8 hook fails to load `flake8-requirements` (`pkg_resources` missing), so flake8 (E, F, W) was run directly and passed. yesqa was skipped for the same reason, because it wrongly strips `# noqa: I900`.
--- CHANGES/13858.bugfix.rst | 7 +++++++ THREAT_MODEL.md | 2 +- aiohttp/_http_parser.pyx | 14 ++++++++------ aiohttp/http_parser.py | 16 ++++++++++++++++ tests/test_client_functional.py | 26 ++++++++------------------ tests/test_http_parser.py | 17 ++++++++++++++++- 6 files changed, 56 insertions(+), 26 deletions(-) create mode 100644 CHANGES/13858.bugfix.rst diff --git a/CHANGES/13858.bugfix.rst b/CHANGES/13858.bugfix.rst new file mode 100644 index 00000000000..9bd2d110184 --- /dev/null +++ b/CHANGES/13858.bugfix.rst @@ -0,0 +1,7 @@ +Rejected absolute-form request targets without ``//`` or with an empty +host, such as ``http:/example.com/`` or ``http:///example.com/``, before +parsing them with yarl, which reads a host from them in its WHATWG mode; +RFC 9110 requires a host for ``http`` and ``https``. The invalid URL test +data no longer uses ``http:///example.com``, which such a yarl version +parses as ``http://example.com/``, as browsers do +-- by :user:`asvetlov`. diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 86ea1bb3dab..898dfe27520 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -268,7 +268,7 @@ into `StreamReader`) is then handed to `web_protocol.RequestHandler` and | 1.9 | Chunk-size DoS | The parser doesn't cap chunk size, but **server-side body length is bounded by `client_max_size` (default `1 MiB`)** in `web_request.py:BaseRequest.read`. Client-side responses are bounded by user-supplied `max_body_size` / streaming reads. | None. If a cap is ever needed at the parser level, plumb it through `HttpPayloadParser`. | | 1.10 | Chunk-extension DoS | Chunk-extension content is bounded by the same wire-level size constraints (it shares the chunk-size line with `max_line_size`). | **Add an explicit test that chunk-extension flooding cannot blow past `max_line_size`.** | | 1.11 | Parser error reflection | `http_parser.py` truncates to `[:100]` only for `LineTooLong`; `BadStatusLine` / `InvalidHeader` / `TransferEncodingError` carry the offending line up to `max_line_size` / `max_field_size`. `_http_parser.pyx` bounds its snippet to 50 bytes either side of the error position, so input with no CRLF to delimit the offending line is not quoted in full (`test_c_parser_error_message_bounded_for_crlf_free_input`). | **Audit any aiohttp path where `BadHttpMessage` content is reflected to the client unsanitised.** **User**: Review custom `web_log` configurations and any middleware that reflects parser exception messages back to the peer. | -| 1.12 | Cython ⇄ pure-Python divergence | `tests/test_http_parser.py` parameterises tests over `REQUEST_PARSERS` / `RESPONSE_PARSERS` (pure-Python always; Cython when the extension imports). The high-leverage attack vectors are already covered under both backends: CL+TE (`test_content_length_transfer_encoding`), CL×N (`test_duplicate_singleton_header_rejected`), obs-fold (`test_reject_obsolete_line_folding`, `test_http_response_parser_obs_line_folding*`), CR/LF/NUL (`test_bad_headers`, `test_http_response_parser_null_byte_in_header_value`, `test_http_response_parser_bad_crlf`), version regex (`test_http_request_parser_bad_version*`, `test_http_response_parser_bad_version*`), bare-LF line endings (`test_reject_bare_lf_no_cross_request_leak`), control characters in the request target (`test_http_request_parser_ctl_in_request_target`). | None. When new attack vectors emerge, add them to the parameterised tests. | +| 1.12 | Cython ⇄ pure-Python divergence | `tests/test_http_parser.py` parameterises tests over `REQUEST_PARSERS` / `RESPONSE_PARSERS` (pure-Python always; Cython when the extension imports). The high-leverage attack vectors are already covered under both backends: CL+TE (`test_content_length_transfer_encoding`), CL×N (`test_duplicate_singleton_header_rejected`), obs-fold (`test_reject_obsolete_line_folding`, `test_http_response_parser_obs_line_folding*`), CR/LF/NUL (`test_bad_headers`, `test_http_response_parser_null_byte_in_header_value`, `test_http_response_parser_bad_crlf`), version regex (`test_http_request_parser_bad_version*`, `test_http_response_parser_bad_version*`), bare-LF line endings (`test_reject_bare_lf_no_cross_request_leak`), control characters in the request target (`test_http_request_parser_ctl_in_request_target`), absolute-form targets without `//` or with an empty host (`test_url_absolute_form_empty_host_rejected`). | None. When new attack vectors emerge, add them to the parameterised tests. | | 1.13 | llhttp version drift | Manual upgrade via `make generate-llhttp`; vendor pinned in `vendor/llhttp/package.json`. | Track upstream releases (e.g. via Dependabot rule for `vendor/llhttp/package.json`), bump on every llhttp release, regenerate in CI. | | 1.14 | npm-side compromise of `llhttp` | The vendored output is checked into git, so a compromise during a future regen would be detectable in PR review. See [§5.19](#519-build--release-supply-chain). | **Make the llhttp build reproducible: pin Node.js version, commit the npm lockfile, and on every bump verify the regenerated C against upstream's release tarballs before committing.** | diff --git a/aiohttp/_http_parser.pyx b/aiohttp/_http_parser.pyx index b656aa741ed..5ee5a905b91 100644 --- a/aiohttp/_http_parser.pyx +++ b/aiohttp/_http_parser.pyx @@ -31,7 +31,7 @@ from .http_exceptions import ( PayloadEncodingError, TransferEncodingError, ) -from .http_parser import DeflateBuffer as _DeflateBuffer +from .http_parser import DeflateBuffer as _DeflateBuffer, _has_authority from .http_writer import ( HttpVersion as _HttpVersion, HttpVersion10 as _HttpVersion10, @@ -793,11 +793,13 @@ cdef class HttpRequestParser(HttpParser): else: # absolute-form for proxy maybe, # https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.2 - try: - self._url = URL(self._path, encoded=True) - host = self._url.raw_host - except ValueError: - host = None + host = None + if _has_authority(self._path): + try: + self._url = URL(self._path, encoded=True) + host = self._url.raw_host + except ValueError: + pass # https://www.rfc-editor.org/rfc/rfc9110#section-4.2.1-4 if host is None: raise InvalidURLError( diff --git a/aiohttp/http_parser.py b/aiohttp/http_parser.py index 7e2376ac1a1..878cbf76f73 100644 --- a/aiohttp/http_parser.py +++ b/aiohttp/http_parser.py @@ -91,6 +91,18 @@ DIGITS: Final[Pattern[str]] = re.compile(r"\d+", re.ASCII) HEXDIGITS: Final[Pattern[bytes]] = re.compile(rb"[0-9a-fA-F]+") + +def _has_authority(target: str) -> bool: + """Tell if an absolute-form request target has "//" and a non-empty authority. + + RFC 9110 section 4.2 requires a host for http and https. yarl's default + WHATWG mode reads a host from "http:host/p" or "http:///host/p", so the + target is checked before it is parsed. + """ + rest = target.partition(":")[2] + return rest[:2] == "//" and rest[2:3] not in ("", "/", "?", "#") + + # RFC 9110 singleton headers — duplicates are rejected in strict mode. # In lax mode (response parser default), the check is skipped entirely # since real-world servers (e.g. Google APIs, Werkzeug) commonly send @@ -719,6 +731,10 @@ def parse_message(self, lines: list[bytes]) -> RawRequestMessage: else: # absolute-form for proxy maybe, # https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.2 + if not _has_authority(path): + raise InvalidURLError( + path.encode(errors="surrogateescape").decode("latin1") + ) try: url = URL(path, encoded=True) host = url.raw_host diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py index 18432214756..705a10f2534 100644 --- a/tests/test_client_functional.py +++ b/tests/test_client_functional.py @@ -3242,17 +3242,9 @@ async def handler_redirect(request: web.Request) -> web.Response: ("http://example.org:non_int_port/", "http://example.org:non_int_port/"), ) -INVALID_URL_WITH_ERROR_MESSAGE_YARL_ORIGIN = ( - # # yarl.URL.origin raises ValueError - ("http:/", "http:///"), - ("http:///example.com", "http:///example.com"), -) - -# A redirect to "http:/" resolves against the current URL, see -# test_redirect_same_scheme_without_authority(). -INVALID_REDIRECT_URL_WITH_ERROR_MESSAGE_YARL_ORIGIN = ( - ("http:///example.com", "http:///example.com"), -) +# yarl.URL.origin raises ValueError. A redirect to "http:/" resolves against +# the current URL instead, see test_redirect_same_scheme_without_authority(). +INVALID_URL_WITH_ERROR_MESSAGE_YARL_ORIGIN = (("http:/", "http:///"),) NON_HTTP_URL_WITH_ERROR_MESSAGE = ( ("call:+380123456789", r"call:\+380123456789"), @@ -3296,8 +3288,7 @@ async def test_invalid_and_non_http_url( ( *( (url, message, InvalidUrlRedirectClientError) - for (url, message) in INVALID_REDIRECT_URL_WITH_ERROR_MESSAGE_YARL_ORIGIN - + INVALID_URL_WITH_ERROR_MESSAGE_YARL_NEW + for (url, message) in INVALID_URL_WITH_ERROR_MESSAGE_YARL_NEW ), *( (url, message, NonHttpUrlRedirectClientError) @@ -3331,8 +3322,7 @@ async def generate_redirecting_response(request: web.Request) -> web.Response: ( *( (url, message, InvalidUrlRedirectClientError) - for (url, message) in INVALID_REDIRECT_URL_WITH_ERROR_MESSAGE_YARL_ORIGIN - + INVALID_URL_WITH_ERROR_MESSAGE_YARL_NEW + for (url, message) in INVALID_URL_WITH_ERROR_MESSAGE_YARL_NEW ), *( (url, message, NonHttpUrlRedirectClientError) @@ -5595,8 +5585,8 @@ async def test_invalid_redirect_origin_closes_payload( async def redirect_handler(request: web.Request) -> web.Response: # Read the payload to simulate server processing await request.read() - # Return a URL that will fail origin() check - using a relative URL without host - return web.Response(status=307, headers={hdrs.LOCATION: "http:///path"}) + # Return a URL that will fail origin() check - using a URL without host + return web.Response(status=307, headers={hdrs.LOCATION: "http://"}) app = web.Application() app.router.add_post("/redirect", redirect_handler) @@ -5669,7 +5659,7 @@ async def stall(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> N async def test_request_error_before_body_created_does_not_mask() -> None: async with aiohttp.ClientSession() as session: with pytest.raises(InvalidUrlClientError): - await session.get("http:///path") + await session.get("http://") async def test_amazon_like_cookie_scenario(aiohttp_client: AiohttpClient) -> None: diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py index 90ac8f3e1ed..11b7df69933 100644 --- a/tests/test_http_parser.py +++ b/tests/test_http_parser.py @@ -1188,8 +1188,23 @@ def test_url_authority_form_only_connect(parser: HttpRequestParser) -> None: b"https:////protected", b"https://:80/protected", b"https://user@/protected", + b"https://", + b"https://?q", + b"http:protected/x", + b"http:/protected/x", + b"http:\\\\protected/x", + ), + ids=( + "empty-host", + "empty-host-extra-slash", + "port-only", + "userinfo-only", + "no-authority", + "empty-host-query", + "no-slashes", + "one-slash", + "backslashes", ), - ids=("empty-host", "empty-host-extra-slash", "port-only", "userinfo-only"), ) def test_url_absolute_form_empty_host_rejected( parser: HttpRequestParser, target: bytes From 8c324e51de638088dfce37d8f95b1ba4caae8e97 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Tue, 29 Sep 2026 12:36:24 +0200 Subject: [PATCH 3/3] Use a long host that is not all digits in Host header tests (#13861) ## What do these changes do? `test_gen_netloc_all` and `test_gen_netloc_no_port` build a URL with a 50-digit host. An upcoming yarl change parses a special-scheme host made only of numbers as an IPv4 address in its default WHATWG mode, as browsers do, and rejects this one as out of range. The tests now use the same digits followed by `.test`, which keeps the point of the tests (a long host in the `Host` header) and passes with every yarl version. ## Are there changes in behavior for the user? No, test-only change. ## Is it a substantial burden for the maintainers to support this? No. ## Related issue number Needed by aio-libs/yarl#1940. yarl CI runs the aiohttp suite against master, 3.14 and 3.15, so this needs backports to both release branches. ## Checklist - [x] I think the code is well written - [x] Unit tests for the changes exist - [ ] Documentation reflects the changes: N/A, test-only - [ ] If you provide code modification, please add yourself to `CONTRIBUTORS.txt`: N/A, already listed - [x] Add a new news fragment into the `CHANGES/` folder Drafted with Claude Code (Claude Opus 5.5); reviewed by @asvetlov.
Agent run details (optional, for reviewers) Tests, pure Python (`AIOHTTP_NO_EXTENSIONS=1`), `pytest tests -n 8`: 4798 passed, 60 skipped, 14 xfailed against the yarl branch for aio-libs/yarl#1940. The two changed tests pass with released yarl 1.25.1; the old versions fail with `ValueError` against the yarl branch. Lint: pre-commit with `SKIP=flake8,yesqa` (the local flake8 hook cannot load `flake8-requirements`); flake8 run directly on the file passed.
--- CHANGES/13861.contrib.rst | 4 ++++ tests/test_client_request.py | 28 +++++++++------------------- 2 files changed, 13 insertions(+), 19 deletions(-) create mode 100644 CHANGES/13861.contrib.rst diff --git a/CHANGES/13861.contrib.rst b/CHANGES/13861.contrib.rst new file mode 100644 index 00000000000..8ce6e9a811a --- /dev/null +++ b/CHANGES/13861.contrib.rst @@ -0,0 +1,4 @@ +Changed the long host in the ``Host`` header tests to one that is not made +only of digits, since yarl now parses such a host as an IP address in its +default mode and rejects this one as out of range +-- by :user:`asvetlov`. diff --git a/tests/test_client_request.py b/tests/test_client_request.py index 224b7fd570c..e1cd4629e82 100644 --- a/tests/test_client_request.py +++ b/tests/test_client_request.py @@ -631,29 +631,19 @@ async def test_params_empty_path_and_url(make_client_request: _RequestMaker) -> assert str(req_none.url) == "http://python.org" +# A long host that is not all digits: WHATWG parses a host made only of +# numbers, like this one without ".test", as an IPv4 address. +LONG_HOST = "12345678901234567890123456789012345678901234567890.test" + + async def test_gen_netloc_all(make_client_request: _RequestMaker) -> None: - req = make_client_request( - "get", - URL( - "https://aiohttp:pwpwpw@12345678901234567890123456789012345678901234567890:8080" - ), - ) - assert ( - req.headers["HOST"] - == "12345678901234567890123456789" + "012345678901234567890:8080" - ) + req = make_client_request("get", URL(f"https://aiohttp:pwpwpw@{LONG_HOST}:8080")) + assert req.headers["HOST"] == f"{LONG_HOST}:8080" async def test_gen_netloc_no_port(make_client_request: _RequestMaker) -> None: - req = make_client_request( - "get", - URL( - "https://aiohttp:pwpwpw@12345678901234567890123456789012345678901234567890/" - ), - ) - assert ( - req.headers["HOST"] == "12345678901234567890123456789" + "012345678901234567890" - ) + req = make_client_request("get", URL(f"https://aiohttp:pwpwpw@{LONG_HOST}/")) + assert req.headers["HOST"] == LONG_HOST async def test_cookie_coded_value_preserved(make_client_request: _RequestMaker) -> None: