Skip to content

Commit 9d91e3d

Browse files
authored
Merge pull request #190 from Integration-Automation/dev
Drain rejected webhook bodies before closing to avoid TCP RST on Windows
2 parents fc38582 + 8209499 commit 9d91e3d

1 file changed

Lines changed: 34 additions & 4 deletions

File tree

je_auto_control/utils/triggers/webhook_server.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@
4646

4747
_DEFAULT_BIND = "127.0.0.1"
4848
_MAX_BODY_BYTES = 1 << 20 # 1 MiB cap
49+
# Cap how much we'll drain from a rejected request so a hostile client
50+
# can't make us spin reading a multi-GiB body. 4× the body cap covers
51+
# typical "client sent slightly too much" cases; beyond that we close
52+
# and accept the TCP RST.
53+
_DRAIN_CAP_MULTIPLE = 4
54+
_DRAIN_CHUNK_BYTES = 64 * 1024
4955

5056

5157
@dataclass
@@ -111,15 +117,34 @@ def _read_body(self) -> str:
111117
if length <= 0:
112118
return ""
113119
if length > _MAX_BODY_BYTES:
114-
self.send_error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
115-
"body too large")
120+
self._reject_with_drain(
121+
HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "body too large", length,
122+
)
116123
return ""
117124
raw = self.rfile.read(length)
118125
try:
119126
return raw.decode("utf-8")
120127
except UnicodeDecodeError:
121128
return raw.decode("latin-1", errors="replace")
122129

130+
def _reject_with_drain(self, status: HTTPStatus, message: str,
131+
length: int) -> None:
132+
"""Send ``status`` then discard the request body before closing.
133+
134+
Without the drain Windows TCP sends RST (not FIN) when the
135+
socket closes with unread bytes in the receive buffer; the
136+
client surfaces that as WinError 10053 *before* it can read
137+
the response, masking the 4xx status.
138+
"""
139+
self.send_error(status, message)
140+
cap = min(int(length), _MAX_BODY_BYTES * _DRAIN_CAP_MULTIPLE)
141+
remaining = cap
142+
while remaining > 0:
143+
chunk = self.rfile.read(min(remaining, _DRAIN_CHUNK_BYTES))
144+
if not chunk:
145+
break
146+
remaining -= len(chunk)
147+
123148
def _collect_headers(self) -> Dict[str, str]:
124149
return {key.lower(): value for key, value in self.headers.items()}
125150

@@ -134,12 +159,17 @@ def _send_json(self, status: HTTPStatus, payload: Dict[str, Any]) -> None:
134159
def _dispatch(self, method: str) -> None:
135160
registry: WebhookTriggerServer = self.server.webhook_owner # type: ignore[attr-defined]
136161
parsed = urlparse(self.path)
162+
declared_length = int(self.headers.get("Content-Length") or 0)
137163
trigger = registry.match(parsed.path, method)
138164
if trigger is None:
139-
self.send_error(HTTPStatus.NOT_FOUND, "no webhook bound")
165+
self._reject_with_drain(
166+
HTTPStatus.NOT_FOUND, "no webhook bound", declared_length,
167+
)
140168
return
141169
if not registry.authorize(trigger, self.headers.get("Authorization")):
142-
self.send_error(HTTPStatus.UNAUTHORIZED, "bad token")
170+
self._reject_with_drain(
171+
HTTPStatus.UNAUTHORIZED, "bad token", declared_length,
172+
)
143173
return
144174
body = self._read_body()
145175
if body == "" and int(self.headers.get("Content-Length") or 0) > 0:

0 commit comments

Comments
 (0)