99 parse_options_header ,
1010 parse_query_string ,
1111)
12- from proper .core .request .headers import parse_request_id
12+ from proper .core .request .headers import parse_host , parse_request_id
1313from proper .errors import (
1414 InvalidHeader ,
1515 MultipartError ,
@@ -412,23 +412,6 @@ def test_protocol_from_x_forwarded_proto():
412412 assert req .is_secure is True
413413
414414
415- def test_server_none_fallback_to_host_header ():
416- scope = make_test_scope ("/" , headers = [("host" , "example.com:8080" )])
417- scope ["server" ] = None
418- req = Request (scope )
419- assert req .host == "example.com"
420- assert req .port == 8080
421-
422-
423- def test_server_none_no_host_header ():
424- scope = make_test_scope ("/" )
425- scope ["server" ] = None
426- # Remove the host header
427- scope ["headers" ] = []
428- req = Request (scope )
429- assert req .host == ""
430-
431-
432415def test_headers_get_bytes_header ():
433416 scope = make_test_scope ("/" )
434417 scope ["headers" ].append ((b"x-test" , b"\xe4 \xb8 \xad " ))
@@ -563,7 +546,24 @@ def test_cookies_empty_pair_skipped():
563546 assert req .cookies ["b" ] == "2"
564547
565548
566- # --- host parsing via header fallback ---
549+ # --- host parsing ---
550+
551+
552+ def test_server_none_fallback_to_host_header ():
553+ scope = make_test_scope ("/" , headers = [("host" , "example.com:8080" )])
554+ scope ["server" ] = None
555+ req = Request (scope )
556+ assert req .host == "example.com"
557+ assert req .port == 8080
558+
559+
560+ def test_allow_no_host ():
561+ scope = make_test_scope ("/" )
562+ scope ["server" ] = None
563+ # Remove the host header
564+ scope ["headers" ] = []
565+ req = Request (scope )
566+ assert req .host == ""
567567
568568
569569def test_host_from_header_ipv6 ():
@@ -583,11 +583,11 @@ def test_host_from_header_ipv6_with_port():
583583
584584
585585def test_host_from_header_non_decimal_port ():
586+ # A non-numeric port is invalid per RFC 9112 §3.2 / RFC 3986 §3.2.3.
586587 scope = make_test_scope ("/" , headers = [("host" , "example.com:abc" )])
587588 scope ["server" ] = None
588- req = Request (scope )
589- assert req .host == "example.com"
590- assert req .port == 80 # non-decimal port → 0, then default_port
589+ with pytest .raises (InvalidHeader , match = "Host" ):
590+ Request (scope )
591591
592592
593593def test_host_from_header_simple ():
@@ -598,6 +598,46 @@ def test_host_from_header_simple():
598598 assert req .port == 8080
599599
600600
601+ @pytest .mark .parametrize ("value" , [
602+ "example.com" ,
603+ "example.com:8080" ,
604+ "example.com:" , # empty port is allowed by RFC 3986 (port = *DIGIT)
605+ "sub.example.com" ,
606+ "localhost" ,
607+ "something.localhost:1234" ,
608+ "127.0.0.1" ,
609+ "127.0.0.1:5000" ,
610+ "[::1]" ,
611+ "[::1]:9090" ,
612+ "[2001:db8::1]:443" ,
613+ "my-host_1.example~test" , # unreserved chars
614+ "xn--80ak6aa92e.com" , # punycode (IDN)
615+ ])
616+ def test_accepts_valid_host (value ):
617+ host , _ = parse_host (value )
618+ print (value )
619+ assert host is not None
620+
621+
622+ @pytest .mark .parametrize ("value" , [
623+ "example.com/abc?bar=" , # X41-2026-002: path/query injection
624+ "example.com/abc" ,
625+ "example.com?x=1" ,
626+ "example.com#frag" ,
627+ "user@example.com" , # userinfo is not part of a Host header
628+ "example.com:abc" , # non-numeric port
629+ "exa mple.com" , # space
630+ "example.com\n " , # trailing newline (header injection)
631+ "exam\t ple.com" , # control character
632+ "::1" , # unbracketed IPv6
633+ "" , # empty
634+ ])
635+ def test_rejects_invalid_host (value ):
636+ with pytest .raises (InvalidHeader , match = "Host" ):
637+ print (value )
638+ host , _ = parse_host (value )
639+
640+
601641# --- request_id edge cases ---
602642
603643
0 commit comments