Skip to content

Commit 663d356

Browse files
Fix raw path (aio-libs#13175)
1 parent 1adc0cd commit 663d356

6 files changed

Lines changed: 95 additions & 3 deletions

File tree

CHANGES/13175.bugfix.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fixed :attr:`~aiohttp.web.BaseRequest.raw_path` including the scheme and host
2+
for absolute-form request targets, and made the pure-Python parser reject
3+
authority-form targets (``host:port``) for methods other than ``CONNECT``
4+
-- by :user:`Dreamsorcerer`.

aiohttp/http_parser.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -696,8 +696,9 @@ def parse_message(self, lines: list[bytes]) -> RawRequestMessage:
696696
# absolute-form for proxy maybe,
697697
# https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.2
698698
url = URL(path, encoded=True)
699-
if url.scheme == "":
700-
# not absolute-form
699+
if not url.absolute:
700+
# authority-form is only allowed with CONNECT
701+
# https://www.rfc-editor.org/info/rfc9112/#section-3.2.3-1
701702
raise InvalidURLError(
702703
path.encode(errors="surrogateescape").decode("latin1")
703704
)

aiohttp/web_request.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,26 @@ def raw_path(self) -> str:
465465
466466
E.g., ``/my%2Fpath%7Cwith%21some%25strange%24characters``
467467
"""
468-
return self._message.path
468+
path = self._message.path
469+
470+
# An absolute-form target carries a "scheme://authority" that must not
471+
# leak into the path. Strip it, keeping the remainder byte-for-byte,
472+
# exactly as an origin-form target. Authority-form is used only by
473+
# CONNECT and is left unchanged.
474+
# https://www.rfc-editor.org/info/rfc9112/#section-3.2.2-9
475+
# https://www.rfc-editor.org/info/rfc9112/#name-authority-form
476+
if self._message.url.absolute and self._method != "CONNECT":
477+
# absolute-form always contains "://" (guaranteed by the parser).
478+
scheme_sep = path.find("://")
479+
assert scheme_sep != -1
480+
cursor = scheme_sep + 3
481+
rel = len(path)
482+
for delimiter in "/?#":
483+
found = path.find(delimiter, cursor)
484+
if found != -1:
485+
rel = min(rel, found)
486+
return path[rel:]
487+
return path
469488

470489
@reify
471490
def query(self) -> MultiDictProxy[str]:

tests/test_http_parser.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,6 +1133,12 @@ def test_url_absolute(parser: HttpRequestParser) -> None:
11331133
assert msg.url == URL("https://www.google.com/path/to.html")
11341134

11351135

1136+
def test_url_authority_form_only_connect(parser: HttpRequestParser) -> None:
1137+
# https://www.rfc-editor.org/info/rfc9112/#section-3.2.3-1
1138+
with pytest.raises(http_exceptions.InvalidURLError):
1139+
parser.feed_data(b"GET www.google.com:443 HTTP/1.1\r\nHost: a\r\n\r\n")
1140+
1141+
11361142
def test_headers_old_websocket_key1(parser: HttpRequestParser) -> None:
11371143
text = b"GET /test HTTP/1.1\r\nHost: a\r\nSEC-WEBSOCKET-KEY1: line\r\n\r\n"
11381144

tests/test_web_middleware.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
from collections.abc import Awaitable, Callable, Iterable
3+
from contextlib import suppress
34
from typing import NoReturn
45

56
import pytest
@@ -415,6 +416,32 @@ async def handle(request: web.Request) -> web.StreamResponse:
415416
assert resp.headers["Location"] == "/google.com"
416417
assert resp.url.query == URL("//google.com").query
417418

419+
async def test_open_redirect_absolute_form_target(
420+
self, aiohttp_server: AiohttpServer
421+
) -> None:
422+
async def handle(request: web.Request) -> web.Response:
423+
assert False
424+
425+
app = web.Application(middlewares=[web.normalize_path_middleware()])
426+
app.add_routes([web.get("/google.com/", handle)])
427+
server = await aiohttp_server(app)
428+
429+
reader, writer = await asyncio.open_connection(server.host, server.port)
430+
try:
431+
writer.write(
432+
b"GET http://google.com/google.com HTTP/1.1\r\n"
433+
b"Host: localhost\r\nConnection: close\r\n\r\n"
434+
)
435+
await writer.drain()
436+
head = (await reader.readuntil(b"\r\n\r\n")).decode("ascii")
437+
finally:
438+
writer.close()
439+
with suppress(ConnectionResetError, BrokenPipeError):
440+
await writer.wait_closed()
441+
442+
assert head.startswith("HTTP/1.1 308 ")
443+
assert "\r\nLocation: /google.com/\r\n" in head
444+
418445

419446
async def test_normalize_path_skips_parser_error(
420447
aiohttp_server: AiohttpServer,

tests/test_web_request.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,41 @@ def test_absolute_url() -> None:
220220
assert req.rel_url == URL.build(path="/path/to", query={"a": "1"})
221221

222222

223+
def test_absolute_form_raw_path() -> None:
224+
# An absolute-form target (RFC 9112 3.2.2) must not leak the scheme/host
225+
# into raw_path. The path, query and fragment are kept byte-for-byte, the
226+
# same raw form an origin-form target yields.
227+
req = make_mocked_request("GET", "https://example.com/path/to?a=1#frag")
228+
assert req.raw_path == "/path/to?a=1#frag"
229+
assert req.raw_path == make_mocked_request("GET", "/path/to?a=1#frag").raw_path
230+
231+
232+
def test_connect_authority_form_raw_path() -> None:
233+
# Authority-form is only used by CONNECT (RFC 9112 3.2.3); its target is a
234+
# bare host:port with no scheme prefix, so raw_path returns it unchanged.
235+
message = RawRequestMessage(
236+
"CONNECT",
237+
"example.com:443",
238+
HttpVersion(1, 1),
239+
HeadersDictProxy(CIMultiDict()),
240+
(),
241+
False,
242+
None,
243+
False,
244+
False,
245+
URL.build(authority="example.com:443", encoded=True),
246+
)
247+
protocol = mock.Mock()
248+
protocol.ssl_context = None
249+
protocol.peername = None
250+
protocol.sockname = ("127.0.0.1", 80)
251+
req = web.BaseRequest(
252+
message, mock.Mock(), protocol, mock.Mock(), mock.Mock(), mock.Mock()
253+
)
254+
assert req._message.url.absolute
255+
assert req.raw_path == "example.com:443"
256+
257+
223258
def test_clone_absolute_scheme() -> None:
224259
req = make_mocked_request("GET", "https://example.com/path/to?a=1")
225260
assert req.scheme == "https"

0 commit comments

Comments
 (0)