Reject absolute-form targets without an authority before parsing by asvetlov · Pull Request #13858 · aio-libs/aiohttp · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGES/13858.bugfix.rst
2 changes: 1 addition & 1 deletion THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.** |

Expand Down
14 changes: 8 additions & 6 deletions aiohttp/_http_parser.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions aiohttp/http_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 8 additions & 18 deletions tests/test_client_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 16 additions & 1 deletion tests/test_http_parser.py
Loading