From 3348fedb17be8089ba18f2e59fa35b2f11b9c491 Mon Sep 17 00:00:00 2001 From: fewensa Date: Sat, 5 Sep 2026 10:06:36 +0800 Subject: [PATCH] Add bounded User-Agent protocol metadata codeon: version: 1 authority: FWN-324 description: |- Add the shared rttp-protocol User-Agent primitive with bounded RFC 9110 product and comment parsing, singleton field enforcement, iterative comment nesting validation, typed read-only accessors, deterministic serialization, and privacy-safe diagnostics. Export the module, add focused protocol coverage, and document the metadata-only behavior. --- crates/rttp-protocol/README.md | 17 ++ crates/rttp-protocol/src/lib.rs | 1 + crates/rttp-protocol/src/user_agent.rs | 372 +++++++++++++++++++++++ crates/rttp-protocol/tests/user_agent.rs | 158 ++++++++++ 4 files changed, 548 insertions(+) create mode 100644 crates/rttp-protocol/src/user_agent.rs create mode 100644 crates/rttp-protocol/tests/user_agent.rs diff --git a/crates/rttp-protocol/README.md b/crates/rttp-protocol/README.md index fd6e726d..5a64d5fd 100644 --- a/crates/rttp-protocol/README.md +++ b/crates/rttp-protocol/README.md @@ -711,6 +711,23 @@ over-limit protocol lists are rejected. This parser validates declared metadata only; callers own `Connection: Upgrade`, h2c negotiation, socket handoff, and any upgraded protocol bytes. +## User-Agent + +`user_agent` parses exactly one bounded RFC 9110 `User-Agent` request field +value as an ordered sequence whose first member is a product and whose later +members are products or parenthesized comments. The field is limited to 64 KiB, +the member list to 256 entries, and nested comments to 128 levels. Product and +product-version values are RFC 9110 tokens; required RWS separates members and +leading or trailing OWS is accepted. Comment validation is iterative, accepts +RFC quoted-pairs and obs-text, and rejects malformed or unterminated comments, +forbidden controls, empty values, invalid products, and duplicate field lines. + +`UserAgent::members()` exposes read-only product, version, and comment +accessors. `header_value()` preserves accepted product/version and comment +spelling while emitting deterministic single-space member separators. This +primitive reports request metadata only; it does not fingerprint clients, +normalize product policy, synthesize defaults, or select application behavior. + ## Content-Encoding `content_encoding` parses one or more `Content-Encoding` field values into an diff --git a/crates/rttp-protocol/src/lib.rs b/crates/rttp-protocol/src/lib.rs index b9eb30c3..67c28b8a 100644 --- a/crates/rttp-protocol/src/lib.rs +++ b/crates/rttp-protocol/src/lib.rs @@ -132,6 +132,7 @@ pub mod trailer; pub mod transfer_encoding; pub mod upgrade; pub mod upgrade_insecure_requests; +pub mod user_agent; pub mod variant_vary; pub mod vary; pub mod via; diff --git a/crates/rttp-protocol/src/user_agent.rs b/crates/rttp-protocol/src/user_agent.rs new file mode 100644 index 00000000..39cc25fc --- /dev/null +++ b/crates/rttp-protocol/src/user_agent.rs @@ -0,0 +1,372 @@ +//! Bounded, policy-free RFC 9110 `User-Agent` request metadata parsing. +//! +//! This module validates one `User-Agent` field value as an ordered sequence +//! of products and comments. It reports the declared metadata only; callers +//! retain responsibility for fingerprinting, product policy, defaults, and +//! application behavior. + +use std::error::Error; +use std::fmt; + +use crate::http1::{is_quoted_pair_char, is_token_byte}; + +/// Maximum bytes accepted in one `User-Agent` field value. +pub const MAX_USER_AGENT_VALUE_BYTES: usize = 64 * 1024; +/// Maximum product or comment members accepted in a `User-Agent` value. +pub const MAX_USER_AGENT_MEMBERS: usize = 256; +/// Maximum nesting depth accepted in a parenthesized `User-Agent` comment. +pub const MAX_USER_AGENT_COMMENT_DEPTH: usize = 128; + +/// Parsed, bounded RFC 9110 `User-Agent` request metadata. +#[derive(Clone, Eq, PartialEq)] +pub struct UserAgent { + members: Vec, +} + +/// One ordered product or comment member from a `User-Agent` value. +/// +/// A product member has a product token and may have a version token. A +/// comment member has the comment contents without its surrounding +/// parentheses. The fields are private so callers can only obtain validated, +/// read-only metadata through the accessors below. +#[derive(Clone, Eq, PartialEq)] +pub struct UserAgentMember { + product: Option, + version: Option, + comment: Option, +} + +/// An error returned when `User-Agent` metadata is malformed or exceeds a +/// protocol bound. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserAgentParseError { + message: String, +} + +impl UserAgent { + /// Parses one `User-Agent` field value. + pub fn parse(value: impl AsRef) -> Result { + Self::parse_values([value.as_ref()]) + } + + /// Parses the singleton `User-Agent` field. + /// + /// Exactly one field value is accepted. Repeated field lines are rejected + /// rather than combined because `User-Agent` is a singleton field. + pub fn parse_values<'a, I>(values: I) -> Result + where + I: IntoIterator, + { + let value = parse_singleton(values)?; + let mut members = Vec::new(); + parse_field(value, &mut members)?; + Ok(Self { members }) + } + + /// Returns the validated product and comment members in wire order. + pub fn members(&self) -> &[UserAgentMember] { + &self.members + } + + /// Returns the number of product and comment members. + pub fn len(&self) -> usize { + self.members.len() + } + + /// Returns whether this value contains no members. + pub fn is_empty(&self) -> bool { + self.members.is_empty() + } + + /// Serializes the metadata with canonical single-space member separators. + /// + /// Product and version token spelling, as well as comment contents and + /// quoted-pair spelling, are retained from the accepted field value. + pub fn header_value(&self) -> String { + self + .members + .iter() + .map(UserAgentMember::header_value) + .collect::>() + .join(" ") + } +} + +impl fmt::Debug for UserAgent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("UserAgent") + .field("member_count", &self.members.len()) + .finish() + } +} + +impl UserAgentMember { + /// Returns the product token for a product member. + pub fn product(&self) -> Option<&str> { + self.product.as_deref() + } + + /// Returns the product-version token for a product member that has one. + pub fn version(&self) -> Option<&str> { + self.version.as_deref() + } + + /// Returns the comment contents, without surrounding parentheses, for a + /// comment member. + pub fn comment(&self) -> Option<&str> { + self.comment.as_deref() + } + + /// Returns whether this member is a product. + pub fn is_product(&self) -> bool { + self.product.is_some() + } + + /// Returns whether this member is a comment. + pub fn is_comment(&self) -> bool { + self.comment.is_some() + } + + fn from_product(product: String, version: Option) -> Self { + Self { + product: Some(product), + version, + comment: None, + } + } + + fn from_comment(comment: String) -> Self { + Self { + product: None, + version: None, + comment: Some(comment), + } + } + + fn header_value(&self) -> String { + if let Some(product) = &self.product { + let mut value = product.clone(); + if let Some(version) = &self.version { + value.push('/'); + value.push_str(version); + } + value + } else { + let comment = self + .comment + .as_deref() + .expect("UserAgentMember must contain a product or comment"); + format!("({comment})") + } + } +} + +impl fmt::Debug for UserAgentMember { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("UserAgentMember") + .field( + "kind", + &if self.is_product() { + "product" + } else { + "comment" + }, + ) + .field("has_version", &self.version.is_some()) + .finish() + } +} + +impl UserAgentParseError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for UserAgentParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for UserAgentParseError {} + +fn parse_singleton<'a, I>(values: I) -> Result<&'a str, UserAgentParseError> +where + I: IntoIterator, +{ + let mut first = None; + let mut duplicate = false; + + for value in values { + validate_bounded_value(value)?; + if first.is_some() { + duplicate = true; + } else { + first = Some(value); + } + } + + let value = first.ok_or_else(invalid_value)?; + if duplicate { + return Err(UserAgentParseError::new( + "duplicate User-Agent header fields", + )); + } + Ok(value) +} + +fn validate_bounded_value(value: &str) -> Result<(), UserAgentParseError> { + if value.len() > MAX_USER_AGENT_VALUE_BYTES { + return Err(UserAgentParseError::new( + "User-Agent header value is too large", + )); + } + if value + .bytes() + .any(|byte| byte.is_ascii_control() && byte != b'\t') + { + return Err(UserAgentParseError::new("invalid User-Agent control byte")); + } + Ok(()) +} + +fn parse_field(value: &str, members: &mut Vec) -> Result<(), UserAgentParseError> { + let bytes = value.as_bytes(); + let mut position = 0; + skip_ows(bytes, &mut position); + if position == bytes.len() { + return Err(invalid_value()); + } + + let product = parse_product(value, &mut position)?; + members.push(product); + + loop { + let separator = skip_ows(bytes, &mut position); + if position == bytes.len() { + return Ok(()); + } + if separator == 0 { + return Err(invalid_value()); + } + if members.len() >= MAX_USER_AGENT_MEMBERS { + return Err(UserAgentParseError::new("too many User-Agent members")); + } + + let member = if bytes[position] == b'(' { + UserAgentMember::from_comment(parse_comment(value, &mut position)?) + } else { + parse_product(value, &mut position)? + }; + members.push(member); + } +} + +fn parse_product( + value: &str, + position: &mut usize, +) -> Result { + let product = parse_token(value, position).ok_or_else(invalid_product)?; + let version = if value.as_bytes().get(*position) == Some(&b'/') { + *position += 1; + Some( + parse_token(value, position) + .ok_or_else(invalid_product)? + .to_string(), + ) + } else { + None + }; + Ok(UserAgentMember::from_product(product.to_string(), version)) +} + +fn parse_comment(value: &str, position: &mut usize) -> Result { + let bytes = value.as_bytes(); + if bytes.get(*position) != Some(&b'(') { + return Err(invalid_comment()); + } + + *position += 1; + let inner_start = *position; + let mut depth = 1usize; + + while let Some(&byte) = bytes.get(*position) { + match byte { + b'(' => { + if depth >= MAX_USER_AGENT_COMMENT_DEPTH { + return Err(invalid_comment()); + } + depth += 1; + *position += 1; + } + b')' => { + *position += 1; + depth -= 1; + if depth == 0 { + return Ok(value[inner_start..*position - 1].to_string()); + } + } + b'\\' => { + *position += 1; + let Some(&escaped) = bytes.get(*position) else { + return Err(invalid_comment()); + }; + if !is_quoted_pair_char(escaped) { + return Err(invalid_comment()); + } + *position += 1; + } + byte if is_comment_text_byte(byte) => *position += 1, + _ => return Err(invalid_comment()), + } + } + + Err(invalid_comment()) +} + +fn parse_token<'a>(value: &'a str, position: &mut usize) -> Option<&'a str> { + let start = *position; + while value + .as_bytes() + .get(*position) + .is_some_and(|byte| is_token_byte(*byte)) + { + *position += 1; + } + (start != *position).then(|| &value[start..*position]) +} + +fn skip_ows(bytes: &[u8], position: &mut usize) -> usize { + let start = *position; + while bytes + .get(*position) + .is_some_and(|byte| matches!(*byte, b' ' | b'\t')) + { + *position += 1; + } + *position - start +} + +fn is_comment_text_byte(byte: u8) -> bool { + matches!( + byte, + b'\t' | b' ' | 0x21..=0x27 | 0x2a..=0x5b | 0x5d..=0x7e + ) || byte >= 0x80 +} + +fn invalid_value() -> UserAgentParseError { + UserAgentParseError::new("invalid User-Agent value") +} + +fn invalid_product() -> UserAgentParseError { + UserAgentParseError::new("invalid User-Agent product") +} + +fn invalid_comment() -> UserAgentParseError { + UserAgentParseError::new("invalid User-Agent comment") +} diff --git a/crates/rttp-protocol/tests/user_agent.rs b/crates/rttp-protocol/tests/user_agent.rs new file mode 100644 index 00000000..52365e93 --- /dev/null +++ b/crates/rttp-protocol/tests/user_agent.rs @@ -0,0 +1,158 @@ +use rttp_protocol::user_agent::{ + UserAgent, MAX_USER_AGENT_COMMENT_DEPTH, MAX_USER_AGENT_MEMBERS, MAX_USER_AGENT_VALUE_BYTES, +}; + +#[test] +fn user_agent_parses_ordered_products_and_comments() { + let user_agent = UserAgent::parse(" Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) ") + .expect("valid User-Agent should parse"); + + assert_eq!(3, user_agent.len()); + assert!(!user_agent.is_empty()); + assert_eq!(Some("Mozilla"), user_agent.members()[0].product()); + assert_eq!(Some("5.0"), user_agent.members()[0].version()); + assert_eq!(Some("AppleWebKit"), user_agent.members()[1].product()); + assert_eq!(Some("537.36"), user_agent.members()[1].version()); + assert_eq!(Some("KHTML, like Gecko"), user_agent.members()[2].comment()); + assert_eq!(None, user_agent.members()[2].product()); + assert_eq!(None, user_agent.members()[2].version()); + assert_eq!( + "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko)", + user_agent.header_value() + ); +} + +#[test] +fn user_agent_preserves_product_version_and_comment_spelling() { + let user_agent = UserAgent::parse(r#"Acme/01.0 (outer \(literal\) (inner) "quoted")"#) + .expect("valid spelling should parse"); + + assert_eq!(Some("Acme"), user_agent.members()[0].product()); + assert_eq!(Some("01.0"), user_agent.members()[0].version()); + assert_eq!( + Some(r#"outer \(literal\) (inner) "quoted""#), + user_agent.members()[1].comment() + ); + assert_eq!( + r#"Acme/01.0 (outer \(literal\) (inner) "quoted")"#, + user_agent.header_value() + ); + assert_eq!( + user_agent, + UserAgent::parse(user_agent.header_value()).expect("serialized value should round-trip") + ); +} + +#[test] +fn user_agent_accepts_empty_comments_and_required_rws() { + for value in ["product ()", "product\t(comment)", "product (one) next/2"] { + let parsed = UserAgent::parse(value).expect("valid RWS-separated members should parse"); + assert_eq!(value.replace('\t', " "), parsed.header_value()); + } +} + +#[test] +fn user_agent_rejects_malformed_products_comments_and_member_order() { + for value in [ + "", + " ", + "\t", + "(comment) product", + "product(comment)", + "product/", + "/version", + "product//version", + "product/version/extra", + "product;parameter", + "product, other", + "product (unterminated", + "product (bad\\)", + "product (bad\\\r)", + "product (bad\u{7f})", + "product (nested (comment)", + "product )", + ] { + assert!( + UserAgent::parse(value).is_err(), + "{value:?} must be rejected" + ); + } +} + +#[test] +fn user_agent_rejects_duplicate_fields_and_empty_field_sets() { + assert!(UserAgent::parse_values(["client/1", "client/2"]).is_err()); + assert!(UserAgent::parse_values(["client/1", ""]).is_err()); + assert!(UserAgent::parse_values([]).is_err()); +} + +#[test] +fn user_agent_rejects_forbidden_controls_and_accepts_valid_comment_obs_text() { + for value in [ + "client/1\r\nX-Injected: secret", + "client/1\nX: value", + "client/1\0", + "client/1\u{1f}", + "client/1\u{7f}", + "client/1 (bad\\\n)", + ] { + let error = UserAgent::parse(value).expect_err("forbidden control must be rejected"); + assert!(!error.to_string().contains("secret")); + } + + let parsed = UserAgent::parse("client/1 (café)").expect("obs-text in comments is valid"); + assert_eq!(Some("café"), parsed.members()[1].comment()); +} + +#[test] +fn user_agent_enforces_member_and_comment_depth_bounds() { + let at_limit = (0..MAX_USER_AGENT_MEMBERS) + .map(|index| format!("p{index}")) + .collect::>() + .join(" "); + let parsed = UserAgent::parse(&at_limit).expect("member bound should be inclusive"); + assert_eq!(MAX_USER_AGENT_MEMBERS, parsed.len()); + + let too_many = format!("{at_limit} overflow"); + assert!(UserAgent::parse(too_many).is_err()); + + let at_depth = format!( + "client {}{}", + "(".repeat(MAX_USER_AGENT_COMMENT_DEPTH), + ")".repeat(MAX_USER_AGENT_COMMENT_DEPTH) + ); + assert!(UserAgent::parse(&at_depth).is_ok()); + + let too_deep = format!( + "client {}{}", + "(".repeat(MAX_USER_AGENT_COMMENT_DEPTH + 1), + ")".repeat(MAX_USER_AGENT_COMMENT_DEPTH + 1) + ); + assert!(UserAgent::parse(&too_deep).is_err()); +} + +#[test] +fn user_agent_enforces_value_size_and_checks_duplicate_values() { + assert!(UserAgent::parse("p".repeat(MAX_USER_AGENT_VALUE_BYTES + 1)).is_err()); + + let at_limit = format!("p{}", " ".repeat(MAX_USER_AGENT_VALUE_BYTES - 1)); + assert_eq!(MAX_USER_AGENT_VALUE_BYTES, at_limit.len()); + assert!(UserAgent::parse(&at_limit).is_ok()); + + let oversized = "p".repeat(MAX_USER_AGENT_VALUE_BYTES + 1); + assert!(UserAgent::parse_values(["client/1", oversized.as_str()]).is_err()); +} + +#[test] +fn user_agent_debug_and_errors_do_not_echo_field_contents() { + let value = "SensitiveBrowser/9.9 (private fingerprint)"; + let user_agent = UserAgent::parse(value).expect("valid User-Agent should parse"); + let debug = format!("{user_agent:?} {:?}", user_agent.members()[0]); + assert!(!debug.contains("SensitiveBrowser")); + assert!(!debug.contains("private fingerprint")); + + let error = UserAgent::parse("SensitiveBrowser/invalid=version secret") + .expect_err("malformed User-Agent should fail"); + assert!(!error.to_string().contains("SensitiveBrowser")); + assert!(!format!("{error:?}").contains("secret")); +}