python3-aiohttp: fix CVE-2026-111748

Backport the protocol-scheme fix [1] to aiohttp 3.13.5, as identified
by [3]. Derive Request.scheme from the transport rather than the
untrusted scheme in an absolute-form request target.

Include the authority-validation correction [2] to reject malformed
targets before URL parsing, independently of yarl normalization.
Retain the upstream parser and request regressions, adapted to the
existing transport-test interface. The recipe regenerates the C
parser from the patched Cython source.

[1] https://github.com/aio-libs/aiohttp/commit/6d4e7b01f9b951aa084a75d7356e907d4ee80411
[2] https://github.com/aio-libs/aiohttp/commit/186065caf37d5999e91fd3d4ddfd03b43eb08801
[3] https://github.com/aio-libs/aiohttp/security/advisories/GHSA-f9xw-jh8m-3mwq

Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
This commit is contained in:
Darsh Kelaiya authored and Anuj Mittal committed 2026-11-09 09:10:49 +05:30
1 parent d21774156e
commit 9244a2e741
3 files changed
+660

No files matched your search

@@ -0,0 +1,376 @@
From 6d4e7b01f9b951aa084a75d7356e907d4ee80411 Mon Sep 17 00:00:00 2001
From: Sam Bull <git@sambull.org>
Date: Sun, 27 Sep 2026 18:11:00 +0100
Subject: [PATCH] Use protocol scheme for absolute-form requests (#13821)
(#13827) (#13828)
(cherry picked from commit a921cfd0fec28d5a9e5bf8d6f4372b8fa63c49df)
(cherry picked from commit 93d91e37e7c6035d09e89646b3de737735ed91b5)
CVE: CVE-2026-111748
Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/6d4e7b01f9b951aa084a75d7356e907d4ee80411]
Backport Changes:
- Omit the enum declaration hunk: HTTP_OPTIONS is already declared.
- Adapt the request-test imports and constructor helper to 3.13.5;
omit newer transport mock attributes absent from RequestHandler.
- Retain the existing surrounding parser-test type annotation.
Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
---
CHANGES/13821.bugfix.rst | 1 +
aiohttp/_http_parser.pyx | 29 +++++++++++++++---
aiohttp/http_parser.py | 26 +++++++++++++---
aiohttp/web_request.py | 7 ++++-
tests/test_http_parser.py | 59 ++++++++++++++++++++++++++++++++++++
tests/test_web_functional.py | 46 ++++++++++++++++++++++++++--
tests/test_web_request.py | 43 +++++++++++++++++++-------
7 files changed, 189 insertions(+), 22 deletions(-)
create mode 100644 CHANGES/13821.bugfix.rst
diff --git a/CHANGES/13821.bugfix.rst b/CHANGES/13821.bugfix.rst
new file mode 100644
index 0000000..f35418d
--- /dev/null
+++ b/CHANGES/13821.bugfix.rst
@@ -0,0 +1 @@
+Fixed the web server trusting the scheme of an absolute-form request-target -- by :user:`Dreamsorcerer`.
diff --git a/aiohttp/_http_parser.pyx b/aiohttp/_http_parser.pyx
index ffbd53f..4390253 100644
--- a/aiohttp/_http_parser.pyx
+++ b/aiohttp/_http_parser.pyx
@@ -669,12 +669,20 @@ cdef class HttpRequestParser(HttpParser):
return
self._path = self._buf.decode('utf-8', 'surrogateescape')
try:
- idx3 = len(self._path)
if self._cparser.method == cparser.HTTP_CONNECT:
# authority-form,
# https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.3
- self._url = URL.build(authority=self._path, encoded=True)
- elif idx3 > 1 and self._path[0] == '/':
+ try:
+ self._url = URL.build(authority=self._path, encoded=True)
+ host = self._url.raw_host
+ except ValueError:
+ host = None
+ if host is None:
+ raise InvalidURLError(
+ self._path.encode(errors="surrogateescape")
+ .decode("latin1")
+ )
+ elif self._path[0] == '/':
# origin-form,
# https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.1
idx1 = self._path.find("?")
@@ -705,10 +713,23 @@ cdef class HttpRequestParser(HttpParser):
fragment=fragment,
encoded=True,
)
+ elif self._path == '*' and self._cparser.method == cparser.HTTP_OPTIONS:
+ # asterisk-form,
+ self._url = URL(self._path, encoded=True)
else:
# absolute-form for proxy maybe,
# https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.2
- self._url = URL(self._path, encoded=True)
+ try:
+ self._url = URL(self._path, encoded=True)
+ host = self._url.raw_host
+ except ValueError:
+ host = None
+ # https://www.rfc-editor.org/rfc/rfc9110#section-4.2.1-4
+ if host is None:
+ raise InvalidURLError(
+ self._path.encode(errors="surrogateescape")
+ .decode("latin1")
+ )
finally:
PyByteArray_Resize(self._buf, 0)
diff --git a/aiohttp/http_parser.py b/aiohttp/http_parser.py
index ce15764..1c0650a 100644
--- a/aiohttp/http_parser.py
+++ b/aiohttp/http_parser.py
@@ -665,7 +665,18 @@ class HttpRequestParser(HttpParser[RawRequestMessage]):
if method == "CONNECT":
# authority-form,
# https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.3
- url = URL.build(authority=path, encoded=True)
+ try:
+ url = URL.build(authority=path, encoded=True)
+ host = url.raw_host
+ except ValueError:
+ # Duplicated so mypy understands that url must be defined below.
+ raise InvalidURLError(
+ path.encode(errors="surrogateescape").decode("latin1")
+ )
+ if host is None:
+ raise InvalidURLError(
+ path.encode(errors="surrogateescape").decode("latin1")
+ )
elif path.startswith("/"):
# origin-form,
# https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.1
@@ -688,9 +699,16 @@ class HttpRequestParser(HttpParser[RawRequestMessage]):
else:
# absolute-form for proxy maybe,
# https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.2
- url = URL(path, encoded=True)
- if url.scheme == "":
- # not absolute-form
+ try:
+ url = URL(path, encoded=True)
+ host = url.raw_host
+ except ValueError:
+ # Duplicated so mypy understands that url must be defined below.
+ raise InvalidURLError(
+ path.encode(errors="surrogateescape").decode("latin1")
+ )
+ # https://www.rfc-editor.org/rfc/rfc9110#section-4.2.1-4
+ if host is None:
raise InvalidURLError(
path.encode(errors="surrogateescape").decode("latin1")
)
diff --git a/aiohttp/web_request.py b/aiohttp/web_request.py
index e978b2a..6e9de9a 100644
--- a/aiohttp/web_request.py
+++ b/aiohttp/web_request.py
@@ -207,8 +207,14 @@ class BaseRequest(MutableMapping[str, Any], HeadersMixin):
self._cache: Dict[str, Any] = {}
url = message.url
if url.absolute:
+ if scheme is None and url.scheme:
+ # Absolute URL is peer-controlled, use the protocol.
+ scheme = "https" if protocol.ssl_context else "http"
if scheme is not None:
+ # Authority-form (CONNECT) has no scheme: leave the target
+ # unchanged unless clone(scheme=...) overrides it.
url = url.with_scheme(scheme)
+ self._cache["scheme"] = scheme
if host is not None:
url = url.with_host(host)
# absolute URL is given,
@@ -216,7 +222,6 @@ class BaseRequest(MutableMapping[str, Any], HeadersMixin):
# all other properties should be good
self._cache["url"] = url
self._cache["host"] = url.host
- self._cache["scheme"] = url.scheme
self._rel_url = url.relative()
else:
self._rel_url = url
diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py
index 4255f4d..610b77e 100644
--- a/tests/test_http_parser.py
+++ b/tests/test_http_parser.py
@@ -900,6 +900,65 @@ def test_url_absolute(parser: Any) -> None:
assert msg.url == URL("https://www.google.com/path/to.html")
+def test_url_authority_form_only_connect(parser: HttpRequestParser) -> None:
+ # https://www.rfc-editor.org/info/rfc9112/#section-3.2.3-1
+ with pytest.raises(http_exceptions.InvalidURLError):
+ parser.feed_data(b"GET www.google.com:443 HTTP/1.1\r\nHost: a\r\n\r\n")
+
+
+@pytest.mark.parametrize(
+ "target",
+ (
+ b"https:///protected",
+ b"https:////protected",
+ b"https://:80/protected",
+ b"https://user@/protected",
+ ),
+ ids=("empty-host", "empty-host-extra-slash", "port-only", "userinfo-only"),
+)
+def test_url_absolute_form_empty_host_rejected(
+ parser: HttpRequestParser, target: bytes
+) -> None:
+ # https://www.rfc-editor.org/rfc/rfc9110#section-4.2.2-4
+ with pytest.raises(http_exceptions.InvalidURLError):
+ parser.feed_data(b"GET " + target + b" HTTP/1.1\r\nHost: a\r\n\r\n")
+
+
+def test_url_absolute_form_invalid_port_rejected(parser: HttpRequestParser) -> None:
+ # yarl raises ValueError for an out-of-range port; that must surface as
+ # a 400, not escape the parser as a bare ValueError.
+ with pytest.raises(http_exceptions.InvalidURLError):
+ parser.feed_data(b"GET http://example.com:65536/x HTTP/1.1\r\nHost: a\r\n\r\n")
+
+
+def test_url_connect_invalid_port_rejected(parser: HttpRequestParser) -> None:
+ with pytest.raises(http_exceptions.InvalidURLError):
+ parser.feed_data(b"CONNECT example.com:65536 HTTP/1.1\r\nHost: a\r\n\r\n")
+
+
+def test_url_connect_empty_host_rejected(parser: HttpRequestParser) -> None:
+ with pytest.raises(http_exceptions.InvalidURLError):
+ parser.feed_data(b"CONNECT :80 HTTP/1.1\r\nHost: a\r\n\r\n")
+
+
+def test_url_origin_form_bare_slash(parser: HttpRequestParser) -> None:
+ messages, upgrade, tail = parser.feed_data(b"GET / HTTP/1.1\r\nHost: a\r\n\r\n")
+ assert messages[0][0].url == URL("/")
+
+
+def test_url_asterisk_form_options(parser: HttpRequestParser) -> None:
+ # https://www.rfc-editor.org/rfc/rfc9112#section-3.2.4
+ messages, upgrade, tail = parser.feed_data(b"OPTIONS * HTTP/1.1\r\nHost: a\r\n\r\n")
+ assert messages[0][0].url == URL("*")
+
+
+def test_url_asterisk_form_only_options(parser: HttpRequestParser) -> None:
+ # asterisk-form is only valid for OPTIONS; for other methods "*" is
+ # neither origin-form nor a valid absolute-form target.
+ with pytest.raises(http_exceptions.InvalidURLError):
+ parser.feed_data(b"GET * HTTP/1.1\r\nHost: a\r\n\r\n")
+
+
def test_headers_old_websocket_key1(parser: Any) -> None:
text = b"GET /test HTTP/1.1\r\nSEC-WEBSOCKET-KEY1: line\r\n\r\n"
diff --git a/tests/test_web_functional.py b/tests/test_web_functional.py
index 9fafd52..ffb2b95 100644
--- a/tests/test_web_functional.py
+++ b/tests/test_web_functional.py
@@ -1929,8 +1929,50 @@ async def test_unread_compressed_body_drain_is_bounded(
assert max(drain_reads) < decompressed_size
-async def test_app_max_client_size(aiohttp_client) -> None:
- async def handler(request):
+async def test_absolute_form_target_does_not_spoof_scheme(
+ aiohttp_server: AiohttpServer,
+) -> None:
+ """A plaintext absolute-form target with https must not look secure."""
+
+ async def handler(request: web.Request) -> web.Response:
+ return web.json_response(
+ {
+ "scheme": request.scheme,
+ "secure": request.secure,
+ "url": str(request.url),
+ "host": request.host,
+ }
+ )
+
+ app = web.Application()
+ app.router.add_get("/probe", handler)
+ server = await aiohttp_server(app)
+
+ reader, writer = await asyncio.open_connection(server.host, server.port)
+ try:
+ writer.write(
+ b"GET https://trusted.example/probe HTTP/1.1\r\n"
+ b"Host: trusted.example\r\n"
+ b"Connection: close\r\n\r\n"
+ )
+ await writer.drain()
+ raw = await asyncio.wait_for(reader.read(), 5)
+ finally:
+ writer.close()
+ with suppress(ConnectionResetError, BrokenPipeError):
+ await writer.wait_closed()
+
+ assert raw.startswith(b"HTTP/1.1 200 ")
+ assert json.loads(raw.split(b"\r\n\r\n", 1)[1]) == {
+ "scheme": "http",
+ "secure": False,
+ "url": "http://trusted.example/probe",
+ "host": "trusted.example",
+ }
+
+
+async def test_app_max_client_size(aiohttp_client: AiohttpClient) -> None:
+ async def handler(request: web.Request) -> NoReturn:
await request.post()
return web.Response(body=b"ok")
diff --git a/tests/test_web_request.py b/tests/test_web_request.py
index 7778128..44bf7a3 100644
--- a/tests/test_web_request.py
+++ b/tests/test_web_request.py
@@ -11,13 +11,15 @@ import pytest
from multidict import CIMultiDict, CIMultiDictProxy, MultiDict
from yarl import URL
-from aiohttp import HttpVersion
+from aiohttp import HttpVersion, web
+from aiohttp.abc import AbstractStreamWriter
from aiohttp.base_protocol import BaseProtocol
from aiohttp.http_exceptions import BadHttpMessage, LineTooLong
from aiohttp.http_parser import RawRequestMessage
from aiohttp.streams import StreamReader
from aiohttp.test_utils import make_mocked_request
-from aiohttp.web import BaseRequest, HTTPRequestEntityTooLarge
+from aiohttp.web import HTTPRequestEntityTooLarge
+from aiohttp.web_protocol import RequestHandler
from aiohttp.web_request import _FORWARDED_PAIR_RE, ETag
@@ -26,6 +28,19 @@ def protocol():
return mock.Mock(_reading_paused=False)
+def make_base_request(
+ message: RawRequestMessage, protocol: RequestHandler
+) -> web.BaseRequest:
+ return web.BaseRequest(
+ message,
+ mock.create_autospec(StreamReader, spec_set=True, instance=True),
+ protocol,
+ mock.create_autospec(AbstractStreamWriter, spec_set=True, instance=True),
+ mock.create_autospec(asyncio.Task, spec_set=True, instance=True),
+ mock.create_autospec(asyncio.AbstractEventLoop, spec_set=True, instance=True),
+ )
+
+
def test_base_ctor() -> None:
message = RawRequestMessage(
"GET",
@@ -40,9 +55,9 @@ def test_base_ctor() -> None:
URL("/path/to?a=1&b=2"),
)
- req = BaseRequest(
- message, mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock()
- )
+ protocol = mock.create_autospec(RequestHandler, spec_set=True, instance=True)
+ protocol.ssl_context = None
+ req = make_base_request(message, protocol)
assert "GET" == req.method
assert HttpVersion(1, 1) == req.version
@@ -167,18 +182,24 @@ def test_non_ascii_raw_path() -> None:
def test_absolute_url() -> None:
req = make_mocked_request("GET", "https://example.com/path/to?a=1")
- assert req.url == URL("https://example.com/path/to?a=1")
- assert req.scheme == "https"
+ assert req.url == URL("http://example.com/path/to?a=1")
+ # The scheme of an absolute-form target is peer-controlled and must not
+ # override the transport-derived scheme.
+ assert req.scheme == "http"
+ assert not req.secure
assert req.host == "example.com"
assert req.rel_url == URL.build(path="/path/to", query={"a": "1"})
def test_clone_absolute_scheme() -> None:
req = make_mocked_request("GET", "https://example.com/path/to?a=1")
- assert req.scheme == "https"
- req2 = req.clone(scheme="http")
- assert req2.scheme == "http"
- assert req2.url.scheme == "http"
+ assert req.scheme == "http"
+ req2 = req.clone(scheme="https")
+ assert req2.scheme == "https"
+ assert req2.url.scheme == "https"
+ req3 = req2.clone(scheme="http")
+ assert req3.scheme == "http"
+ assert req3.url.scheme == "http"
def test_clone_absolute_host() -> None:
--
2.35.6
@@ -0,0 +1,282 @@
From 186065caf37d5999e91fd3d4ddfd03b43eb08801 Mon Sep 17 00:00:00 2001
From: Andrew Svetlov <andrew.svetlov@gmail.com>
Date: Tue, 29 Sep 2026 11:25:53 +0200
Subject: [PATCH] Reject absolute-form targets without an authority before
parsing (#13858) (#13860)
<!-- Thank you for your contribution! -->
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.
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.
No, it is one string check shared by both parsers.
Follow-up to #13855; needed by aio-libs/yarl#1937.
- [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.
<details>
<summary>Agent run details (optional, for reviewers)</summary>
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`.
</details>
(cherry picked from commit ce960dd17e34f143762c14c32b9e14435ea7ce11)
<!-- Thank you for your contribution! -->
## What do these changes do?
<!-- Please give a short brief about these changes. -->
## Are there changes in behavior for the user?
<!-- Outline any notable behaviour for the end users. -->
## Is it a substantial burden for the maintainers to support this?
<!--
Stop right there! Pause. Just for a minute... Can you think of anything
obvious that would complicate the ongoing development of this project?
Try to consider if you'd be able to maintain it throughout the next
5 years. Does it seem viable? Tell us your thoughts! We'd very much
love to hear what the consequences of merging this patch might be...
This will help us assess if your change is something we'd want to
entertain early in the review process. Thank you in advance!
-->
## Related issue number
<!-- Will this resolve any open issues? -->
<!-- Remember to prefix with 'Fixes' if it closes an issue (e.g. 'Fixes
#123'). -->
## Checklist
- [ ] I think the code is well written
- [ ] Unit tests for the changes exist
- [ ] Documentation reflects the changes
- [ ] If you provide code modification, please add yourself to
`CONTRIBUTORS.txt`
* The format is &lt;Name&gt; &lt;Surname&gt;.
* Please keep alphabetical order, the file is sorted by names.
- [ ] Add a new news fragment into the `CHANGES/` folder
* name it `<issue_or_pr_num>.<type>.rst` (e.g. `588.bugfix.rst`)
* if you don't have an issue number, change it to the pull request
number after creating the PR
* `.bugfix`: A bug fix for something the maintainers deemed an
improper undesired behavior that got corrected to match
pre-agreed expectations.
* `.feature`: A new behavior, public APIs. That sort of stuff.
* `.deprecation`: A declaration of future API removals and breaking
changes in behavior.
* `.breaking`: When something public is removed in a breaking way.
Could be deprecated in an earlier release.
* `.doc`: Notable updates to the documentation structure or build
process.
* `.packaging`: Notes for downstreams about unobvious side effects
and tooling. Changes in the test invocation considerations and
runtime assumptions.
* `.contrib`: Stuff that affects the contributor experience. e.g.
Running tests, building the docs, setting up the development
environment.
* `.misc`: Changes that are hard to assign to any of the above
categories.
* Make sure to use full sentences with correct case and punctuation,
for example:
```rst
Fixed issue with non-ascii contents in doctest text files
-- by :user:`contributor-gh-handle`.
```
Use the past tense or the present tense a non-imperative mood,
referring to what's changed compared to the last released version
of this project.
CVE: CVE-2026-111748
Upstream-Status: Backport [https://github.com/aio-libs/aiohttp/commit/186065caf37d5999e91fd3d4ddfd03b43eb08801]
Backport Changes:
- Omit the THREAT_MODEL.md update because that file is absent.
- Adapt the existing invalid-URL fixture list and redirect test.
Omit updates to newer redirect data and tests absent from 3.13.5.
Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
---
CHANGES/13858.bugfix.rst | 7 +++++++
aiohttp/_http_parser.pyx | 14 ++++++++------
aiohttp/http_parser.py | 16 ++++++++++++++++
tests/test_client_functional.py | 6 ++----
tests/test_http_parser.py | 17 ++++++++++++++++-
5 files changed, 49 insertions(+), 11 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 0000000..9bd2d11
--- /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/aiohttp/_http_parser.pyx b/aiohttp/_http_parser.pyx
index 4390253..694ccea 100644
--- a/aiohttp/_http_parser.pyx
+++ b/aiohttp/_http_parser.pyx
@@ -30,7 +30,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,
@@ -719,11 +719,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 1c0650a..4687768 100644
--- a/aiohttp/http_parser.py
+++ b/aiohttp/http_parser.py
@@ -89,6 +89,18 @@ VERSRE: Final[Pattern[str]] = re.compile(r"HTTP/(\d)\.(\d)", re.ASCII)
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
@@ -699,6 +711,10 @@ class HttpRequestParser(HttpParser[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 3415b83..eac6d81 100644
--- a/tests/test_client_functional.py
+++ b/tests/test_client_functional.py
@@ -3129,8 +3129,6 @@ INVALID_URL_WITH_ERROR_MESSAGE_YARL_NEW = (
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"),
)
NON_HTTP_URL_WITH_ERROR_MESSAGE = (
@@ -5458,8 +5456,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)
diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py
index 610b77e..1397c1f 100644
--- a/tests/test_http_parser.py
+++ b/tests/test_http_parser.py
@@ -913,8 +913,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
--
2.35.6
@@ -27,6 +27,8 @@ SRC_URI += " \
file://CVE-2026-111750.patch \
file://CVE-2026-111749.patch \
file://CVE-2026-111065.patch \
file://CVE-2026-111748_p1.patch \
file://CVE-2026-111748_p2.patch \
"
CVE_PRODUCT = "aiohttp:aiohttp"