Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow configuring max_incomplete_event_size for h11 implementation #1514

Merged
merged 8 commits into from Jun 22, 2022
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/deployment.md
Expand Up @@ -120,6 +120,9 @@ Options:
--app-dir TEXT Look for APP in the specified directory, by
adding this to the PYTHONPATH. Defaults to
the current working directory. [default: .]
--h11-max-incomplete-event-size INTEGER
For h11, the maximum number of bytes to
buffer of an incomplete event.
--factory Treat APP as an application factory, i.e. a
() -> <ASGI app> callable.
--help Show this message and exit.
Expand Down
3 changes: 3 additions & 0 deletions docs/index.md
Expand Up @@ -187,6 +187,9 @@ Options:
--app-dir TEXT Look for APP in the specified directory, by
adding this to the PYTHONPATH. Defaults to
the current working directory. [default: .]
--h11-max-incomplete-event-size INTEGER
For h11, the maximum number of bytes to
buffer of an incomplete event.
--factory Treat APP as an application factory, i.e. a
() -> <ASGI app> callable.
--help Show this message and exit.
Expand Down
1 change: 1 addition & 0 deletions docs/settings.md
Expand Up @@ -65,6 +65,7 @@ For more nuanced control over which file modifications trigger reloads, install
* `--ws-ping-interval <float>` - Set the WebSockets ping interval, in seconds. Please note that this can be used only with the default `websockets` protocol.
* `--ws-ping-timeout <float>` - Set the WebSockets ping timeout, in seconds. Please note that this can be used only with the default `websockets` protocol.
* `--lifespan <str>` - Set the Lifespan protocol implementation. **Options:** *'auto', 'on', 'off'.* **Default:** *'auto'*.
* `--h11-max-incomplete-event-size <int>` - Set the maximum number of bytes to buffer of an incomplete event. Only available for `h11` HTTP protocol implementation. **Default:** *'16384'* (16 KB).

## Application Interface

Expand Down
81 changes: 81 additions & 0 deletions tests/protocols/test_http.py
Expand Up @@ -87,6 +87,17 @@
]
)

GET_REQUEST_HUGE_HEADERS = [
b"".join(
[
b"GET / HTTP/1.1\r\n",
b"Host: example.org\r\n",
b"Cookie: " + b"x" * 32 * 1024,
]
),
b"".join([b"x" * 32 * 1024 + b"\r\n", b"\r\n", b"\r\n"]),
]


class MockTransport:
def __init__(self, sockname=None, peername=None, sslcontext=False):
Expand Down Expand Up @@ -796,3 +807,73 @@ def send_fragmented_req(path):
assert bad_response != response[: len(bad_response)]
server.should_exit = True
t.join()


@pytest.mark.parametrize("protocol_cls", [H11Protocol])
def test_huge_headers_h11protocol_will_fail(protocol_cls, event_loop):
app = Response("Hello, world", media_type="text/plain")

with get_connected_protocol(app, protocol_cls, event_loop) as protocol:
# Huge headers make h11 fail in it's default config
# h11 sends back a 400 in this case
protocol.data_received(GET_REQUEST_HUGE_HEADERS[0])
assert b"HTTP/1.1 400 Bad Request" in protocol.transport.buffer
assert b"Connection: close" in protocol.transport.buffer
assert b"Invalid HTTP request received." in protocol.transport.buffer


def test_huge_headers_httptools_will_pass(event_loop, protocol_cls=HttpToolsProtocol):
app = Response("Hello, world", media_type="text/plain")

with get_connected_protocol(app, protocol_cls, event_loop) as protocol:
# Huge headers make h11 fail in it's default config
# httptools protocol will always pass
protocol.data_received(GET_REQUEST_HUGE_HEADERS[0])
protocol.data_received(GET_REQUEST_HUGE_HEADERS[1])
protocol.loop.run_one()
assert b"HTTP/1.1 200 OK" in protocol.transport.buffer
assert b"Hello, world" in protocol.transport.buffer


@pytest.mark.parametrize("protocol_cls", [H11Protocol])
def test_huge_headers_h11protocol_will_fail_with_setting(protocol_cls, event_loop):
app = Response("Hello, world", media_type="text/plain")

with get_connected_protocol(
app, protocol_cls, event_loop, h11_max_incomplete_event_size=20 * 1024
) as protocol:
# Huge headers make h11 fail in it's default config
# h11 sends back a 400 in this case
protocol.data_received(GET_REQUEST_HUGE_HEADERS[0])
assert b"HTTP/1.1 400 Bad Request" in protocol.transport.buffer
assert b"Connection: close" in protocol.transport.buffer
assert b"Invalid HTTP request received." in protocol.transport.buffer


def test_huge_headers_httptools_will_pass_with_setting(
event_loop, protocol_cls=HttpToolsProtocol
):
app = Response("Hello, world", media_type="text/plain")

with get_connected_protocol(app, protocol_cls, event_loop) as protocol:
# Huge headers make h11 fail in it's default config
# httptools protocol will always pass
protocol.data_received(GET_REQUEST_HUGE_HEADERS[0])
protocol.data_received(GET_REQUEST_HUGE_HEADERS[1])
protocol.loop.run_one()
assert b"HTTP/1.1 200 OK" in protocol.transport.buffer
assert b"Hello, world" in protocol.transport.buffer


@pytest.mark.parametrize("protocol_cls", HTTP_PROTOCOLS)
def test_huge_headers_h11_max_incomplete(protocol_cls, event_loop):
app = Response("Hello, world", media_type="text/plain")

with get_connected_protocol(
app, protocol_cls, event_loop, h11_max_incomplete_event_size=64 * 1024
) as protocol:
protocol.data_received(GET_REQUEST_HUGE_HEADERS[0])
protocol.data_received(GET_REQUEST_HUGE_HEADERS[1])
protocol.loop.run_one()
assert b"HTTP/1.1 200 OK" in protocol.transport.buffer
assert b"Hello, world" in protocol.transport.buffer
15 changes: 15 additions & 0 deletions tests/test_cli.py
Expand Up @@ -122,6 +122,21 @@ def test_cli_incomplete_app_parameter() -> None:
assert result.exit_code == 1


def test_cli_event_size() -> None:
runner = CliRunner()

with mock.patch.object(main, "run") as mock_run:
result = runner.invoke(
cli,
["tests.test_cli:App", "--h11-max-incomplete-event-size", str(32 * 1024)],
)

assert result.output == ""
assert result.exit_code == 0
mock_run.assert_called_once()
assert mock_run.call_args[1]["h11_max_incomplete_event_size"] == 32768


@pytest.fixture()
def load_env_h11_protocol():
old_environ = dict(os.environ)
Expand Down
4 changes: 4 additions & 0 deletions uvicorn/config.py
Expand Up @@ -10,6 +10,8 @@
from pathlib import Path
from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type, Union

from h11._connection import DEFAULT_MAX_INCOMPLETE_EVENT_SIZE

from uvicorn._logging import TRACE_LOG_LEVEL

if sys.version_info < (3, 8): # pragma: py-gte-38
Expand Down Expand Up @@ -240,6 +242,7 @@ def __init__(
ssl_ciphers: str = "TLSv1",
headers: Optional[List[Tuple[str, str]]] = None,
factory: bool = False,
h11_max_incomplete_event_size: int = DEFAULT_MAX_INCOMPLETE_EVENT_SIZE,
):
self.app = app
self.host = host
Expand Down Expand Up @@ -283,6 +286,7 @@ def __init__(
self.headers: List[Tuple[str, str]] = headers or []
self.encoded_headers: List[Tuple[bytes, bytes]] = []
self.factory = factory
self.h11_max_incomplete_event_size = h11_max_incomplete_event_size

self.loaded = False
self.configure_logging()
Expand Down
12 changes: 12 additions & 0 deletions uvicorn/main.py
Expand Up @@ -7,6 +7,7 @@

import click
from asgiref.typing import ASGIApplication
from h11._connection import DEFAULT_MAX_INCOMPLETE_EVENT_SIZE

import uvicorn
from uvicorn.config import (
Expand Down Expand Up @@ -339,6 +340,13 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
help="Look for APP in the specified directory, by adding this to the PYTHONPATH."
" Defaults to the current working directory.",
)
@click.option(
"--h11-max-incomplete-event-size",
"h11_max_incomplete_event_size",
type=int,
default=None,
help="For h11, the maximum number of bytes to buffer of an incomplete event.",
)
@click.option(
"--factory",
is_flag=True,
Expand Down Expand Up @@ -391,6 +399,7 @@ def main(
headers: typing.List[str],
use_colors: bool,
app_dir: str,
h11_max_incomplete_event_size: int,
factory: bool,
) -> None:
run(
Expand Down Expand Up @@ -439,6 +448,7 @@ def main(
use_colors=use_colors,
factory=factory,
app_dir=app_dir,
h11_max_incomplete_event_size=h11_max_incomplete_event_size,
)


Expand Down Expand Up @@ -489,6 +499,7 @@ def run(
use_colors: typing.Optional[bool] = None,
app_dir: typing.Optional[str] = None,
factory: bool = False,
h11_max_incomplete_event_size: int = DEFAULT_MAX_INCOMPLETE_EVENT_SIZE,
) -> None:
if app_dir is not None:
sys.path.insert(0, app_dir)
Expand Down Expand Up @@ -538,6 +549,7 @@ def run(
headers=headers,
use_colors=use_colors,
factory=factory,
h11_max_incomplete_event_size=h11_max_incomplete_event_size,
)
server = Server(config=config)

Expand Down
2 changes: 1 addition & 1 deletion uvicorn/protocols/http/h11_impl.py
Expand Up @@ -77,7 +77,7 @@ def __init__(
self.logger = logging.getLogger("uvicorn.error")
self.access_logger = logging.getLogger("uvicorn.access")
self.access_log = self.access_logger.hasHandlers()
self.conn = h11.Connection(h11.SERVER)
self.conn = h11.Connection(h11.SERVER, config.h11_max_incomplete_event_size)
self.ws_protocol_class = config.ws_protocol_class
self.root_path = config.root_path
self.limit_concurrency = config.limit_concurrency
Expand Down