feat(parser): reject missing/duplicate/invalid Host (RFC 9112 §3.2)
parse_head never inspected Host, so a missing (HTTP/1.1), duplicate, or syntactically invalid Host all passed through to the router (h1spec #8/#9/ #10 -> 404 instead of 400). Add per-header validity (RFC 3986 host[:port] charset via valid_host) plus a post-loop presence/uniqueness check: 1.1 MUST carry exactly one valid Host; 1.0 may omit it but a duplicate/invalid one is still 400. Unit matrix mirrors the three h1spec cases with reg-name/ port/IPv6-literal positive controls.
This commit is contained in:
@@ -100,6 +100,8 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
||||
let mut connection_hdr = None;
|
||||
let mut chunked = false;
|
||||
let mut expect_100 = false;
|
||||
let mut host_count = 0usize;
|
||||
let mut host_ok = true;
|
||||
|
||||
for h in req.headers.iter() {
|
||||
let name_lower = h.name.to_ascii_lowercase();
|
||||
@@ -126,11 +128,29 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
||||
"expect" if value.eq_ignore_ascii_case("100-continue") => {
|
||||
expect_100 = true;
|
||||
}
|
||||
"host" => {
|
||||
// Presence/uniqueness enforced post-loop; validity here.
|
||||
host_count += 1;
|
||||
if !valid_host(value) {
|
||||
host_ok = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
headers.append(&name_lower, value.to_string());
|
||||
}
|
||||
|
||||
// Host (RFC 9112 §3.2): an HTTP/1.1 request MUST carry exactly one valid
|
||||
// Host; a missing, duplicate, or malformed Host is a 400. HTTP/1.0 may
|
||||
// omit Host, but a duplicate or invalid one is still rejected on any
|
||||
// version (ambiguous / malformed authority).
|
||||
if host_count > 1 || !host_ok {
|
||||
return Err(ParseError::Malformed);
|
||||
}
|
||||
if version == HttpVersion::Http11 && host_count == 0 {
|
||||
return Err(ParseError::Malformed);
|
||||
}
|
||||
|
||||
if chunked {
|
||||
// Transfer-Encoding is an HTTP/1.1 mechanism; a 1.0 request
|
||||
// carrying it is malformed. And a request carrying BOTH a
|
||||
@@ -163,6 +183,32 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
||||
})
|
||||
}
|
||||
|
||||
/// Conservative RFC 3986 check for a `Host` field-value: non-empty and every
|
||||
/// byte drawn from the `host[:port]` productions (reg-name / IP-literal
|
||||
/// brackets / port colon). This is charset-level, not full structural
|
||||
/// validation (no bracket matching, no pct-encoding well-formedness) — enough
|
||||
/// to reject the smuggling-relevant garbage (whitespace, controls, `@`, `/`,
|
||||
/// `?`, `#`) while accepting every legitimate host. Tighter structural checks
|
||||
/// (bracketed IPv6, single port colon) are a possible follow-up.
|
||||
fn valid_host(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.bytes().all(|b| {
|
||||
b.is_ascii_alphanumeric()
|
||||
|| matches!(
|
||||
b,
|
||||
// unreserved punctuation
|
||||
b'-' | b'.' | b'_' | b'~'
|
||||
// sub-delims
|
||||
| b'!' | b'$' | b'&' | b'\'' | b'(' | b')'
|
||||
| b'*' | b'+' | b',' | b';' | b'='
|
||||
// pct-encoded lead
|
||||
| b'%'
|
||||
// IP-literal brackets + port separator
|
||||
| b'[' | b']' | b':'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conn assembly
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -403,6 +449,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Host (RFC 9112 §3.2) -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_missing_host_http11_is_malformed() {
|
||||
let req = b"GET / HTTP/1.1\r\n\r\n";
|
||||
match parse_head(req, 64) {
|
||||
Err(ParseError::Malformed) => {}
|
||||
_ => panic!("expected Malformed for missing Host on 1.1"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_missing_host_http10_is_allowed() {
|
||||
// Host is optional in HTTP/1.0.
|
||||
let req = b"GET / HTTP/1.0\r\n\r\n";
|
||||
assert!(parse_head(req, 64).is_ok(), "1.0 may omit Host");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_duplicate_host_is_malformed() {
|
||||
let req = b"GET / HTTP/1.1\r\nHost: a\r\nHost: b\r\n\r\n";
|
||||
match parse_head(req, 64) {
|
||||
Err(ParseError::Malformed) => {}
|
||||
_ => panic!("expected Malformed for duplicate Host"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_host_value_is_malformed() {
|
||||
// Embedded whitespace — invalid in an RFC 3986 authority.
|
||||
let req = b"GET / HTTP/1.1\r\nHost: bad host\r\n\r\n";
|
||||
match parse_head(req, 64) {
|
||||
Err(ParseError::Malformed) => {}
|
||||
_ => panic!("expected Malformed for invalid Host"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_valid_hosts_accepted() {
|
||||
// Positive controls: reg-name, reg-name:port, and IPv6-literal:port.
|
||||
for req in [
|
||||
b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n".as_slice(),
|
||||
b"GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n".as_slice(),
|
||||
b"GET / HTTP/1.1\r\nHost: [::1]:443\r\n\r\n".as_slice(),
|
||||
] {
|
||||
assert!(parse_head(req, 64).is_ok(), "should accept a valid Host");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialise_basic_200() {
|
||||
let conn = Conn::new().put_status(200).put_body("hi");
|
||||
|
||||
Reference in New Issue
Block a user