diff --git a/crates/rttp-client/src/response/mod.rs b/crates/rttp-client/src/response/mod.rs index 75672832..70a27052 100644 --- a/crates/rttp-client/src/response/mod.rs +++ b/crates/rttp-client/src/response/mod.rs @@ -229,4 +229,5 @@ pub use rttp_protocol::www_authenticate::{ pub use rttp_protocol::x_content_type_options::{ XContentTypeOptions, XContentTypeOptionsParseError, }; +pub use rttp_protocol::x_download_options::{XDownloadOptions, XDownloadOptionsParseError}; 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 17e97cec..fce008ed 100644 --- a/crates/rttp-client/src/response/response.rs +++ b/crates/rttp-client/src/response/response.rs @@ -98,6 +98,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_download_options::XDownloadOptions; use rttp_protocol::x_frame_options::XFrameOptions; const MAX_CACHE_CONTROL_VALUE_BYTES: usize = 64 * 1024; @@ -944,6 +945,17 @@ impl Response { .map_err(|parse_error| error::bad_response(parse_error.to_string())) } + /// Parses bounded `X-Download-Options` response metadata without applying download handling policy. + pub fn x_download_options(&self) -> error::Result> { + let values = self.header_values("x-download-options"); + if values.is_empty() { + return Ok(None); + } + XDownloadOptions::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 32f1b93d..bfd8e9f8 100644 --- a/crates/rttp-client/tests/metadata_facade.rs +++ b/crates/rttp-client/tests/metadata_facade.rs @@ -35,8 +35,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, XDownloadOptions, + XDownloadOptionsParseError, XFrameOptions, XFrameOptionsParseError, }; use rttp_client::response::{ ContentDigest, ContentDisposition, ContentDispositionParseError, ContentLocation, @@ -321,6 +321,10 @@ fn response_facade_exports_representative_bounded_metadata_types() { XContentTypeOptions::parse("NoSniff").expect("X-Content-Type-Options should parse"); let _: XContentTypeOptionsParseError = XContentTypeOptions::parse("unknown") .expect_err("unknown X-Content-Type-Options should be rejected"); + let x_download_options = + XDownloadOptions::parse("NoOpen").expect("X-Download-Options should parse"); + let _: XDownloadOptionsParseError = + XDownloadOptions::parse("unknown").expect_err("unknown X-Download-Options should be rejected"); let x_frame_options = XFrameOptions::parse("deny").expect("X-Frame-Options should parse"); let _: XFrameOptionsParseError = XFrameOptions::parse("ALLOW-FROM https://example.test") .expect_err("deprecated X-Frame-Options ALLOW-FROM should be rejected"); @@ -633,6 +637,8 @@ fn response_facade_exports_representative_bounded_metadata_types() { assert!(strict_transport_security.include_sub_domains()); assert_eq!(x_content_type_options, XContentTypeOptions::Nosniff); assert_eq!(x_content_type_options.header_value(), "nosniff"); + assert_eq!(x_download_options, XDownloadOptions::Noopen); + assert_eq!(x_download_options.header_value(), "noopen"); assert_eq!(x_frame_options, XFrameOptions::Deny); assert_eq!(x_frame_options.header_value(), "DENY"); assert_eq!( diff --git a/crates/rttp-client/tests/test_response.rs b/crates/rttp-client/tests/test_response.rs index 43cb1aaa..2eccd6e8 100644 --- a/crates/rttp-client/tests/test_response.rs +++ b/crates/rttp-client/tests/test_response.rs @@ -10,7 +10,8 @@ use rttp_client::response::{ 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, XDownloadOptions, + XFrameOptions, }; use rttp_client::types::{Cookie, RoUrl}; use rttp_client::DavClass; @@ -848,6 +849,85 @@ fn x_content_type_options_metadata_is_absent_without_a_header() { let _: Option = response.x_content_type_options().expect("header is absent"); } +#[test] +fn x_download_options_metadata_parses_noopen_without_applying_policy() { + for value in ["noopen", "NoOpen"] { + let response = Response::new( + RoUrl::with("https://example.test"), + format!("HTTP/1.1 200 OK\r\nX-Download-Options: {value}\r\nContent-Length: 0\r\n\r\n") + .into_bytes(), + ) + .expect("response should parse"); + + let metadata = response + .x_download_options() + .expect("X-Download-Options should parse") + .expect("X-Download-Options should be present"); + + assert_eq!(metadata, XDownloadOptions::Noopen); + assert_eq!(metadata.header_value(), "noopen"); + assert_eq!( + response.header_value("X-Download-Options"), + Some(&value.to_string()) + ); + } +} + +#[test] +fn x_download_options_metadata_rejects_invalid_values_without_hiding_raw_headers() { + for value in ["", "unknown", "noopen, noopen"] { + let response = Response::new( + RoUrl::with("https://example.test"), + format!("HTTP/1.1 200 OK\r\nX-Download-Options: {value}\r\nContent-Length: 0\r\n\r\n") + .into_bytes(), + ) + .expect("response should parse"); + + assert!( + response.x_download_options().is_err(), + "should reject {value:?}" + ); + assert_eq!( + response.header_value("X-Download-Options"), + Some(&value.to_string()) + ); + } + + let response = Response::new( + RoUrl::with("https://example.test"), + concat!( + "HTTP/1.1 200 OK\r\n", + "X-Download-Options: noopen\r\n", + "X-Download-Options: noopen\r\n", + "Content-Length: 0\r\n\r\n" + ) + .as_bytes() + .to_vec(), + ) + .expect("response should parse"); + + assert!(response.x_download_options().is_err()); + assert_eq!( + response.header_values("X-Download-Options"), + [&"noopen".to_string(), &"noopen".to_string()] + ); +} + +#[test] +fn x_download_options_metadata_is_absent_without_a_header() { + let response = Response::new( + RoUrl::with("https://example.test"), + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".to_vec(), + ) + .expect("response should parse"); + + assert_eq!( + response.x_download_options().expect("header is absent"), + None + ); + let _: Option = response.x_download_options().expect("header is absent"); +} + #[test] fn x_frame_options_metadata_parses_tokens_without_applying_policy() { for (value, expected) in [ diff --git a/crates/rttp-protocol/README.md b/crates/rttp-protocol/README.md index 1093740f..cc342674 100644 --- a/crates/rttp-protocol/README.md +++ b/crates/rttp-protocol/README.md @@ -1243,6 +1243,18 @@ coding lists, and empty present field sets are errors. This parser never fails open and does not enable a transfer-coding engine, negotiate trailers, or apply compression or proxy behavior. +## X-Download-Options + +`x_download_options` parses a singleton `X-Download-Options` response field. +Each field value is bounded to 64 KiB. A second field is rejected after every +supplied field is bound-checked. Surrounding SP and HTAB are trimmed as +optional whitespace. The value must be exactly `noopen`, matched +case-insensitively and formatted canonically in lowercase. Empty values, +comma-joined values, semicolon parameters, quoted values, unsupported tokens, +ASCII controls other than HTAB, and other ambiguous input are errors. This +parser reports declared metadata only; it does not decide download handling +policy. + ## 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 4ecc481c..aa3dee00 100644 --- a/crates/rttp-protocol/src/lib.rs +++ b/crates/rttp-protocol/src/lib.rs @@ -144,6 +144,7 @@ pub mod want_repr_digest; pub mod warning; pub mod www_authenticate; pub mod x_content_type_options; +pub mod x_download_options; pub mod x_forwarded_for; pub mod x_forwarded_host; pub mod x_forwarded_proto; diff --git a/crates/rttp-protocol/src/x_download_options.rs b/crates/rttp-protocol/src/x_download_options.rs new file mode 100644 index 00000000..fc4aa9b9 --- /dev/null +++ b/crates/rttp-protocol/src/x_download_options.rs @@ -0,0 +1,103 @@ +//! Bounded, policy-free `X-Download-Options` response metadata parsing. +//! +//! This module validates the response field value only. Callers decide whether +//! and how to enforce download handling. + +use std::error::Error; +use std::fmt; + +pub const MAX_X_DOWNLOAD_OPTIONS_VALUE_BYTES: usize = 64 * 1024; + +/// The download handling declared by `X-Download-Options`. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum XDownloadOptions { + Noopen, +} + +impl XDownloadOptions { + 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("noopen") { + Ok(Self::Noopen) + } else { + Err(invalid_value()) + } + } + + pub const fn header_value(self) -> &'static str { + match self { + Self::Noopen => "noopen", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct XDownloadOptionsParseError { + message: String, +} + +impl XDownloadOptionsParseError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for XDownloadOptionsParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for XDownloadOptionsParseError {} + +fn parse_singleton<'a, I>(values: I) -> Result<&'a str, XDownloadOptionsParseError> +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(XDownloadOptionsParseError::new( + "duplicate X-Download-Options 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<(), XDownloadOptionsParseError> { + if value.len() > MAX_X_DOWNLOAD_OPTIONS_VALUE_BYTES { + return Err(XDownloadOptionsParseError::new( + "X-Download-Options 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() -> XDownloadOptionsParseError { + XDownloadOptionsParseError::new("invalid X-Download-Options header value") +} diff --git a/crates/rttp-protocol/tests/metadata_facade.rs b/crates/rttp-protocol/tests/metadata_facade.rs index ffffd9b1..4bc63e88 100644 --- a/crates/rttp-protocol/tests/metadata_facade.rs +++ b/crates/rttp-protocol/tests/metadata_facade.rs @@ -108,6 +108,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_download_options::XDownloadOptions; use rttp_protocol::x_forwarded_for::{XForwardedFor, XForwardedForParseError}; use rttp_protocol::x_forwarded_host::{XForwardedHost, XForwardedHostParseError}; use rttp_protocol::x_forwarded_proto::{XForwardedProto, XForwardedProtoParseError}; @@ -268,6 +269,8 @@ 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_download_options = + XDownloadOptions::parse("noopen").expect("X-Download-Options should parse"); 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""#) @@ -645,6 +648,7 @@ 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_download_options.header_value(), "noopen"); 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_download_options.rs b/crates/rttp-protocol/tests/x_download_options.rs new file mode 100644 index 00000000..c7efea23 --- /dev/null +++ b/crates/rttp-protocol/tests/x_download_options.rs @@ -0,0 +1,70 @@ +use rttp_protocol::x_download_options::{XDownloadOptions, MAX_X_DOWNLOAD_OPTIONS_VALUE_BYTES}; + +#[test] +fn x_download_options_parses_noopen_case_insensitively() { + assert_eq!( + XDownloadOptions::Noopen, + XDownloadOptions::parse("noopen").expect("noopen should parse") + ); + assert_eq!( + XDownloadOptions::Noopen, + XDownloadOptions::parse("NoOpen").expect("NoOpen should parse") + ); + assert_eq!( + XDownloadOptions::Noopen, + XDownloadOptions::parse("NOOPEN").expect("NOOPEN should parse") + ); + assert_eq!("noopen", XDownloadOptions::Noopen.header_value()); +} + +#[test] +fn x_download_options_accepts_http_optional_whitespace_padding() { + for value in ["\tnoopen\t", " \tnoopen\t ", "noopen\t", "\tnoopen"] { + assert_eq!( + XDownloadOptions::Noopen, + XDownloadOptions::parse(value).expect("OWS-padded noopen should parse") + ); + } +} + +#[test] +fn x_download_options_rejects_empty_duplicate_malformed_and_ambiguous_values() { + for value in [ + "", + " ", + "unknown", + "noopen, noopen", + "noopen; foo", + "\"noopen\"", + "noopen\r\nX: y", + "noopen\u{7f}", + ] { + assert!( + XDownloadOptions::parse(value).is_err(), + "{value:?} must be rejected" + ); + } + + assert!( + XDownloadOptions::parse_values(["noopen", "noopen"]).is_err(), + "duplicate singleton fields must be rejected" + ); + assert!( + XDownloadOptions::parse_values([]).is_err(), + "empty field sets must be rejected" + ); + assert!( + XDownloadOptions::parse("a".repeat(MAX_X_DOWNLOAD_OPTIONS_VALUE_BYTES + 1)).is_err(), + "oversized values must be rejected" + ); +} + +#[test] +fn x_download_options_checks_duplicate_values_against_its_bound() { + let oversized = "a".repeat(MAX_X_DOWNLOAD_OPTIONS_VALUE_BYTES + 1); + + assert!( + XDownloadOptions::parse_values(["noopen", oversized.as_str()]).is_err(), + "oversized duplicate fields must not bypass validation" + ); +} diff --git a/crates/rttp-server/src/server/response.rs b/crates/rttp-server/src/server/response.rs index 8d6b28e3..5efdd11b 100644 --- a/crates/rttp-server/src/server/response.rs +++ b/crates/rttp-server/src/server/response.rs @@ -278,6 +278,10 @@ pub use rttp_protocol::x_content_type_options::{ XContentTypeOptions as HttpXContentTypeOptions, XContentTypeOptionsParseError as HttpXContentTypeOptionsParseError, }; +pub use rttp_protocol::x_download_options::{ + XDownloadOptions as HttpXDownloadOptions, + XDownloadOptionsParseError as HttpXDownloadOptionsParseError, +}; pub use rttp_protocol::x_frame_options::{ XFrameOptions as HttpXFrameOptions, XFrameOptionsParseError as HttpXFrameOptionsParseError, }; @@ -1595,6 +1599,23 @@ impl HttpResponse { Ok(self) } + /// Validates and replaces `X-Download-Options` response metadata without + /// applying download handling policy. + pub fn with_x_download_options( + mut self, + value: impl AsRef, + ) -> Result { + let options = HttpXDownloadOptions::parse(value)?; + self + .headers + .retain(|header| !header.name.eq_ignore_ascii_case("X-Download-Options")); + self.headers.push(HttpHeader::new( + "X-Download-Options", + options.header_value(), + )); + Ok(self) + } + /// Validates and replaces `X-Frame-Options` response metadata without /// applying clickjacking protection. pub fn with_x_frame_options( @@ -3005,6 +3026,23 @@ impl HttpResponse { HttpXContentTypeOptions::parse_values(values).map(Some) } + /// Parses attached `X-Download-Options` response metadata without + /// applying download handling policy. + pub fn x_download_options( + &self, + ) -> Result, HttpXDownloadOptionsParseError> { + let values: Vec<&str> = self + .headers + .iter() + .filter(|header| header.name.eq_ignore_ascii_case("X-Download-Options")) + .map(|header| header.value.as_str()) + .collect(); + if values.is_empty() { + return Ok(None); + } + HttpXDownloadOptions::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 9e86cff0..dc10e405 100644 --- a/crates/rttp-server/src/server/server_tests.rs +++ b/crates/rttp-server/src/server/server_tests.rs @@ -2615,6 +2615,85 @@ fn x_content_type_options_helpers_preserve_raw_metadata_and_report_parse_errors( .is_err()); } +#[test] +fn x_download_options_helpers_validate_replace_and_parse_response_metadata() { + let response = HttpResponse::ok([]) + .header("X-Download-Options", "noopen") + .header("x-download-options", "noopen") + .with_x_download_options("NoOpen") + .expect("X-Download-Options should be accepted"); + + assert_eq!( + "noopen", + response + .x_download_options() + .expect("X-Download-Options should parse") + .expect("X-Download-Options should be present") + .header_value() + ); + assert_eq!( + vec![("X-Download-Options", "noopen")], + response + .headers + .iter() + .map(|header| (header.name.as_str(), header.value.as_str())) + .collect::>() + ); +} + +#[test] +fn x_download_options_helpers_preserve_raw_metadata_and_report_parse_errors() { + let raw = HttpResponse::ok([]).header("X-Download-Options", "NoOpen"); + assert_eq!( + "noopen", + raw + .x_download_options() + .expect("raw NoOpen should parse") + .expect("X-Download-Options should be present") + .header_value() + ); + assert_eq!( + Some("NoOpen"), + raw + .headers + .iter() + .find(|header| header.name.eq_ignore_ascii_case("X-Download-Options")) + .map(|header| header.value.as_str()) + ); + + let malformed = HttpResponse::ok([]).header("X-Download-Options", "unknown"); + assert!(malformed.x_download_options().is_err()); + assert!(HttpResponse::ok([]) + .with_x_download_options("unknown") + .is_err()); + assert_eq!( + None, + HttpResponse::ok([]) + .x_download_options() + .expect("absent X-Download-Options should parse") + ); + for value in ["noopen", "NoOpen", "NOOPEN"] { + assert_eq!( + "noopen", + HttpResponse::ok([]) + .with_x_download_options(value) + .expect("valid X-Download-Options should be accepted") + .x_download_options() + .expect("X-Download-Options should parse") + .expect("X-Download-Options should be present") + .header_value() + ); + } + + let duplicate = HttpResponse::ok([]) + .header("X-Download-Options", "noopen") + .header("x-download-options", "noopen"); + assert!(duplicate.x_download_options().is_err()); + assert!(HttpResponse::ok([]) + .with_x_download_options("x".repeat(64 * 1024 + 1)) + .is_err()); +} + #[test] fn x_frame_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 0759407a..79a01502 100644 --- a/crates/rttp/src/lib.rs +++ b/crates/rttp/src/lib.rs @@ -51,7 +51,8 @@ pub use rttp_client::response::{ UpgradeParseError, VariantVary, VariantVaryParseError, WantContentDigest, WantReprDigest, Warning, WarningParseError, WarningValue, WwwAuthenticate, WwwAuthenticateChallenge, WwwAuthenticateParameter, WwwAuthenticateParseError, XContentTypeOptions, - XContentTypeOptionsParseError, XFrameOptions, XFrameOptionsParseError, + XContentTypeOptionsParseError, XDownloadOptions, XDownloadOptionsParseError, XFrameOptions, + XFrameOptionsParseError, }; #[cfg(feature = "client")] pub use rttp_client::{ diff --git a/crates/rttp/tests/metadata_facade.rs b/crates/rttp/tests/metadata_facade.rs index 95ff21b7..986ee435 100644 --- a/crates/rttp/tests/metadata_facade.rs +++ b/crates/rttp/tests/metadata_facade.rs @@ -474,6 +474,12 @@ fn compatibility_facade_exports_client_metadata_types() { let _: rttp::XContentTypeOptionsParseError = rttp_client::response::XContentTypeOptions::parse("unknown") .expect_err("unknown X-Content-Type-Options should be rejected"); + let x_download_options: rttp::XDownloadOptions = + rttp_client::response::XDownloadOptions::parse("NoOpen") + .expect("X-Download-Options should parse"); + let _: rttp::XDownloadOptionsParseError = + rttp_client::response::XDownloadOptions::parse("unknown") + .expect_err("unknown X-Download-Options should be rejected"); let x_frame_options: rttp::XFrameOptions = rttp_client::response::XFrameOptions::parse("deny").expect("X-Frame-Options should parse"); let _: rttp::XFrameOptionsParseError = @@ -780,6 +786,8 @@ fn compatibility_facade_exports_client_metadata_types() { assert_eq!("no-cache, community=private", pragma.header_value()); assert_eq!(x_content_type_options, rttp::XContentTypeOptions::Nosniff); assert_eq!(x_content_type_options.header_value(), "nosniff"); + assert_eq!(x_download_options, rttp::XDownloadOptions::Noopen); + assert_eq!(x_download_options.header_value(), "noopen"); assert_eq!(x_frame_options, rttp::XFrameOptions::Deny); assert_eq!(x_frame_options.header_value(), "DENY"); assert_eq!(