diff --git a/crates/rttp-client/src/response/mod.rs b/crates/rttp-client/src/response/mod.rs index 4d6c227c..f524dda4 100644 --- a/crates/rttp-client/src/response/mod.rs +++ b/crates/rttp-client/src/response/mod.rs @@ -248,4 +248,7 @@ pub use rttp_protocol::www_authenticate::{ pub use rttp_protocol::x_content_type_options::{ XContentTypeOptions, XContentTypeOptionsParseError, }; +pub use rttp_protocol::x_dns_prefetch_control::{ + XDnsPrefetchControl, XDnsPrefetchControlParseError, +}; pub use rttp_protocol::x_frame_options::{XFrameOptions, XFrameOptionsParseError}; diff --git a/crates/rttp-client/src/response/response.rs b/crates/rttp-client/src/response/response.rs index f7f7ffe1..18987a3d 100644 --- a/crates/rttp-client/src/response/response.rs +++ b/crates/rttp-client/src/response/response.rs @@ -101,6 +101,7 @@ use rttp_protocol::timing_allow_origin::TimingAllowOrigin; use rttp_protocol::variant_vary::VariantVary; use rttp_protocol::vary::Vary; use rttp_protocol::x_content_type_options::XContentTypeOptions; +use rttp_protocol::x_dns_prefetch_control::XDnsPrefetchControl; use rttp_protocol::x_frame_options::XFrameOptions; const MAX_CACHE_CONTROL_VALUE_BYTES: usize = 64 * 1024; @@ -968,6 +969,18 @@ impl Response { .map_err(|parse_error| error::bad_response(parse_error.to_string())) } + /// Parses bounded `X-DNS-Prefetch-Control` response metadata without + /// applying DNS-prefetch behavior. + pub fn x_dns_prefetch_control(&self) -> error::Result> { + let values = self.header_values("x-dns-prefetch-control"); + if values.is_empty() { + return Ok(None); + } + XDnsPrefetchControl::parse_values(values.into_iter().map(String::as_str)) + .map(Some) + .map_err(|parse_error| error::bad_response(parse_error.to_string())) + } + /// Parses bounded `X-Frame-Options` response metadata without applying clickjacking policy. pub fn x_frame_options(&self) -> error::Result> { let values = self.header_values("x-frame-options"); diff --git a/crates/rttp-client/tests/metadata_facade.rs b/crates/rttp-client/tests/metadata_facade.rs index e9015ae3..d0828171 100644 --- a/crates/rttp-client/tests/metadata_facade.rs +++ b/crates/rttp-client/tests/metadata_facade.rs @@ -36,8 +36,8 @@ use rttp_client::response::{ TransferEncoding, TransferEncodingParseError, Upgrade, UpgradeParseError, VariantVary, VariantVaryParseError, Vary, VaryParseError, Via, ViaParseError, WantContentDigest, WantReprDigest, Warning, WwwAuthenticate, WwwAuthenticateChallenge, WwwAuthenticateParameter, - WwwAuthenticateParseError, XContentTypeOptions, XContentTypeOptionsParseError, XFrameOptions, - XFrameOptionsParseError, + WwwAuthenticateParseError, XContentTypeOptions, XContentTypeOptionsParseError, + XDnsPrefetchControl, XDnsPrefetchControlParseError, XFrameOptions, XFrameOptionsParseError, }; use rttp_client::response::{ ContentDigest, ContentDisposition, ContentDispositionParseError, ContentLocation, @@ -72,6 +72,17 @@ fn client_facade_exports_from_metadata_types() { let _: FromParseError = From::parse("invalid").expect_err("invalid From should fail"); } +#[test] +fn client_facade_exports_x_dns_prefetch_control_metadata_types() { + let on = XDnsPrefetchControl::parse("ON").expect("X-DNS-Prefetch-Control should parse"); + let off = XDnsPrefetchControl::parse(" off ").expect("X-DNS-Prefetch-Control should parse"); + assert_eq!(XDnsPrefetchControl::On, on); + assert_eq!(XDnsPrefetchControl::Off, off); + assert_eq!("on", on.header_value()); + let _: XDnsPrefetchControlParseError = + XDnsPrefetchControl::parse("invalid").expect_err("invalid X-DNS-Prefetch-Control should fail"); +} + #[test] fn client_facade_exports_alt_svc_metadata_types() { let alt_svc = diff --git a/crates/rttp-client/tests/test_response.rs b/crates/rttp-client/tests/test_response.rs index e7ac8d6a..9b308306 100644 --- a/crates/rttp-client/tests/test_response.rs +++ b/crates/rttp-client/tests/test_response.rs @@ -11,7 +11,8 @@ use rttp_client::response::{ ProxyStatusBareItem, ReferrerPolicy, ReferrerPolicyToken, Response, RetryAfter, ScheduleTag, SecWebSocketAccept, SecWebSocketExtensions, SecWebSocketProtocol, SecWebSocketVersion, ServerTiming, ServiceWorkerAllowed, SignatureInput, SpeculationRules, StrictTransportSecurity, - SupportsLoadingMode, Tcn, TcnDirective, Via, Warning, XContentTypeOptions, XFrameOptions, + SupportsLoadingMode, Tcn, TcnDirective, Via, Warning, XContentTypeOptions, XDnsPrefetchControl, + XFrameOptions, }; use rttp_client::types::{Cookie, RoUrl}; use rttp_client::DavClass; @@ -643,6 +644,44 @@ fn strict_transport_security_metadata_is_absent_without_a_header() { .expect("header is absent"); } +#[test] +fn x_dns_prefetch_control_metadata_parses_without_applying_policy() { + for (value, expected) in [ + (" ON ", XDnsPrefetchControl::On), + ("oFf", XDnsPrefetchControl::Off), + ] { + let response = Response::new( + RoUrl::with("https://example.test"), + format!("HTTP/1.1 200 OK\r\nX-DNS-Prefetch-Control: {value}\r\nContent-Length: 0\r\n\r\n") + .into_bytes(), + ) + .expect("response should preserve metadata"); + assert_eq!( + Some(expected), + response + .x_dns_prefetch_control() + .expect("X-DNS-Prefetch-Control should parse") + ); + } + + for value in ["unknown", "on, off", "\"on\""] { + let response = Response::new( + RoUrl::with("https://example.test"), + format!("HTTP/1.1 200 OK\r\nX-DNS-Prefetch-Control: {value}\r\nContent-Length: 0\r\n\r\n") + .into_bytes(), + ) + .expect("response should preserve malformed metadata"); + assert!(response.x_dns_prefetch_control().is_err()); + } + + let duplicate = Response::new( + RoUrl::with("https://example.test"), + b"HTTP/1.1 200 OK\r\nX-DNS-Prefetch-Control: on\r\nx-dns-prefetch-control: off\r\nContent-Length: 0\r\n\r\n".to_vec(), + ) + .expect("response should preserve duplicate metadata"); + assert!(duplicate.x_dns_prefetch_control().is_err()); +} + #[test] fn x_content_type_options_metadata_parses_nosniff_without_applying_policy() { for value in ["nosniff", "NoSniff"] { diff --git a/crates/rttp-protocol/README.md b/crates/rttp-protocol/README.md index 399ae564..54b206bb 100644 --- a/crates/rttp-protocol/README.md +++ b/crates/rttp-protocol/README.md @@ -1590,6 +1590,14 @@ duplicate metadata, Preference-Applied parameters, and bound violations are errors. The parser does not check whether a matching request preference was sent or apply any preference semantics. +## X-DNS-Prefetch-Control + +`x_dns_prefetch_control` parses a singleton `X-DNS-Prefetch-Control` response +field. Values are bounded to 64 KiB; duplicate fields, comma lists, controls, +and unsupported tokens are rejected. `on` and `off` are matched +case-insensitively and serialized in lowercase. Parsing metadata does not +enable or disable DNS prefetching. + ## X-Frame-Options `x_frame_options` parses a singleton `X-Frame-Options` response field. Each diff --git a/crates/rttp-protocol/src/lib.rs b/crates/rttp-protocol/src/lib.rs index 2806f08d..6c575aad 100644 --- a/crates/rttp-protocol/src/lib.rs +++ b/crates/rttp-protocol/src/lib.rs @@ -148,6 +148,7 @@ pub mod want_repr_digest; pub mod warning; pub mod www_authenticate; pub mod x_content_type_options; +pub mod x_dns_prefetch_control; pub mod x_forwarded_for; pub mod x_forwarded_host; pub mod x_forwarded_proto; diff --git a/crates/rttp-protocol/src/x_dns_prefetch_control.rs b/crates/rttp-protocol/src/x_dns_prefetch_control.rs new file mode 100644 index 00000000..2d701f35 --- /dev/null +++ b/crates/rttp-protocol/src/x_dns_prefetch_control.rs @@ -0,0 +1,110 @@ +//! Bounded, policy-free `X-DNS-Prefetch-Control` response metadata parsing. +//! +//! This module validates the response field value only. Callers decide whether +//! and how to apply DNS-prefetch behavior. + +use std::error::Error; +use std::fmt; + +/// Maximum bytes accepted in an `X-DNS-Prefetch-Control` field value. +pub const MAX_X_DNS_PREFETCH_CONTROL_VALUE_BYTES: usize = 64 * 1024; + +/// The DNS-prefetch behavior declared by `X-DNS-Prefetch-Control`. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum XDnsPrefetchControl { + On, + Off, +} + +impl XDnsPrefetchControl { + pub fn parse(value: impl AsRef) -> Result { + Self::parse_values([value.as_ref()]) + } + + pub fn parse_values<'a, I>(values: I) -> Result + where + I: IntoIterator, + { + let value = parse_singleton(values)?; + if value.eq_ignore_ascii_case("on") { + Ok(Self::On) + } else if value.eq_ignore_ascii_case("off") { + Ok(Self::Off) + } else { + Err(invalid_value()) + } + } + + pub const fn header_value(self) -> &'static str { + match self { + Self::On => "on", + Self::Off => "off", + } + } +} + +/// An error returned when `X-DNS-Prefetch-Control` metadata is malformed or +/// exceeds bounds. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct XDnsPrefetchControlParseError { + message: String, +} + +impl XDnsPrefetchControlParseError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for XDnsPrefetchControlParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for XDnsPrefetchControlParseError {} + +fn parse_singleton<'a, I>(values: I) -> Result<&'a str, XDnsPrefetchControlParseError> +where + I: IntoIterator, +{ + let mut values = values.into_iter(); + let value = values.next().ok_or_else(invalid_value)?; + validate_bounded_value(value)?; + let mut has_duplicate = false; + for value in values { + has_duplicate = true; + validate_bounded_value(value)?; + } + if has_duplicate { + return Err(XDnsPrefetchControlParseError::new( + "duplicate X-DNS-Prefetch-Control header fields", + )); + } + let value = value.trim_matches([' ', '\t']); + if value.is_empty() { + return Err(invalid_value()); + } + Ok(value) +} + +fn validate_bounded_value(value: &str) -> Result<(), XDnsPrefetchControlParseError> { + if value.len() > MAX_X_DNS_PREFETCH_CONTROL_VALUE_BYTES { + return Err(XDnsPrefetchControlParseError::new( + "X-DNS-Prefetch-Control header value is too large", + )); + } + if value + .bytes() + .any(|byte| byte.is_ascii_control() && byte != b'\t') + { + return Err(invalid_value()); + } + Ok(()) +} + +fn invalid_value() -> XDnsPrefetchControlParseError { + XDnsPrefetchControlParseError::new("invalid X-DNS-Prefetch-Control header value") +} diff --git a/crates/rttp-protocol/tests/metadata_facade.rs b/crates/rttp-protocol/tests/metadata_facade.rs index 4d21a653..f00891d1 100644 --- a/crates/rttp-protocol/tests/metadata_facade.rs +++ b/crates/rttp-protocol/tests/metadata_facade.rs @@ -116,6 +116,7 @@ use rttp_protocol::want_content_digest::WantContentDigest; use rttp_protocol::want_repr_digest::WantReprDigest; use rttp_protocol::warning::Warning; use rttp_protocol::x_content_type_options::XContentTypeOptions; +use rttp_protocol::x_dns_prefetch_control::{XDnsPrefetchControl, XDnsPrefetchControlParseError}; use rttp_protocol::x_forwarded_for::{XForwardedFor, XForwardedForParseError}; use rttp_protocol::x_forwarded_host::{XForwardedHost, XForwardedHostParseError}; use rttp_protocol::x_forwarded_proto::{XForwardedProto, XForwardedProtoParseError}; @@ -297,6 +298,10 @@ fn protocol_exports_representative_bounded_metadata_types() { .expect("Signature-Input should parse"); let x_content_type_options = XContentTypeOptions::parse("nosniff").expect("X-Content-Type-Options should parse"); + let x_dns_prefetch_control = + XDnsPrefetchControl::parse("OFF").expect("X-DNS-Prefetch-Control should parse"); + let _: XDnsPrefetchControlParseError = XDnsPrefetchControl::parse("unknown") + .expect_err("unknown X-DNS-Prefetch-Control should be rejected"); let x_frame_options = XFrameOptions::parse("SAMEORIGIN").expect("X-Frame-Options should parse"); let cross_origin_embedder_policy = CrossOriginEmbedderPolicy::parse(r#"require-corp; report-to="coep""#) @@ -709,6 +714,8 @@ fn protocol_exports_representative_bounded_metadata_types() { r#"sig1=("@method" "@path");created=1618884473"# ); assert_eq!(x_content_type_options.header_value(), "nosniff"); + assert_eq!(x_dns_prefetch_control, XDnsPrefetchControl::Off); + assert_eq!(x_dns_prefetch_control.header_value(), "off"); assert_eq!(x_frame_options.header_value(), "SAMEORIGIN"); assert_eq!(cross_origin_embedder_policy.header_value(), "require-corp"); assert_eq!( diff --git a/crates/rttp-protocol/tests/x_dns_prefetch_control.rs b/crates/rttp-protocol/tests/x_dns_prefetch_control.rs new file mode 100644 index 00000000..052990ea --- /dev/null +++ b/crates/rttp-protocol/tests/x_dns_prefetch_control.rs @@ -0,0 +1,60 @@ +use rttp_protocol::x_dns_prefetch_control::{ + XDnsPrefetchControl, MAX_X_DNS_PREFETCH_CONTROL_VALUE_BYTES, +}; + +#[test] +fn x_dns_prefetch_control_parses_on_and_off_case_insensitively() { + for (value, expected, canonical) in [ + ("on", XDnsPrefetchControl::On, "on"), + ("ON", XDnsPrefetchControl::On, "on"), + ("On", XDnsPrefetchControl::On, "on"), + ("off", XDnsPrefetchControl::Off, "off"), + ("OFF", XDnsPrefetchControl::Off, "off"), + ("Off", XDnsPrefetchControl::Off, "off"), + ] { + let metadata = XDnsPrefetchControl::parse(value).expect("valid value should parse"); + assert_eq!(expected, metadata); + assert_eq!(canonical, metadata.header_value()); + } +} + +#[test] +fn x_dns_prefetch_control_accepts_ows_and_rejects_malformed_values() { + for value in ["\ton\t", " on ", "\toFf "] { + assert!( + XDnsPrefetchControl::parse(value).is_ok(), + "OWS-padded value {value:?} should parse" + ); + } + + for value in [ + "", + " ", + "true", + "on, off", + "on; foo", + "\"on\"", + "on\tinner", + "on\r\nX: y", + "on\u{7f}", + ] { + assert!( + XDnsPrefetchControl::parse(value).is_err(), + "{value:?} must be rejected" + ); + } +} + +#[test] +fn x_dns_prefetch_control_rejects_duplicate_fields_and_empty_sets() { + assert!(XDnsPrefetchControl::parse_values(["on", "off"]).is_err()); + assert!(XDnsPrefetchControl::parse_values([]).is_err()); +} + +#[test] +fn x_dns_prefetch_control_enforces_value_bounds_on_all_fields() { + let oversized = "x".repeat(MAX_X_DNS_PREFETCH_CONTROL_VALUE_BYTES + 1); + + assert!(XDnsPrefetchControl::parse(&oversized).is_err()); + assert!(XDnsPrefetchControl::parse_values(["on", oversized.as_str()]).is_err()); +} diff --git a/crates/rttp-server/src/server/response.rs b/crates/rttp-server/src/server/response.rs index 3e41d417..3f1c08f6 100644 --- a/crates/rttp-server/src/server/response.rs +++ b/crates/rttp-server/src/server/response.rs @@ -319,6 +319,10 @@ pub use rttp_protocol::x_content_type_options::{ XContentTypeOptions as HttpXContentTypeOptions, XContentTypeOptionsParseError as HttpXContentTypeOptionsParseError, }; +pub use rttp_protocol::x_dns_prefetch_control::{ + XDnsPrefetchControl as HttpXDnsPrefetchControl, + XDnsPrefetchControlParseError as HttpXDnsPrefetchControlParseError, +}; pub use rttp_protocol::x_frame_options::{ XFrameOptions as HttpXFrameOptions, XFrameOptionsParseError as HttpXFrameOptionsParseError, }; @@ -1694,6 +1698,23 @@ impl HttpResponse { Ok(self) } + /// Validates and replaces `X-DNS-Prefetch-Control` response metadata + /// without applying DNS-prefetch behavior. + pub fn with_x_dns_prefetch_control( + mut self, + value: impl AsRef, + ) -> Result { + let control = HttpXDnsPrefetchControl::parse(value)?; + self + .headers + .retain(|header| !header.name.eq_ignore_ascii_case("X-DNS-Prefetch-Control")); + self.headers.push(HttpHeader::new( + "X-DNS-Prefetch-Control", + control.header_value(), + )); + Ok(self) + } + /// Validates and replaces `X-Frame-Options` response metadata without /// applying clickjacking protection. pub fn with_x_frame_options( @@ -3261,6 +3282,23 @@ impl HttpResponse { HttpXContentTypeOptions::parse_values(values).map(Some) } + /// Parses attached `X-DNS-Prefetch-Control` response metadata without + /// applying DNS-prefetch behavior. + pub fn x_dns_prefetch_control( + &self, + ) -> Result, HttpXDnsPrefetchControlParseError> { + let values: Vec<&str> = self + .headers + .iter() + .filter(|header| header.name.eq_ignore_ascii_case("X-DNS-Prefetch-Control")) + .map(|header| header.value.as_str()) + .collect(); + if values.is_empty() { + return Ok(None); + } + HttpXDnsPrefetchControl::parse_values(values).map(Some) + } + /// Parses attached `X-Frame-Options` response metadata without /// applying clickjacking protection. pub fn x_frame_options(&self) -> Result, HttpXFrameOptionsParseError> { diff --git a/crates/rttp-server/src/server/server_tests.rs b/crates/rttp-server/src/server/server_tests.rs index 1478adbc..f2f35750 100644 --- a/crates/rttp-server/src/server/server_tests.rs +++ b/crates/rttp-server/src/server/server_tests.rs @@ -2630,6 +2630,74 @@ fn strict_transport_security_helpers_preserve_raw_metadata_and_report_parse_erro .is_err()); } +#[test] +fn x_dns_prefetch_control_helpers_validate_replace_and_parse_response_metadata() { + let response = HttpResponse::ok([]) + .header("X-DNS-Prefetch-Control", "on") + .header("x-dns-prefetch-control", "off") + .with_x_dns_prefetch_control("OFF") + .expect("X-DNS-Prefetch-Control should be accepted"); + + assert_eq!( + "off", + response + .x_dns_prefetch_control() + .expect("X-DNS-Prefetch-Control should parse") + .expect("X-DNS-Prefetch-Control should be present") + .header_value() + ); + assert_eq!( + vec![("X-DNS-Prefetch-Control", "off")], + response + .headers + .iter() + .map(|header| (header.name.as_str(), header.value.as_str())) + .collect::>() + ); +} + +#[test] +fn x_dns_prefetch_control_helpers_preserve_raw_metadata_and_report_parse_errors() { + let raw = HttpResponse::ok([]).header("X-DNS-Prefetch-Control", " ON "); + assert_eq!( + "on", + raw + .x_dns_prefetch_control() + .expect("raw X-DNS-Prefetch-Control should parse") + .expect("X-DNS-Prefetch-Control should be present") + .header_value() + ); + assert_eq!( + Some(" ON "), + raw + .headers + .iter() + .find(|header| header.name.eq_ignore_ascii_case("X-DNS-Prefetch-Control")) + .map(|header| header.value.as_str()) + ); + + let malformed = HttpResponse::ok([]).header("X-DNS-Prefetch-Control", "unknown"); + assert!(malformed.x_dns_prefetch_control().is_err()); + assert!(HttpResponse::ok([]) + .with_x_dns_prefetch_control("unknown") + .is_err()); + assert_eq!( + None, + HttpResponse::ok([]) + .x_dns_prefetch_control() + .expect("absent X-DNS-Prefetch-Control should parse") + ); + + let duplicate = HttpResponse::ok([]) + .header("X-DNS-Prefetch-Control", "on") + .header("x-dns-prefetch-control", "off"); + assert!(duplicate.x_dns_prefetch_control().is_err()); + + assert!(HttpResponse::ok([]) + .with_x_dns_prefetch_control("x".repeat(64 * 1024 + 1)) + .is_err()); +} + #[test] fn x_content_type_options_helpers_validate_replace_and_parse_response_metadata() { let response = HttpResponse::ok([]) diff --git a/crates/rttp/src/lib.rs b/crates/rttp/src/lib.rs index 4520b391..809eb027 100644 --- a/crates/rttp/src/lib.rs +++ b/crates/rttp/src/lib.rs @@ -112,7 +112,8 @@ pub use rttp_client::response::{ WantContentDigestEntry, WantContentDigestParseError, WantReprDigest, WantReprDigestEntry, WantReprDigestParseError, Warning, WarningParseError, WarningValue, WwwAuthenticate, WwwAuthenticateChallenge, WwwAuthenticateParameter, WwwAuthenticateParseError, - XContentTypeOptions, XContentTypeOptionsParseError, XFrameOptions, XFrameOptionsParseError, + XContentTypeOptions, XContentTypeOptionsParseError, XDnsPrefetchControl, + XDnsPrefetchControlParseError, XFrameOptions, XFrameOptionsParseError, }; #[cfg(feature = "client")] pub use rttp_client::response::{