diff mbox series

[meta-python,wrynose,5/7] python3-aiohttp: fix CVE-2026-111748

Message ID 20261106072702.502813-6-dkelaiya@cisco.com
State New
Headers show
Series python3-aiohttp: Security fixes | expand

Commit Message

From: Darsh Kelaiya <dkelaiya@cisco.com>

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>
---
 .../python3-aiohttp/CVE-2026-111748_p1.patch  | 376 ++++++++++++++++++
 .../python3-aiohttp/CVE-2026-111748_p2.patch  | 282 +++++++++++++
 .../python/python3-aiohttp_3.13.5.bb          |   2 +
 3 files changed, 660 insertions(+)
 create mode 100644 meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111748_p1.patch
 create mode 100644 meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111748_p2.patch
diff mbox series

Patch

diff --git a/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111748_p1.patch b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111748_p1.patch
new file mode 100644
index 0000000000..30dd8e2b48
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111748_p1.patch
@@ -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
diff --git a/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111748_p2.patch b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111748_p2.patch
new file mode 100644
index 0000000000..1e04648c32
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2026-111748_p2.patch
@@ -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
diff --git a/meta-python/recipes-devtools/python/python3-aiohttp_3.13.5.bb b/meta-python/recipes-devtools/python/python3-aiohttp_3.13.5.bb
index e2ac1eda3a..10817e6f34 100644
--- a/meta-python/recipes-devtools/python/python3-aiohttp_3.13.5.bb
+++ b/meta-python/recipes-devtools/python/python3-aiohttp_3.13.5.bb
@@ -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"