new file mode 100644
@@ -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
new file mode 100644
@@ -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 <Name> <Surname>.
+ * 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"