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