@@ -269,32 +269,76 @@ def test_content_length_transfer_encoding(parser: Any) -> None:
269269 "hdr" ,
270270 (
271271 "Content-Length" ,
272+ "Host" ,
273+ "Transfer-Encoding" ,
274+ ),
275+ )
276+ def test_duplicate_singleton_header_rejected (
277+ parser : HttpRequestParser , hdr : str
278+ ) -> None :
279+ val1 , val2 = ("1" , "2" ) if hdr == "Content-Length" else ("value1" , "value2" )
280+ text = (
281+ f"GET /test HTTP/1.1\r \n "
282+ f"Host: example.com\r \n "
283+ f"{ hdr } : { val1 } \r \n "
284+ f"{ hdr } : { val2 } \r \n "
285+ "\r \n "
286+ ).encode ()
287+ with pytest .raises (http_exceptions .BadHttpMessage , match = "Duplicate" ):
288+ parser .feed_data (text )
289+
290+
291+ @pytest .mark .parametrize (
292+ "hdr" ,
293+ (
272294 "Content-Location" ,
273295 "Content-Range" ,
274296 "Content-Type" ,
275297 "ETag" ,
276- "Host" ,
277298 "Max-Forwards" ,
278299 "Server" ,
279- "Transfer-Encoding" ,
280300 "User-Agent" ,
281301 ),
282302)
283- def test_duplicate_singleton_header_rejected (
303+ def test_duplicate_non_security_singleton_header_rejected_strict (
284304 parser : HttpRequestParser , hdr : str
285305) -> None :
286- val1 , val2 = ( "1" , "2" ) if hdr == "Content-Length" else ( "value1" , "value2" )
306+ """Non-security singletons are rejected in strict mode (requests)."""
287307 text = (
288308 f"GET /test HTTP/1.1\r \n "
289309 f"Host: example.com\r \n "
290- f"{ hdr } : { val1 } \r \n "
291- f"{ hdr } : { val2 } \r \n "
292- f "\r \n "
310+ f"{ hdr } : value1 \r \n "
311+ f"{ hdr } : value2 \r \n "
312+ "\r \n "
293313 ).encode ()
294314 with pytest .raises (http_exceptions .BadHttpMessage , match = "Duplicate" ):
295315 parser .feed_data (text )
296316
297317
318+ @pytest .mark .parametrize (
319+ "hdr" ,
320+ (
321+ # Content-Length is excluded because llhttp rejects duplicates
322+ # at the C level before our singleton check runs.
323+ "Content-Location" ,
324+ "Content-Range" ,
325+ "Content-Type" ,
326+ "ETag" ,
327+ "Max-Forwards" ,
328+ "Server" ,
329+ "Transfer-Encoding" ,
330+ "User-Agent" ,
331+ ),
332+ )
333+ def test_duplicate_singleton_header_accepted_in_lax_mode (
334+ response : HttpResponseParser , hdr : str
335+ ) -> None :
336+ """All singleton duplicates are accepted in lax mode (response parser default)."""
337+ text = (f"HTTP/1.1 200 OK\r \n { hdr } : value1\r \n { hdr } : value2\r \n \r \n " ).encode ()
338+ messages , upgrade , tail = response .feed_data (text )
339+ assert len (messages ) == 1
340+
341+
298342def test_duplicate_host_header_rejected (parser : HttpRequestParser ) -> None :
299343 text = (
300344 b"GET /admin HTTP/1.1\r \n "
@@ -306,6 +350,45 @@ def test_duplicate_host_header_rejected(parser: HttpRequestParser) -> None:
306350 parser .feed_data (text )
307351
308352
353+ @pytest .mark .parametrize (
354+ ("hdr1" , "hdr2" ),
355+ (
356+ ("content-length" , "Content-Length" ),
357+ ("Content-Length" , "content-length" ),
358+ ("transfer-encoding" , "Transfer-Encoding" ),
359+ ("Transfer-Encoding" , "transfer-encoding" ),
360+ ),
361+ )
362+ def test_duplicate_singleton_header_different_casing_rejected (
363+ parser : HttpRequestParser , hdr1 : str , hdr2 : str
364+ ) -> None :
365+ """Singleton check must be case-insensitive per RFC 9110."""
366+ val1 , val2 = ("1" , "2" ) if "content-length" in hdr1 .lower () else ("v1" , "v2" )
367+ text = (
368+ f"GET /test HTTP/1.1\r \n "
369+ f"Host: example.com\r \n "
370+ f"{ hdr1 } : { val1 } \r \n "
371+ f"{ hdr2 } : { val2 } \r \n "
372+ "\r \n "
373+ ).encode ()
374+ with pytest .raises (http_exceptions .BadHttpMessage , match = "Duplicate" ):
375+ parser .feed_data (text )
376+
377+
378+ def test_duplicate_host_header_different_casing_rejected (
379+ parser : HttpRequestParser ,
380+ ) -> None :
381+ """Duplicate Host with different casing must also be rejected."""
382+ text = (
383+ b"GET /test HTTP/1.1\r \n "
384+ b"host: evil.example\r \n "
385+ b"Host: good.example\r \n "
386+ b"\r \n "
387+ )
388+ with pytest .raises (http_exceptions .BadHttpMessage , match = "Duplicate" ):
389+ parser .feed_data (text )
390+
391+
309392def test_bad_chunked (parser : HttpRequestParser ) -> None :
310393 """Test that invalid chunked encoding doesn't allow content-length to be used."""
311394 text = (
0 commit comments