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 connection_hdr = None;
|
||||||
let mut chunked = false;
|
let mut chunked = false;
|
||||||
let mut expect_100 = false;
|
let mut expect_100 = false;
|
||||||
|
let mut host_count = 0usize;
|
||||||
|
let mut host_ok = true;
|
||||||
|
|
||||||
for h in req.headers.iter() {
|
for h in req.headers.iter() {
|
||||||
let name_lower = h.name.to_ascii_lowercase();
|
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" if value.eq_ignore_ascii_case("100-continue") => {
|
||||||
expect_100 = true;
|
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());
|
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 {
|
if chunked {
|
||||||
// Transfer-Encoding is an HTTP/1.1 mechanism; a 1.0 request
|
// Transfer-Encoding is an HTTP/1.1 mechanism; a 1.0 request
|
||||||
// carrying it is malformed. And a request carrying BOTH a
|
// 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
|
// 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]
|
#[test]
|
||||||
fn serialise_basic_200() {
|
fn serialise_basic_200() {
|
||||||
let conn = Conn::new().put_status(200).put_body("hi");
|
let conn = Conn::new().put_status(200).put_body("hi");
|
||||||
|
|||||||
Reference in New Issue
Block a user