Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGES/13929.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Tightened the HTTP/1 version parsing (``VERSRE`` and the C parser's
``http_version()``) to only accept ``HTTP/1.0`` and ``HTTP/1.1``.
Previously ``HTTP/0.9``, ``HTTP/2.0``, and other malformed versions
(e.g. ``HTTP/9.9``) were accepted and could silently affect
keep-alive and ``Host`` header handling downstream
-- by :user:`choudhryfrompak`.
1 change: 1 addition & 0 deletions CONTRIBUTORS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Carl George
Cecile Tonglet
Chien-Wei Huang
Chih-Yuan Chen
Choudhry Shehryar
Chris AtLee
Chris Laws
Chris Moore
Expand Down
18 changes: 12 additions & 6 deletions THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,11 @@ into `StreamReader`) is then handed to `web_protocol.RequestHandler` and
responsibility of consumers (logging, header reflection, proxying).
- Methods are accepted as any RFC 7230 token; the parser does not canonicalise
case.
- Versions are accepted by the regex `HTTP/(\d)\.(\d)` — i.e. `HTTP/0.9`,
`HTTP/2.0`, etc. all parse without rejection, even though they cannot be
served correctly.
- Versions are restricted to `HTTP/1.0` and `HTTP/1.1` by the regex
`HTTP/(1)\.([01])` (pure-Python parser) and an equivalent `major == 1 and
minor in (0, 1)` guard in the C parser's `http_version()`; anything else
(`HTTP/0.9`, `HTTP/2.0`, `HTTP/9.9`, etc.) is rejected with `BadStatusLine`
before a message is handed downstream.

**Assets at risk.**

Expand All @@ -242,7 +244,7 @@ into `StreamReader`) is then handed to `web_protocol.RequestHandler` and
| 1.2 | Header block, line endings | T | Smuggling via bare-LF, obs-fold, optional CR-before-LF on the *request* parser. Request parser is strict; lenient flags apply only to the response parser. | Medium |
| 1.3 | Header values, CR/LF/NUL | T / I | CRLF injection enabling response splitting / header injection if downstream re-emits values verbatim. Historically [CVE-2023-37276](https://github.com/aio-libs/aiohttp/security/advisories/GHSA-45c4-8wx5-qw6w). | High |
| 1.4 | Header values, surrogateescape decode | I / T | Non-UTF-8 bytes round-trip through `Headers` and may be reflected by user code / proxies / logs into untrusted contexts. | Medium |
| 1.5 | HTTP version regex | T | `HTTP/0.9` and `HTTP/2.0` accepted on the wire, opening a small surface for protocol-confusion against intermediaries that handle these specially. | Low |
| 1.5 | HTTP version regex | T | `HTTP/0.9` and `HTTP/2.0` accepted on the wire, opening a small surface for protocol-confusion against intermediaries that handle these specially. **Closed** (see Mitigations below and the recap). | Low |
| 1.6 | Method token | I / T | Methods are not case-canonicalised; arbitrary tokens up to `max_line_size` accepted. May confuse downstream method-based authorisation if user code compares case-sensitively. | Low |
| 1.7 | `Content-Length` parsing | T | Negative or non-decimal CL handling, multiple comma-separated CLs, CL with leading `+`/whitespace. | Medium |
| 1.8 | `Transfer-Encoding: chunked` parsing | T | Lenient acceptance (`xchunked`, `chunked, identity`, doubled `chunked`) leading to smuggling against a non-aiohttp peer that interprets differently. | Medium |
Expand All @@ -261,14 +263,14 @@ into `StreamReader`) is then handed to `web_protocol.RequestHandler` and
| 1.2 | Lenient response parsing | Lenient flags (`llhttp_set_lenient_headers`, `llhttp_set_lenient_optional_cr_before_lf`, `llhttp_set_lenient_spaces_after_chunk_size`) are only enabled on the **response** parser and only when `DEBUG` is False (set in `HttpResponseParser.__init__`). The **request** parser is strict. | Documented design decision: keep lenient response parsing for real-world server interop |
| 1.3 | CRLF / NUL in header values | Bytes `\r`, `\n`, `\x00` rejected in header values (`_http_parser.pyx` callbacks; `http_parser.py:HeadersParser.parse_headers`). | Keep regression tests in `tests/test_http_parser.py` covering each forbidden byte both in name and value, and across both Cython and pure-Python parsers. |
| 1.4 | Non-UTF-8 round-trip | None at parser layer (intentional — preserving original bytes is required for some use cases). | **Document in user-facing docs that header values are bytes-preserving.** **User**: Re-validate any header value before reflecting it into responses, logs, or sub-requests. |
| 1.5 | HTTP version regex accepts 0.9 / 2.0 | None (regex is permissive). | **Tighten `VERSRE` (and llhttp configuration if possible) to reject anything outside `HTTP/1.0` and `HTTP/1.1`.** |
| 1.5 | HTTP version regex accepts 0.9 / 2.0 | `VERSRE` tightened to `HTTP/(1)\.([01])`; the C parser's `http_version()` raises `BadStatusLine` for any `(major, minor)` other than `(1, 0)` / `(1, 1)` instead of falling through to an unchecked `HttpVersion(major, minor)`. Covered by `test_http_request_parser_rejects_non_1x_version` / `test_http_response_parser_rejects_non_1x_version` across both backends. | None. |
| 1.6 | Method-case round-trip | Method token validated by regex; not canonicalised. | **Document the asymmetry.** **User**: Compare HTTP methods case-sensitively to canonical RFC tokens, or use `web.RouteTableDef` decorators (which already match canonical methods). |
| 1.7 | `Content-Length` parsing | llhttp validates CL is decimal and non-negative; pure-Python parser validates via `DIGITS.fullmatch(r"\d+")` before `int(...)`, rejecting `+`/`-`/non-ASCII-digit forms (`test_bad_headers`, `test_headers_content_length_err_*` cover these). | None. Cross-backend parity is covered by the shared parser tests. |
| 1.8 | `Transfer-Encoding` lenience | `_is_chunked_te` requires `chunked` to be the last value; duplicate `chunked` rejected (`#10611`). Request parser strict. | None. |
| 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`), 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.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*`, `test_http_request_parser_rejects_non_1x_version`, `test_http_request_parser_accepts_1x_version`, `test_http_response_parser_rejects_non_1x_version`, `test_http_response_parser_accepts_1x_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 Expand Up @@ -297,6 +299,10 @@ into `StreamReader`) is then handed to `web_protocol.RequestHandler` and
- **PR #12137** (3.13.4) — precautionary hardening: pure-Python parser
now explicitly rejects duplicate `Transfer-Encoding: chunked` on
the request parser.
- **PR #13929** (unreleased) — closed threat 1.5: `VERSRE` and the C
parser's `http_version()` now reject any version outside `HTTP/1.0` /
`HTTP/1.1` (`HTTP/0.9`, `HTTP/2.0`, `HTTP/9.9`, etc. are rejected with
`BadStatusLine` on both backends, request and response).
- **GHSA-c427-h43c-vf67 (CVE-2026-34525)** (3.13.4) — duplicate `Host` header accepted
in request parser, bypassing `Application.add_domain()` host-based
routing / authorisation. Fixed by adding `Host` to the strict
Expand Down
4 changes: 3 additions & 1 deletion aiohttp/_http_parser.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,9 @@ cdef class HttpParser:
elif parser.http_minor == 1:
return HttpVersion11

return HttpVersion(parser.http_major, parser.http_minor)
raise BadStatusLine(
error=f"Unsupported HTTP version: {parser.http_major}.{parser.http_minor}"
)

### Public API ###

Expand Down
2 changes: 1 addition & 1 deletion aiohttp/http_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
r"[\x00-\x08\x0a-\x1f\x7f]"
)
_TARGET_FORBIDDEN_CTL_RE: Final[Pattern[str]] = re.compile(r"[\x00-\x1f\x7f]")
VERSRE: Final[Pattern[str]] = re.compile(r"HTTP/(\d)\.(\d)", re.ASCII)
VERSRE: Final[Pattern[str]] = re.compile(r"HTTP/(1)\.([01])", re.ASCII)
DIGITS: Final[Pattern[str]] = re.compile(r"\d+", re.ASCII)
HEXDIGITS: Final[Pattern[bytes]] = re.compile(rb"[0-9a-fA-F]+")

Expand Down
44 changes: 42 additions & 2 deletions tests/test_http_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -760,15 +760,15 @@ def xfail_c_parser_status(request: pytest.FixtureRequest) -> None:

@pytest.mark.usefixtures("xfail_c_parser_status")
def test_parse_unusual_request_line(parser: HttpRequestParser) -> None:
text = b"#smol //a HTTP/1.3\r\n\r\n"
text = b"#smol //a HTTP/1.0\r\n\r\n"
messages, upgrade, tail = parser.feed_data(text)
assert len(messages) == 1
msg, _ = messages[0]
assert msg.compression is None
assert not msg.upgrade
assert msg.method == "#SMOL"
assert msg.path == "//a"
assert msg.version == (1, 3)
assert msg.version == (1, 0)


def test_py_parser_normalises_method_to_uppercase(
Expand Down Expand Up @@ -2193,6 +2193,27 @@ def test_http_request_parser_bad_version_number(parser: HttpRequestParser) -> No
parser.feed_data(b"GET /test HTTP/1.32\r\nHost: a\r\n\r\n")


@pytest.mark.parametrize("version", [b"HTTP/0.9", b"HTTP/2.0", b"HTTP/9.9"])
def test_http_request_parser_rejects_non_1x_version(
parser: HttpRequestParser, version: bytes
) -> None:
"""Only HTTP/1.0 and HTTP/1.1 are valid; see THREAT_MODEL.md 5.1, threat 1.5."""
with pytest.raises(http_exceptions.BadHttpMessage):
parser.feed_data(b"GET /test " + version + b"\r\nHost: a\r\n\r\n")


@pytest.mark.parametrize("version", [b"HTTP/1.0", b"HTTP/1.1"])
def test_http_request_parser_accepts_1x_version(
parser: HttpRequestParser, version: bytes
) -> None:
messages, upgrade, tail = parser.feed_data(
b"GET /test " + version + b"\r\nHost: a\r\n\r\n"
)
msg = messages[0][0]
major, minor = (int(x) for x in version[len(b"HTTP/") :].split(b"."))
assert msg.version == (major, minor)


def test_http_request_parser_bad_ascii_uri(parser: HttpRequestParser) -> None:
with pytest.raises(http_exceptions.InvalidURLError):
parser.feed_data(b"GET ! HTTP/1.1\r\n\r\n")
Expand Down Expand Up @@ -2366,6 +2387,25 @@ def test_http_response_parser_bad_version_number(response: HttpResponseParser) -
response.feed_data(b"HTTP/12.3 200 Ok\r\n\r\n")


@pytest.mark.parametrize("version", [b"HTTP/0.9", b"HTTP/2.0", b"HTTP/9.9"])
def test_http_response_parser_rejects_non_1x_version(
response: HttpResponseParser, version: bytes
) -> None:
"""Only HTTP/1.0 and HTTP/1.1 are valid; see THREAT_MODEL.md 5.1, threat 1.5."""
with pytest.raises(http_exceptions.BadHttpMessage):
response.feed_data(version + b" 200 Ok\r\n\r\n")


@pytest.mark.parametrize("version", [b"HTTP/1.0", b"HTTP/1.1"])
def test_http_response_parser_accepts_1x_version(
response: HttpResponseParser, version: bytes
) -> None:
messages, upgrade, tail = response.feed_data(version + b" 200 Ok\r\n\r\n")
msg = messages[0][0]
major, minor = (int(x) for x in version[len(b"HTTP/") :].split(b"."))
assert msg.version == (major, minor)


def test_http_response_parser_no_reason(response: HttpResponseParser) -> None:
msg = response.feed_data(b"HTTP/1.1 200\r\n\r\n")[0][0][0]

Expand Down
Loading