@@ -281,32 +281,76 @@ def test_content_length_transfer_encoding(parser: HttpRequestParser) -> None:
281281 "hdr" ,
282282 (
283283 "Content-Length" ,
284+ "Host" ,
285+ "Transfer-Encoding" ,
286+ ),
287+ )
288+ def test_duplicate_singleton_header_rejected (
289+ parser : HttpRequestParser , hdr : str
290+ ) -> None :
291+ val1 , val2 = ("1" , "2" ) if hdr == "Content-Length" else ("value1" , "value2" )
292+ text = (
293+ f"GET /test HTTP/1.1\r \n "
294+ f"Host: example.com\r \n "
295+ f"{ hdr } : { val1 } \r \n "
296+ f"{ hdr } : { val2 } \r \n "
297+ "\r \n "
298+ ).encode ()
299+ with pytest .raises (http_exceptions .BadHttpMessage , match = "Duplicate" ):
300+ parser .feed_data (text )
301+
302+
303+ @pytest .mark .parametrize (
304+ "hdr" ,
305+ (
284306 "Content-Location" ,
285307 "Content-Range" ,
286308 "Content-Type" ,
287309 "ETag" ,
288- "Host" ,
289310 "Max-Forwards" ,
290311 "Server" ,
291- "Transfer-Encoding" ,
292312 "User-Agent" ,
293313 ),
294314)
295- def test_duplicate_singleton_header_rejected (
315+ def test_duplicate_non_security_singleton_header_rejected_strict (
296316 parser : HttpRequestParser , hdr : str
297317) -> None :
298- val1 , val2 = ( "1" , "2" ) if hdr == "Content-Length" else ( "value1" , "value2" )
318+ """Non-security singletons are rejected in strict mode (requests)."""
299319 text = (
300320 f"GET /test HTTP/1.1\r \n "
301321 f"Host: example.com\r \n "
302- f"{ hdr } : { val1 } \r \n "
303- f"{ hdr } : { val2 } \r \n "
304- f "\r \n "
322+ f"{ hdr } : value1 \r \n "
323+ f"{ hdr } : value2 \r \n "
324+ "\r \n "
305325 ).encode ()
306326 with pytest .raises (http_exceptions .BadHttpMessage , match = "Duplicate" ):
307327 parser .feed_data (text )
308328
309329
330+ @pytest .mark .parametrize (
331+ "hdr" ,
332+ (
333+ # Content-Length is excluded because llhttp rejects duplicates
334+ # at the C level before our singleton check runs.
335+ "Content-Location" ,
336+ "Content-Range" ,
337+ "Content-Type" ,
338+ "ETag" ,
339+ "Max-Forwards" ,
340+ "Server" ,
341+ "Transfer-Encoding" ,
342+ "User-Agent" ,
343+ ),
344+ )
345+ def test_duplicate_singleton_header_accepted_in_lax_mode (
346+ response : HttpResponseParser , hdr : str
347+ ) -> None :
348+ """All singleton duplicates are accepted in lax mode (response parser default)."""
349+ text = (f"HTTP/1.1 200 OK\r \n { hdr } : value1\r \n { hdr } : value2\r \n \r \n " ).encode ()
350+ messages , upgrade , tail = response .feed_data (text )
351+ assert len (messages ) == 1
352+
353+
310354def test_duplicate_host_header_rejected (parser : HttpRequestParser ) -> None :
311355 text = (
312356 b"GET /admin HTTP/1.1\r \n "
@@ -318,6 +362,45 @@ def test_duplicate_host_header_rejected(parser: HttpRequestParser) -> None:
318362 parser .feed_data (text )
319363
320364
365+ @pytest .mark .parametrize (
366+ ("hdr1" , "hdr2" ),
367+ (
368+ ("content-length" , "Content-Length" ),
369+ ("Content-Length" , "content-length" ),
370+ ("transfer-encoding" , "Transfer-Encoding" ),
371+ ("Transfer-Encoding" , "transfer-encoding" ),
372+ ),
373+ )
374+ def test_duplicate_singleton_header_different_casing_rejected (
375+ parser : HttpRequestParser , hdr1 : str , hdr2 : str
376+ ) -> None :
377+ """Singleton check must be case-insensitive per RFC 9110."""
378+ val1 , val2 = ("1" , "2" ) if "content-length" in hdr1 .lower () else ("v1" , "v2" )
379+ text = (
380+ f"GET /test HTTP/1.1\r \n "
381+ f"Host: example.com\r \n "
382+ f"{ hdr1 } : { val1 } \r \n "
383+ f"{ hdr2 } : { val2 } \r \n "
384+ "\r \n "
385+ ).encode ()
386+ with pytest .raises (http_exceptions .BadHttpMessage , match = "Duplicate" ):
387+ parser .feed_data (text )
388+
389+
390+ def test_duplicate_host_header_different_casing_rejected (
391+ parser : HttpRequestParser ,
392+ ) -> None :
393+ """Duplicate Host with different casing must also be rejected."""
394+ text = (
395+ b"GET /test HTTP/1.1\r \n "
396+ b"host: evil.example\r \n "
397+ b"Host: good.example\r \n "
398+ b"\r \n "
399+ )
400+ with pytest .raises (http_exceptions .BadHttpMessage , match = "Duplicate" ):
401+ parser .feed_data (text )
402+
403+
321404def test_bad_chunked (parser : HttpRequestParser ) -> None :
322405 """Test that invalid chunked encoding doesn't allow content-length to be used."""
323406 text = (
0 commit comments