Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/rttp-client/src/response/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
12 changes: 12 additions & 0 deletions crates/rttp-client/src/response/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Option<XDownloadOptions>> {
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<Option<XFrameOptions>> {
let values = self.header_values("x-frame-options");
Expand Down
10 changes: 8 additions & 2 deletions crates/rttp-client/tests/metadata_facade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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!(
Expand Down
82 changes: 81 additions & 1 deletion crates/rttp-client/tests/test_response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -848,6 +849,85 @@ fn x_content_type_options_metadata_is_absent_without_a_header() {
let _: Option<XContentTypeOptions> = 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<XDownloadOptions> = response.x_download_options().expect("header is absent");
}

#[test]
fn x_frame_options_metadata_parses_tokens_without_applying_policy() {
for (value, expected) in [
Expand Down
12 changes: 12 additions & 0 deletions crates/rttp-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/rttp-protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
103 changes: 103 additions & 0 deletions crates/rttp-protocol/src/x_download_options.rs
Original file line number Diff line number Diff line change
@@ -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<str>) -> Result<Self, XDownloadOptionsParseError> {
Self::parse_values([value.as_ref()])
}

pub fn parse_values<'a, I>(values: I) -> Result<Self, XDownloadOptionsParseError>
where
I: IntoIterator<Item = &'a str>,
{
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<String>) -> 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<Item = &'a str>,
{
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")
}
4 changes: 4 additions & 0 deletions crates/rttp-protocol/tests/metadata_facade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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""#)
Expand Down Expand Up @@ -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!(
Expand Down
70 changes: 70 additions & 0 deletions crates/rttp-protocol/tests/x_download_options.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
Loading