Skip to content

Commit 974ee44

Browse files
committed
harden against DoS crashes and raise concurrency defaults
Security/robustness fixes for running on public networks: - reject deeply nested JSON with 400 instead of crashing the event loop (json.loads raises RecursionError, a RuntimeError subclass, which was neither ValueError nor ClientError and propagated out of wait()) - close the WebSocket on send-buffer overflow during auto-pong instead of letting OSError escape the loop (ping flood / dead consumer) - tolerate custom/unknown response status codes via STATUS_CODES.get() instead of raising KeyError Defaults tuned for public-facing use: - MAX_WAITING_CLIENTS 5 -> 32 (browsers open ~6 connections each) - listen backlog 2 -> 8 Docs: - sync README kwargs defaults with code (keep_alive_timeout 30->15, max_waiting_clients, add request_timeout/file_chunk_size/listen) - warn about path traversal in respond_file (README + docstring) - add TODO: distinguish waiting vs active connections in _accept eviction Add tests/test_dos_hardening.py covering the three crash fixes.
1 parent 46b723d commit 974ee44

3 files changed

Lines changed: 253 additions & 12 deletions

File tree

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,13 +256,16 @@ Parameters:
256256
- `ssl_context` - Optional `ssl.SSLContext` for HTTPS connections (default: None)
257257
- `event_mode` - Enable event mode for streaming uploads (default: False)
258258
- `**kwargs` - Additional options:
259-
- `max_waiting_clients` - Maximum concurrent connections (default: 5)
260-
- `keep_alive_timeout` - Keep-alive timeout in seconds (default: 30)
259+
- `max_waiting_clients` - Maximum concurrent connections (default: 32)
260+
- `keep_alive_timeout` - Keep-alive timeout in seconds (default: 15)
261261
- `keep_alive_max_requests` - Max requests per connection (default: 100)
262+
- `request_timeout` - Max seconds from first byte to complete headers (default: 5)
262263
- `max_headers_length` - Maximum header size in bytes (default: 4KB)
263264
- `max_content_length` - Maximum body size in bytes (default: 512KB, only enforced when event_mode=False)
264265
- `max_send_buffer_size` - Maximum pending bytes in send buffer for backpressure (default: 64KB). When a slow client cannot drain TCP fast enough, `_send()` raises `OSError` instead of growing `_send_buffer` unbounded.
265266
- `max_ws_message_length` - Maximum WebSocket message size before chunking (default: 64KB)
267+
- `file_chunk_size` - Chunk size in bytes for streaming file responses (default: 4KB)
268+
- `listen` - Listening socket backlog (default: 8)
266269
- `trusted_proxies` - List of trusted proxy IP addresses (default: None). When set, `remote_address` uses `X-Forwarded-For` header for connections from these IPs. When not set, `X-Forwarded-For` is ignored.
267270

268271
#### Properties:
@@ -433,6 +436,13 @@ Parameters:
433436
**`respond_file(self, file_name, headers=None)`**
434437

435438
- Respond with file content, streaming asynchronously to minimize memory usage
439+
- **⚠️ Security:** this method does **not** restrict file access to any base
440+
directory and does **not** sanitize `file_name`. If you build `file_name`
441+
from request data (e.g. `client.path`), an attacker can use `..` to escape
442+
your web root (path traversal) and read arbitrary files. Always validate the
443+
path yourself first: reject any segment equal to `..`, then join onto a fixed
444+
base directory and verify the result stays inside it. Note `client.path` is
445+
already percent-decoded, so `%2e%2e` and `..` look identical to your check.
436446

437447
**`response_multipart(self, headers=None)`**
438448

tests/test_dos_hardening.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
#!/usr/bin/env python3
2+
"""DoS hardening regression tests.
3+
4+
Covers crashes that a single malicious request could trigger before the
5+
hardening fixes:
6+
7+
1. Deeply nested JSON body -> RecursionError. json.loads() raises
8+
RecursionError (a RuntimeError subclass), which is neither ValueError
9+
nor ClientError, so it used to propagate out of wait() and crash the
10+
server loop. Now caught and turned into 400.
11+
2. WebSocket auto-pong when the send buffer cap is hit -> OSError. The
12+
automatic pong reply lives deep inside _ws_process_buffer(); an OSError
13+
from the send-buffer cap (slow/dead consumer, ping flood) used to
14+
escape the event loop. Now it closes the connection instead.
15+
3. Custom/unknown response status code -> KeyError in the status line.
16+
Now tolerated via STATUS_CODES.get().
17+
"""
18+
import errno
19+
import socket
20+
import threading
21+
import time
22+
import unittest
23+
24+
from uhttp import server as uhttp_server
25+
from uhttp.server import (
26+
HttpConnection, EVENT_WS_CLOSE, EVENT_WS_PING, WS_OPCODE_PING,
27+
)
28+
from tests.test_websocket import build_masked_frame
29+
30+
31+
class TestDeepJsonNoCrash(unittest.TestCase):
32+
"""A deeply nested JSON body must yield 400, not crash the loop."""
33+
34+
PORT = 9958
35+
crash = None
36+
37+
@classmethod
38+
def setUpClass(cls):
39+
cls.server = uhttp_server.HttpServer(port=cls.PORT)
40+
cls._stop = False
41+
42+
def run():
43+
while not cls._stop and cls.server:
44+
try:
45+
client = cls.server.wait(timeout=0.1)
46+
except Exception as err: # pylint: disable=broad-except
47+
# An unhandled exception here means the malicious
48+
# request escaped request processing and killed the
49+
# loop — exactly the bug under test.
50+
cls.crash = repr(err)
51+
break
52+
if client:
53+
client.respond({'ok': True})
54+
55+
cls._thread = threading.Thread(target=run, daemon=True)
56+
cls._thread.start()
57+
time.sleep(0.3)
58+
59+
@classmethod
60+
def tearDownClass(cls):
61+
cls._stop = True
62+
if cls.server:
63+
cls.server.close()
64+
cls._thread.join(timeout=2)
65+
66+
def _request(self, body, content_type=b'application/json'):
67+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
68+
sock.connect(('localhost', self.PORT))
69+
sock.sendall(
70+
b"POST /x HTTP/1.1\r\n"
71+
b"Host: localhost\r\n"
72+
b"Content-Type: " + content_type + b"\r\n"
73+
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
74+
b"Connection: close\r\n"
75+
b"\r\n" + body)
76+
sock.settimeout(3.0)
77+
data = b""
78+
while True:
79+
try:
80+
chunk = sock.recv(4096)
81+
except socket.timeout:
82+
break
83+
if not chunk:
84+
break
85+
data += chunk
86+
sock.close()
87+
return data
88+
89+
def test_deep_json_returns_400_and_survives(self):
90+
"""~100k-deep JSON array: server answers 400 and stays alive."""
91+
depth = 100000
92+
body = b'[' * depth + b']' * depth # valid but pathologically deep
93+
response = self._request(body)
94+
self.assertIn(b"400", response, "deep JSON should be rejected as 400")
95+
self.assertIsNone(
96+
self.crash, f"server loop crashed: {self.crash}")
97+
98+
# Prove the loop is still serving requests after the attack.
99+
good = self._request(b'{"a":1}')
100+
self.assertIn(b"200", good, "server stopped serving after deep JSON")
101+
self.assertIsNone(self.crash, f"server loop crashed: {self.crash}")
102+
103+
104+
class TestCustomStatusCode(unittest.TestCase):
105+
"""respond() with a status not in STATUS_CODES must not raise."""
106+
107+
PORT = 9959
108+
109+
def test_unknown_status_code(self):
110+
server = uhttp_server.HttpServer(port=self.PORT)
111+
stop = {'v': False}
112+
loop_error = {'v': None}
113+
114+
def run():
115+
while not stop['v'] and server._socket is not None:
116+
try:
117+
client = server.wait(timeout=0.1)
118+
except Exception as err: # pylint: disable=broad-except
119+
loop_error['v'] = repr(err)
120+
break
121+
if client:
122+
client.respond(data="custom", status=250)
123+
124+
thread = threading.Thread(target=run, daemon=True)
125+
thread.start()
126+
time.sleep(0.3)
127+
try:
128+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
129+
sock.connect(('localhost', self.PORT))
130+
sock.sendall(
131+
b"GET / HTTP/1.1\r\nHost: localhost\r\n"
132+
b"Connection: close\r\n\r\n")
133+
sock.settimeout(2.0)
134+
data = b""
135+
while True:
136+
chunk = sock.recv(4096)
137+
if not chunk:
138+
break
139+
data += chunk
140+
sock.close()
141+
# Status line present with the custom code, no KeyError crash.
142+
self.assertIn(b"250", data)
143+
self.assertTrue(data.startswith(b"HTTP/1.1 250"))
144+
self.assertIsNone(loop_error['v'], loop_error['v'])
145+
finally:
146+
stop['v'] = True
147+
server.close()
148+
thread.join(timeout=2)
149+
150+
151+
class _StalledSocket:
152+
"""Socket whose send never drains (EAGAIN), to force the send-buffer
153+
cap to trip deterministically without any real network."""
154+
155+
def send(self, _data):
156+
raise OSError(errno.EAGAIN, "stalled")
157+
158+
def recv(self, _n):
159+
raise OSError(errno.EAGAIN, "no data")
160+
161+
def close(self):
162+
pass
163+
164+
165+
class _FakeServer:
166+
"""Minimal server stub for constructing a bare HttpConnection."""
167+
168+
_trusted_proxies = None
169+
event_mode = True
170+
is_secure = False
171+
172+
def remove_connection(self, connection):
173+
pass
174+
175+
176+
class TestWsPingFloodNoCrash(unittest.TestCase):
177+
"""Auto-pong hitting the send-buffer cap closes instead of crashing."""
178+
179+
def test_ping_flood_send_cap_does_not_raise(self):
180+
conn = HttpConnection(
181+
_FakeServer(), _StalledSocket(), ('1.2.3.4', 1234),
182+
max_send_buffer_size=256)
183+
conn._ws_mode = True
184+
185+
# Flood masked PING frames. Each auto-pong (~102 B) accumulates in
186+
# the (never-draining) send buffer; after a couple it exceeds the
187+
# 256 B cap and _send() raises OSError from inside the auto-pong.
188+
ping = build_masked_frame(WS_OPCODE_PING, b'x' * 100)
189+
conn._buffer.extend(ping * 20)
190+
191+
events = []
192+
for _ in range(50):
193+
try:
194+
ready = conn._ws_process_buffer()
195+
except OSError:
196+
self.fail(
197+
"auto-pong raised OSError — would crash the event loop")
198+
if not ready:
199+
break
200+
events.append(conn._event)
201+
if conn._event == EVENT_WS_CLOSE:
202+
break
203+
204+
self.assertIn(EVENT_WS_PING, events, "expected some pings processed")
205+
self.assertIn(
206+
EVENT_WS_CLOSE, events,
207+
"send-cap overflow should close the WS, not crash")
208+
self.assertFalse(conn._ws_mode, "connection should leave WS mode")
209+
210+
211+
if __name__ == '__main__':
212+
unittest.main()

uhttp/server.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
MB = 2 ** 20
1717
GB = 2 ** 30
1818

19-
LISTEN_SOCKETS = 2
20-
MAX_WAITING_CLIENTS = 5
19+
LISTEN_SOCKETS = 8
20+
MAX_WAITING_CLIENTS = 32
2121
MAX_HEADERS_LENGTH = 4 * KB
2222
MAX_CONTENT_LENGTH = 512 * KB
2323
FILE_CHUNK_SIZE = 4 * KB # bytes - chunk size for streaming file responses
@@ -436,17 +436,31 @@ def _ws_process_buffer(self):
436436
# Control frame
437437
if self._ws_frame_opcode >= 0x8:
438438
if self._ws_frame_opcode == WS_OPCODE_PING:
439-
self._ws_do_send(_ws_build_frame(
440-
WS_OPCODE_PONG,
441-
bytes(self._ws_control_buffer)))
439+
# Auto-pong may raise OSError if the send buffer cap is
440+
# hit (slow/dead consumer or ping flood). Treat as a
441+
# close rather than letting it crash the event loop.
442+
try:
443+
self._ws_do_send(_ws_build_frame(
444+
WS_OPCODE_PONG,
445+
bytes(self._ws_control_buffer)))
446+
except OSError:
447+
self._ws_message = None
448+
self._event = EVENT_WS_CLOSE
449+
self._ws_control_buffer = bytearray()
450+
self._ws_on_close()
451+
return True
442452
self._ws_message = bytes(self._ws_control_buffer)
443453
self._event = EVENT_WS_PING
444454
self._ws_control_buffer = bytearray()
445455
return True
446456
if self._ws_frame_opcode == WS_OPCODE_CLOSE:
447-
self._ws_do_send(_ws_build_frame(
448-
WS_OPCODE_CLOSE,
449-
bytes(self._ws_control_buffer)))
457+
# Closing anyway: swallow a send buffer overflow.
458+
try:
459+
self._ws_do_send(_ws_build_frame(
460+
WS_OPCODE_CLOSE,
461+
bytes(self._ws_control_buffer)))
462+
except OSError:
463+
pass
450464
self._ws_message = (
451465
bytes(self._ws_control_buffer)
452466
if self._ws_control_buffer else None)
@@ -1068,7 +1082,11 @@ def _process_data(self):
10681082
elif CONTENT_TYPE_JSON in content_type_parts:
10691083
try:
10701084
self._data = _json.loads(self._buffer)
1071-
except ValueError as err:
1085+
except (ValueError, RuntimeError) as err:
1086+
# ValueError: malformed JSON. RuntimeError: deeply nested
1087+
# JSON exhausts the recursion limit (RecursionError is a
1088+
# subclass of RuntimeError on both CPython and MicroPython).
1089+
# Without this an attacker crashes the loop with a tiny body.
10721090
raise HttpErrorWithResponse(
10731091
400, "Invalid JSON body") from err
10741092
else:
@@ -1470,7 +1488,8 @@ def _build_response_header(self, status=200, headers=None, cookies=None):
14701488
Connection header is added automatically based on keep-alive decision if not explicitly set.
14711489
To force connection close, set headers['connection'] = 'close'.
14721490
"""
1473-
parts = [f'{PROTOCOLS[-1]} {status} {STATUS_CODES[status]}']
1491+
# .get() tolerates custom/unknown status codes without KeyError
1492+
parts = [f'{PROTOCOLS[-1]} {status} {STATUS_CODES.get(status, "")}']
14741493

14751494
if headers:
14761495
for key, val in headers.items():

0 commit comments

Comments
 (0)