Skip to content

Commit

Permalink
Add a max keep alive requests configuration option
Browse files Browse the repository at this point in the history
This will cause HTTP/1 and HTTP/2 requests to close when the limit has
been reached. This matches nginx's mitigation against the rapid reset
HTTP/2 attack.
  • Loading branch information
pgjones committed Dec 26, 2023
1 parent 0e4117d commit 926c430
Show file tree
Hide file tree
Showing 7 changed files with 58 additions and 0 deletions.
11 changes: 11 additions & 0 deletions docs/discussion/dos_mitigations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,14 @@ data that it cannot send to the client.

To mitigate this Hypercorn responds to the backpressure and pauses
(blocks) the coroutine writing the response.

Rapid reset
^^^^^^^^^^^

This attack works by opening and closing streams in quick succession
in the expectation that this is more costly for the server than the
client.

To mitigate Hypercorn will only allow a maximum number of requests per
kept-alive connection before closing it. This ensures that cost of the
attack is equally born by the client.
2 changes: 2 additions & 0 deletions docs/how_to_guides/configuring.rst
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ insecure_bind ``--insecure-bind`` The TCP host/address to
See *bind* for formatting options.
Care must be taken! See HTTP -> HTTPS
redirection docs.
keep_alive_max_requests N/A Maximum number of requests before connection 1000
is closed. HTTP/1 & HTTP/2 only.
keep_alive_timeout ``--keep-alive`` Seconds to keep inactive connections alive 5s
before closing.
keyfile ``--keyfile`` Path to the SSL key file.
Expand Down
1 change: 1 addition & 0 deletions src/hypercorn/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ class Config:
include_date_header = True
include_server_header = True
keep_alive_timeout = 5 * SECONDS
keep_alive_max_requests = 1000
keyfile: Optional[str] = None
keyfile_password: Optional[str] = None
logconfig: Optional[str] = None
Expand Down
3 changes: 3 additions & 0 deletions src/hypercorn/protocol/h11.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ def __init__(
h11.SERVER, max_incomplete_event_size=self.config.h11_max_incomplete_size
)
self.context = context
self.keep_alive_requests = 0
self.send = send
self.server = server
self.ssl = ssl
Expand Down Expand Up @@ -234,6 +235,7 @@ async def _create_stream(self, request: h11.Request) -> None:
raw_path=request.target,
)
)
self.keep_alive_requests += 1

async def _send_h11_event(self, event: H11SendableEvent) -> None:
try:
Expand Down Expand Up @@ -264,6 +266,7 @@ async def _maybe_recycle(self) -> None:
not self.context.terminated.is_set()
and self.connection.our_state is h11.DONE
and self.connection.their_state is h11.DONE
and self.keep_alive_requests <= self.config.keep_alive_max_requests
):
try:
self.connection.start_next_cycle()
Expand Down
6 changes: 6 additions & 0 deletions src/hypercorn/protocol/h2.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def __init__(
},
)

self.keep_alive_requests = 0
self.send = send
self.server = server
self.ssl = ssl
Expand Down Expand Up @@ -244,6 +245,9 @@ async def _handle_events(self, events: List[h2.events.Event]) -> None:
else:
await self._create_stream(event)
await self.send(Updated(idle=False))

if self.keep_alive_requests > self.config.keep_alive_max_requests:
self.connection.close_connection()
elif isinstance(event, h2.events.DataReceived):
await self.streams[event.stream_id].handle(
Body(stream_id=event.stream_id, data=event.data)
Expand Down Expand Up @@ -349,6 +353,7 @@ async def _create_stream(self, request: h2.events.RequestReceived) -> None:
raw_path=raw_path,
)
)
self.keep_alive_requests += 1

async def _create_server_push(
self, stream_id: int, path: bytes, headers: List[Tuple[bytes, bytes]]
Expand All @@ -374,6 +379,7 @@ async def _create_server_push(
event.headers = request_headers
await self._create_stream(event)
await self.streams[event.stream_id].handle(EndBody(stream_id=event.stream_id))
self.keep_alive_max_requests += 1

async def _close_stream(self, stream_id: int) -> None:
if stream_id in self.streams:
Expand Down
12 changes: 12 additions & 0 deletions tests/protocol/test_h11.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ async def test_protocol_send_body(protocol: H11Protocol) -> None:
]


@pytest.mark.asyncio
async def test_protocol_keep_alive_max_requests(protocol: H11Protocol) -> None:
data = b"GET / HTTP/1.1\r\nHost: hypercorn\r\n\r\n"
protocol.config.keep_alive_max_requests = 0
await protocol.handle(RawData(data=data))
await protocol.stream_send(Response(stream_id=1, status_code=200, headers=[]))
await protocol.stream_send(EndBody(stream_id=1))
await protocol.stream_send(StreamClosed(stream_id=1))
protocol.send.assert_called() # type: ignore
assert protocol.send.call_args_list[3] == call(Closed()) # type: ignore


@pytest.mark.asyncio
@pytest.mark.parametrize("keep_alive, expected", [(True, Updated(idle=True)), (False, Closed())])
async def test_protocol_send_stream_closed(
Expand Down
23 changes: 23 additions & 0 deletions tests/protocol/test_h2.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from unittest.mock import call, Mock

import pytest
from h2.connection import H2Connection
from h2.events import ConnectionTerminated

from hypercorn.asyncio.worker_context import EventWrapper, WorkerContext
from hypercorn.config import Config
Expand Down Expand Up @@ -78,3 +80,24 @@ async def test_protocol_handle_protocol_error() -> None:
await protocol.handle(RawData(data=b"broken nonsense\r\n\r\n"))
protocol.send.assert_awaited() # type: ignore
assert protocol.send.call_args_list == [call(Closed())] # type: ignore


@pytest.mark.asyncio
async def test_protocol_keep_alive_max_requests() -> None:
protocol = H2Protocol(
Mock(), Config(), WorkerContext(), AsyncMock(), False, None, None, AsyncMock()
)
protocol.config.keep_alive_max_requests = 0
client = H2Connection()
client.initiate_connection()
headers = [
(":method", "GET"),
(":path", "/reqinfo"),
(":authority", "hypercorn"),
(":scheme", "https"),
]
client.send_headers(1, headers, end_stream=True)
await protocol.handle(RawData(data=client.data_to_send()))
protocol.send.assert_awaited() # type: ignore
events = client.receive_data(protocol.send.call_args_list[1].args[0].data) # type: ignore
assert isinstance(events[-1], ConnectionTerminated)

0 comments on commit 926c430

Please sign in to comment.