From 3060d728cd6979ce764414e61284fd6f2ba0668f Mon Sep 17 00:00:00 2001 From: fewensa Date: Fri, 11 Sep 2026 21:13:03 +0800 Subject: [PATCH] Add bounded Sec-Required-Document-Policy request metadata codeon: version: 1 authority: FWN-371 description: |- Add SecRequiredDocumentPolicy by wrapping the shared Document-Policy Structured Fields parser with distinct request types and bounds. Wire HttpClient::sec_required_document_policy validation before I/O, Request/HttpRequest accessors with raw fallback, rttp facade re-exports, docs, and HTTP/1.1 interop coverage. Do not enforce browser policy or compare against Document-Policy. --- crates/rttp-client/README.md | 16 ++ crates/rttp-client/src/client.rs | 25 +++ crates/rttp-client/src/lib.rs | 4 + crates/rttp-client/tests/metadata_facade.rs | 34 +++- .../tests/test_raw_request_capture.rs | 83 +++++++++ crates/rttp-protocol/README.md | 16 ++ crates/rttp-protocol/src/lib.rs | 1 + .../src/sec_required_document_policy.rs | 86 ++++++++++ .../tests/sec_required_document_policy.rs | 112 ++++++++++++ crates/rttp-server/README.md | 16 ++ crates/rttp-server/src/server/request.rs | 41 +++++ crates/rttp-server/src/server/server_tests.rs | 107 ++++++++++++ crates/rttp-server/tests/metadata_facade.rs | 24 +++ crates/rttp/README.md | 18 ++ crates/rttp/src/lib.rs | 4 +- crates/rttp/tests/metadata_facade.rs | 122 +++++++++++-- tests/http11_client_server_matrix.rs | 160 +++++++++++++++++- 17 files changed, 845 insertions(+), 24 deletions(-) create mode 100644 crates/rttp-protocol/src/sec_required_document_policy.rs create mode 100644 crates/rttp-protocol/tests/sec_required_document_policy.rs diff --git a/crates/rttp-client/README.md b/crates/rttp-client/README.md index 3fb57853..8e3d6844 100644 --- a/crates/rttp-client/README.md +++ b/crates/rttp-client/README.md @@ -1377,6 +1377,21 @@ This helper only declares request metadata. RTTP does not infer or enforce consent, tracking, legal, or serving policy. Callers that need values outside the helper can retain raw-header control with `header(("Sec-GPC", "..."))`. +## Bounded Sec-Required-Document-Policy request metadata + +`HttpClient::sec_required_document_policy(value)` validates one WICG Document +Policy Structured Fields dictionary through the shared protocol +`SecRequiredDocumentPolicy` type and replaces any existing case-insensitive +`Sec-Required-Document-Policy` field with the canonical value before +connecting. The helper uses the same grammar and bounds as `Document-Policy` +while keeping a distinct request metadata type. Malformed, control-bearing, +duplicate, or oversized input fails before a socket is opened. + +This helper only declares request metadata. RTTP does not enforce document +policy, compare values against `Document-Policy`, block document loads, or +send reports. Callers that need values outside the helper can retain +raw-header control with `header(("Sec-Required-Document-Policy", "..."))`. + ## Bounded Upgrade-Insecure-Requests request metadata `HttpClient::upgrade_insecure_requests()` emits `Upgrade-Insecure-Requests: 1`. @@ -1795,6 +1810,7 @@ header-block model. | Referer | `referer` emits one bounded canonical `Referer` request field through the shared protocol type, replacing existing case-insensitive fields; absolute, relative, and scheme-relative URI references are accepted, and raw `header(("Referer", value))` remains available as a fallback | No `Referrer-Policy` enforcement, trust decisions, CSRF protection, redaction, URL canonicalization, or redirect behavior | | User-Agent | `user_agent` emits one bounded canonical `User-Agent` request field through the shared protocol type, replacing existing case-insensitive fields so typed values win over raw headers and the automatic default; absent typed/raw values retain `Mozilla/5.0 rttp/{version}` | No fingerprinting, platform discovery, product policy, global or environment-based defaults, or automatic policy beyond the existing default header | | Sec-GPC | `sec_gpc` emits bounded `Sec-GPC: 1` request metadata through the shared protocol type | No consent inference, tracking-policy enforcement, legal policy, serving policy, retries, or browser state | +| Sec-Required-Document-Policy | `sec_required_document_policy` emits bounded WICG Document Policy dictionary request metadata through the shared protocol type, replacing existing same-name fields and rejecting malformed, control, duplicate, or oversized input before connecting | No document-policy enforcement, required-policy comparison against `Document-Policy`, document-load blocking, feature enablement, or report sending | | Upgrade-Insecure-Requests | `upgrade_insecure_requests` emits bounded singleton `Upgrade-Insecure-Requests: 1` request metadata | No URL rewriting, redirecting, Content-Security-Policy enforcement, HSTS, or automatic scheme selection | | Max-Forwards | `max_forwards` emits bounded singleton `Max-Forwards` request metadata through the shared protocol type | No hop decrement, proxy routing, TRACE/OPTIONS selection, retry, or forwarding policy | | Depth | `depth` emits bounded singleton WebDAV `Depth` request metadata through the shared protocol type, normalizing `infinity` to lowercase and replacing an existing same-name field | No resource traversal, WebDAV method selection, method-policy enforcement, retry, or forwarding policy | diff --git a/crates/rttp-client/src/client.rs b/crates/rttp-client/src/client.rs index 5f8162c0..e4a5c368 100644 --- a/crates/rttp-client/src/client.rs +++ b/crates/rttp-client/src/client.rs @@ -46,6 +46,7 @@ use rttp_protocol::range::{Range, MAX_RANGE_COUNT}; use rttp_protocol::referer::Referer; use rttp_protocol::save_data::SaveData; use rttp_protocol::sec_gpc::SecGpc; +use rttp_protocol::sec_required_document_policy::SecRequiredDocumentPolicy; use rttp_protocol::sec_websocket_extensions::SecWebSocketExtensions; use rttp_protocol::sec_websocket_key::SecWebSocketKey; use rttp_protocol::sec_websocket_protocol::SecWebSocketProtocol; @@ -529,6 +530,30 @@ impl HttpClient { Ok(self.header(Header::new("Sec-GPC", sec_gpc.header_value()))) } + /// Set bounded `Sec-Required-Document-Policy` request metadata. + /// + /// The value is validated through the shared protocol + /// `SecRequiredDocumentPolicy` type using the same Document Policy + /// Structured Fields grammar and bounds as `Document-Policy`. The + /// canonical value replaces any existing case-insensitive + /// `Sec-Required-Document-Policy` field before a connection is opened. + /// Malformed, control-bearing, duplicate, or oversized input returns an + /// error without opening a socket. This declares request metadata only; it + /// does not enforce document policy, compare values against + /// `Document-Policy`, block document loads, or send reports. Use `header` + /// directly for unusual values. + pub fn sec_required_document_policy>( + &mut self, + value: S, + ) -> error::Result<&mut Self> { + let policy = SecRequiredDocumentPolicy::parse(value.as_ref()) + .map_err(|parse_error| error::builder_with_message(parse_error.to_string()))?; + Ok(self.header(Header::new( + "Sec-Required-Document-Policy", + policy.header_value(), + ))) + } + /// Set `Upgrade-Insecure-Requests: 1` request metadata. /// /// This declares the valid upgrade-insecure-requests form only; it does not diff --git a/crates/rttp-client/src/lib.rs b/crates/rttp-client/src/lib.rs index 39803cad..e89deab2 100644 --- a/crates/rttp-client/src/lib.rs +++ b/crates/rttp-client/src/lib.rs @@ -152,6 +152,10 @@ pub use rttp_protocol::overwrite::{Overwrite, OverwriteParseError}; pub use rttp_protocol::referer::{Referer, RefererParseError}; pub use rttp_protocol::schedule_tag::{ScheduleTag, ScheduleTagParseError}; pub use rttp_protocol::sec_gpc::{SecGpc, SecGpcParseError}; +pub use rttp_protocol::sec_required_document_policy::{ + SecRequiredDocumentPolicy, SecRequiredDocumentPolicyDirective, + SecRequiredDocumentPolicyParseError, SecRequiredDocumentPolicyValue, +}; pub use rttp_protocol::sec_websocket_extensions::{ SecWebSocketExtension, SecWebSocketExtensionParameter, SecWebSocketExtensionParameterValue, SecWebSocketExtensions, SecWebSocketExtensionsParseError, diff --git a/crates/rttp-client/tests/metadata_facade.rs b/crates/rttp-client/tests/metadata_facade.rs index 28af1d1e..b94628ca 100644 --- a/crates/rttp-client/tests/metadata_facade.rs +++ b/crates/rttp-client/tests/metadata_facade.rs @@ -50,13 +50,14 @@ use rttp_client::{ HttpClient, If, IfCondition, IfList, IfParseError, IfPredicate, IfResourceTag, IfScheduleTagMatch, IfScheduleTagMatchParseError, IfStateToken, Negotiate, NegotiateDirective, NegotiateParseError, Overwrite, OverwriteParseError, SecFetchDest, SecFetchMode, SecFetchSite, - SecFetchUser, SecGpc, SecGpcParseError, SecPurpose, SecWebSocketKey, SecWebSocketKeyParseError, - Tcn, TcnDirective, TcnParseError, Timeout, TimeoutParseError, TimeoutType, TraceParent, - TraceParentParseError, TraceState, TraceStateMember, TraceStateParseError, - UpgradeInsecureRequests, UpgradeInsecureRequestsParseError, UserAgent, UserAgentMember, - UserAgentParseError, Via as ClientVia, ViaParseError as ClientViaParseError, XForwardedFor, - XForwardedForParseError, XForwardedHost, XForwardedHostParseError, XForwardedProto, - XForwardedProtoParseError, + SecFetchUser, SecGpc, SecGpcParseError, SecPurpose, SecRequiredDocumentPolicy, + SecRequiredDocumentPolicyDirective, SecRequiredDocumentPolicyParseError, + SecRequiredDocumentPolicyValue, SecWebSocketKey, SecWebSocketKeyParseError, Tcn, TcnDirective, + TcnParseError, Timeout, TimeoutParseError, TimeoutType, TraceParent, TraceParentParseError, + TraceState, TraceStateMember, TraceStateParseError, UpgradeInsecureRequests, + UpgradeInsecureRequestsParseError, UserAgent, UserAgentMember, UserAgentParseError, + Via as ClientVia, ViaParseError as ClientViaParseError, XForwardedFor, XForwardedForParseError, + XForwardedHost, XForwardedHostParseError, XForwardedProto, XForwardedProtoParseError, }; use rttp_test_support as support; @@ -401,6 +402,12 @@ fn response_facade_exports_representative_bounded_metadata_types() { let _: DntParseError = Dnt::parse("on").expect_err("invalid DNT should be rejected"); let sec_gpc = SecGpc::parse("1").expect("Sec-GPC should parse"); let _: SecGpcParseError = SecGpc::parse("0").expect_err("invalid Sec-GPC should be rejected"); + let sec_required_document_policy = + SecRequiredDocumentPolicy::parse("oversized-images=2.0, unsized-media=?0, *;report-to=default") + .expect("Sec-Required-Document-Policy should parse"); + let _: SecRequiredDocumentPolicyParseError = + SecRequiredDocumentPolicy::parse("unsized-media=src;foo=bar") + .expect_err("unknown Sec-Required-Document-Policy parameter should be rejected"); let sec_purpose = SecPurpose::parse("prefetch, vendor-ext").expect("Sec-Purpose should parse"); let baggage = Baggage::parse("tenant=acme;source=gateway").expect("baggage should parse"); let _: BaggageParseError = @@ -727,6 +734,19 @@ fn response_facade_exports_representative_bounded_metadata_types() { assert_eq!(fetch_user.header_value(), "?1"); assert_eq!(dnt.header_value(), "1"); assert_eq!(sec_gpc.header_value(), "1"); + assert_eq!(sec_required_document_policy.directives().len(), 3); + assert_eq!( + sec_required_document_policy + .directive("oversized-images") + .unwrap() + .value(), + &SecRequiredDocumentPolicyValue::Decimal("2.0".to_string()) + ); + let _: &SecRequiredDocumentPolicyDirective = sec_required_document_policy.directive("*").unwrap(); + assert_eq!( + sec_required_document_policy.header_value(), + "oversized-images=2.0, unsized-media=?0, *;report-to=default" + ); assert_eq!(sec_purpose.tokens(), ["prefetch", "vendor-ext"]); assert!(sec_purpose.contains_prefetch()); assert_eq!("tenant", baggage_member.key()); diff --git a/crates/rttp-client/tests/test_raw_request_capture.rs b/crates/rttp-client/tests/test_raw_request_capture.rs index e74e0ea1..c09fe905 100644 --- a/crates/rttp-client/tests/test_raw_request_capture.rs +++ b/crates/rttp-client/tests/test_raw_request_capture.rs @@ -5908,6 +5908,89 @@ fn sec_gpc_helper_emits_one_request_signal() { assert_eq!(Some("1"), header_value(&request, "Sec-GPC")); } +#[test] +fn sec_required_document_policy_helper_emits_canonical_metadata() { + let request = capture_request(|base_url| { + client() + .get() + .url(format!("{}/doc", base_url)) + .sec_required_document_policy("oversized-images=2.0, unsized-media=?0, *;report-to=default") + .expect("Sec-Required-Document-Policy should be accepted") + .emit() + .expect("request should succeed"); + }); + let request = request_text(&request); + + assert_eq!( + Some("oversized-images=2.0, unsized-media=?0, *;report-to=default"), + header_value(&request, "Sec-Required-Document-Policy") + ); + assert_eq!( + 1, + request + .lines() + .filter(|line| line + .to_ascii_lowercase() + .starts_with("sec-required-document-policy:")) + .count() + ); +} + +#[test] +fn sec_required_document_policy_helper_replaces_existing_field() { + let request = capture_request(|base_url| { + client() + .get() + .url(format!("{}/doc", base_url)) + .header(("Sec-Required-Document-Policy", "oversized-images=1.0")) + .sec_required_document_policy("unsized-media=?0, *;report-to=default") + .expect("Sec-Required-Document-Policy should replace prior field") + .emit() + .expect("request should succeed"); + }); + let request = request_text(&request); + + assert_eq!( + Some("unsized-media=?0, *;report-to=default"), + header_value(&request, "Sec-Required-Document-Policy") + ); + assert_eq!( + 1, + request + .lines() + .filter(|line| line + .to_ascii_lowercase() + .starts_with("sec-required-document-policy:")) + .count() + ); +} + +#[test] +fn sec_required_document_policy_helper_rejects_invalid_values_before_connecting() { + for value in [ + "", + "oversized-images=1;foo=bar", + "oversized-images=2.0, oversized-images=3.0", + "oversized-images=2.0\r, unsized-media=?0", + "oversized-images=2.0\n, unsized-media=?0", + &"x".repeat(64 * 1024 + 1), + ] { + let request = capture_optional_request(|base_url| { + let mut client = client(); + let error = client + .get() + .url(format!("{}/doc", base_url)) + .sec_required_document_policy(value) + .expect_err("invalid Sec-Required-Document-Policy should be rejected"); + assert!(error.is_builder()); + }); + assert!( + request.is_empty(), + "invalid Sec-Required-Document-Policy must not open a socket" + ); + } +} + #[test] fn upgrade_insecure_requests_helper_emits_signal_value_without_rewriting_target() { let request = capture_request(|base_url| { diff --git a/crates/rttp-protocol/README.md b/crates/rttp-protocol/README.md index fa20c4fa..24b3697f 100644 --- a/crates/rttp-protocol/README.md +++ b/crates/rttp-protocol/README.md @@ -1532,6 +1532,22 @@ same directive model, parser, formatter, and bounds while exposing distinct report-only types and parse errors. It is also metadata-only: it does not enforce policy or deliver reports. +## Sec-Required-Document-Policy + +`sec_required_document_policy` parses bounded `Sec-Required-Document-Policy` +request metadata through the same Document Policy Structured Fields +dictionary model, parser, formatter, and bounds as `Document-Policy`, while +exposing distinct required-policy types and parse errors. Each field value is +bounded to 64 KiB, the cumulative raw bytes across all supplied fields are +bounded to 64 KiB, and the combined directive count is bounded to 256. +Directive names are opaque lowercase tokens or `*` and are not looked up +against a browser configuration-point list. Empty dictionaries, duplicate +directive names including across fields, duplicate parameters, control-bearing +input, and bound violations are errors. The parser reports declared request +metadata only: it does not enforce document policy, compare values against +`Document-Policy`, block document loads, disable browser features, or send +reports. + ## Supports-Loading-Mode `supports_loading_mode` parses bounded `Supports-Loading-Mode` response diff --git a/crates/rttp-protocol/src/lib.rs b/crates/rttp-protocol/src/lib.rs index b576a458..fa151ebe 100644 --- a/crates/rttp-protocol/src/lib.rs +++ b/crates/rttp-protocol/src/lib.rs @@ -110,6 +110,7 @@ pub mod retry_after; pub mod save_data; pub mod schedule_tag; pub mod sec_gpc; +pub mod sec_required_document_policy; pub mod sec_websocket_accept; pub mod sec_websocket_extensions; pub mod sec_websocket_key; diff --git a/crates/rttp-protocol/src/sec_required_document_policy.rs b/crates/rttp-protocol/src/sec_required_document_policy.rs new file mode 100644 index 00000000..d959ce8b --- /dev/null +++ b/crates/rttp-protocol/src/sec_required_document_policy.rs @@ -0,0 +1,86 @@ +//! Bounded, policy-free `Sec-Required-Document-Policy` request metadata parsing. +//! +//! This module validates required Document Policy request metadata through +//! the same directive model and bounds as `Document-Policy`. It reports declared +//! metadata only: callers decide whether and how to use it. This parser does not +//! enforce document policy, block document loads, disable browser features, +//! compare values against `Document-Policy`, or send reports. + +use std::error::Error; +use std::fmt; + +use crate::document_policy::{ + format_document_policy_directives, parse_document_policy_values, DocumentPolicyDirective, +}; +pub use crate::document_policy::{ + DocumentPolicyDirective as SecRequiredDocumentPolicyDirective, + DocumentPolicyValue as SecRequiredDocumentPolicyValue, MAX_DOCUMENT_POLICY_DIRECTIVES, + MAX_DOCUMENT_POLICY_TOTAL_BYTES, MAX_DOCUMENT_POLICY_VALUE_BYTES, +}; + +/// Parsed, bounded `Sec-Required-Document-Policy` request metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SecRequiredDocumentPolicy { + directives: Vec, +} + +/// An error returned when `Sec-Required-Document-Policy` metadata is malformed +/// or exceeds bounds. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SecRequiredDocumentPolicyParseError { + message: String, +} + +impl SecRequiredDocumentPolicyParseError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for SecRequiredDocumentPolicyParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for SecRequiredDocumentPolicyParseError {} + +impl SecRequiredDocumentPolicy { + 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, + { + parse_document_policy_values("Sec-Required-Document-Policy", values) + .map(|directives| Self { directives }) + .map_err(|error| SecRequiredDocumentPolicyParseError::new(error.message())) + } + + pub fn directives(&self) -> &[DocumentPolicyDirective] { + &self.directives + } + + pub fn directive(&self, name: impl AsRef) -> Option<&DocumentPolicyDirective> { + self + .directives + .iter() + .find(|directive| directive.name() == name.as_ref()) + } + + pub fn len(&self) -> usize { + self.directives.len() + } + + pub fn is_empty(&self) -> bool { + self.directives.is_empty() + } + + pub fn header_value(&self) -> String { + format_document_policy_directives(&self.directives) + } +} diff --git a/crates/rttp-protocol/tests/sec_required_document_policy.rs b/crates/rttp-protocol/tests/sec_required_document_policy.rs new file mode 100644 index 00000000..5a8cdf17 --- /dev/null +++ b/crates/rttp-protocol/tests/sec_required_document_policy.rs @@ -0,0 +1,112 @@ +use rttp_protocol::sec_required_document_policy::{ + SecRequiredDocumentPolicy, SecRequiredDocumentPolicyValue, MAX_DOCUMENT_POLICY_DIRECTIVES, + MAX_DOCUMENT_POLICY_TOTAL_BYTES, MAX_DOCUMENT_POLICY_VALUE_BYTES, +}; + +#[test] +fn sec_required_document_policy_parses_dictionary_with_document_policy_model() { + let policy = + SecRequiredDocumentPolicy::parse("oversized-images=2.0, unsized-media=?0, *;report-to=default") + .expect("Sec-Required-Document-Policy should parse"); + + assert_eq!(policy.len(), 3); + assert!(!policy.is_empty()); + assert_eq!(policy.directives()[0].name(), "oversized-images"); + assert_eq!( + policy.directive("oversized-images").unwrap().value(), + &SecRequiredDocumentPolicyValue::Decimal("2.0".to_owned()) + ); + assert_eq!( + policy.directive("unsized-media").unwrap().value(), + &SecRequiredDocumentPolicyValue::Boolean(false) + ); + assert_eq!(policy.directive("*").unwrap().report_to(), Some("default")); + assert_eq!( + policy.header_value(), + "oversized-images=2.0, unsized-media=?0, *;report-to=default" + ); +} + +#[test] +fn sec_required_document_policy_combines_fields_in_wire_order() { + let policy = SecRequiredDocumentPolicy::parse_values([ + "oversized-images=2.0, unsized-media=?0", + "*;report-to=default", + ]) + .expect("combined Sec-Required-Document-Policy fields should parse"); + + assert_eq!(policy.len(), 3); + assert_eq!( + policy.header_value(), + "oversized-images=2.0, unsized-media=?0, *;report-to=default" + ); +} + +#[test] +fn sec_required_document_policy_rejects_malformed_control_and_duplicate_values() { + for value in [ + "", + " ", + "oversized-images=()", + "oversized-images=(1 2)", + "oversized-images=\"2.0\"", + "oversized-images=:MjA=:", + "oversized-images=@123", + "oversized-images=%\"2.0\"", + "oversized-images=+2.0", + "oversized-images=1.", + "oversized-images=1.2345", + "oversized-images=1;foo=bar", + "oversized-images=1;report-to=5", + "oversized-images=1;report-to=first;report-to=second", + "Oversized-Images=2.0", + "oversized-images=2.0, oversized-images=3.0", + "oversized-images=2.0,, unsized-media=?0", + "oversized-images=2.0\r, unsized-media=?0", + "oversized-images=2.0\n, unsized-media=?0", + "oversized-images=2.0\0, unsized-media=?0", + ] { + assert!( + SecRequiredDocumentPolicy::parse(value).is_err(), + "{value:?} must be rejected" + ); + } + + assert!( + SecRequiredDocumentPolicy::parse_values(["oversized-images=2.0", "oversized-images=3.0"]) + .is_err(), + "duplicate directive names across fields must be rejected" + ); +} + +#[test] +fn sec_required_document_policy_enforces_shared_size_and_member_bounds() { + assert!( + SecRequiredDocumentPolicy::parse("x".repeat(MAX_DOCUMENT_POLICY_VALUE_BYTES + 1)).is_err(), + "oversized values must be rejected" + ); + + let at_limit = (0..MAX_DOCUMENT_POLICY_DIRECTIVES) + .map(|index| format!("feature{index}=?1")) + .collect::>() + .join(", "); + let parsed = SecRequiredDocumentPolicy::parse(&at_limit).expect("256 directives should parse"); + assert_eq!(parsed.len(), MAX_DOCUMENT_POLICY_DIRECTIVES); + + let too_many = (0..=MAX_DOCUMENT_POLICY_DIRECTIVES) + .map(|index| format!("feature{index}=?1")) + .collect::>() + .join(", "); + assert!( + SecRequiredDocumentPolicy::parse(&too_many).is_err(), + "more than 256 directives must be rejected" + ); + + let first = format!("first={}", "a".repeat(40 * 1024)); + let second = format!("second={}", "b".repeat(40 * 1024)); + assert!(first.len() + second.len() > MAX_DOCUMENT_POLICY_TOTAL_BYTES); + assert!( + SecRequiredDocumentPolicy::parse_values([first.as_str(), second.as_str()]).is_err(), + "fields that exceed the cumulative bound together must be rejected" + ); +} diff --git a/crates/rttp-server/README.md b/crates/rttp-server/README.md index 3bd6e16d..5628c1de 100644 --- a/crates/rttp-server/README.md +++ b/crates/rttp-server/README.md @@ -1041,6 +1041,22 @@ original raw field. These helpers parse request metadata only. They do not infer or enforce consent, tracking, legal, or serving policy. +## Sec-Required-Document-Policy request metadata + +Handlers can call `Request::sec_required_document_policy()` and +`HttpRequest::sec_required_document_policy()` to observe bounded typed +`Sec-Required-Document-Policy` request metadata through the shared protocol +`HttpSecRequiredDocumentPolicy` type. The helpers use the same Document +Policy Structured Fields grammar and bounds as `Document-Policy` while +keeping a distinct request metadata type. Absent fields return `Ok(None)`. +Malformed, control-bearing, duplicate, or oversized values return a parser +error while `Request::header()` and `HttpRequest::header()` continue to +expose the original raw field. + +These helpers parse request metadata only. They do not enforce document +policy, compare values against `Document-Policy`, block document loads, or +send reports. + ## Upgrade-Insecure-Requests request metadata Handlers can call `Request::upgrade_insecure_requests()` and diff --git a/crates/rttp-server/src/server/request.rs b/crates/rttp-server/src/server/request.rs index c50bfdd2..4cd49c6e 100644 --- a/crates/rttp-server/src/server/request.rs +++ b/crates/rttp-server/src/server/request.rs @@ -121,6 +121,12 @@ pub use rttp_protocol::save_data::{ SaveData as HttpSaveData, SaveDataParseError as HttpSaveDataParseError, }; pub use rttp_protocol::sec_gpc::{SecGpc as HttpSecGpc, SecGpcParseError as HttpSecGpcParseError}; +pub use rttp_protocol::sec_required_document_policy::{ + SecRequiredDocumentPolicy as HttpSecRequiredDocumentPolicy, + SecRequiredDocumentPolicyDirective as HttpSecRequiredDocumentPolicyDirective, + SecRequiredDocumentPolicyParseError as HttpSecRequiredDocumentPolicyParseError, + SecRequiredDocumentPolicyValue as HttpSecRequiredDocumentPolicyValue, +}; pub use rttp_protocol::sec_websocket_extensions::{ SecWebSocketExtension as HttpSecWebSocketExtension, SecWebSocketExtensionParameter as HttpSecWebSocketExtensionParameter, @@ -597,6 +603,19 @@ impl Request { HttpSecGpc::parse_values(values).map(Some) } + /// Parses received `Sec-Required-Document-Policy` request metadata without + /// enforcing document policy, comparing values against `Document-Policy`, + /// blocking document loads, or sending reports. + pub fn sec_required_document_policy( + &self, + ) -> Result, HttpSecRequiredDocumentPolicyParseError> { + let values: Vec<&str> = self.headers_named("Sec-Required-Document-Policy").collect(); + if values.is_empty() { + return Ok(None); + } + HttpSecRequiredDocumentPolicy::parse_values(values).map(Some) + } + /// Parses received `Upgrade-Insecure-Requests` request metadata without /// rewriting URLs, redirecting, or enforcing Content-Security-Policy. pub fn upgrade_insecure_requests( @@ -2800,6 +2819,28 @@ impl HttpRequest { HttpSecGpc::parse_values(values).map(Some) } + /// Parses received `Sec-Required-Document-Policy` request metadata without + /// enforcing document policy, comparing values against `Document-Policy`, + /// blocking document loads, or sending reports. + pub fn sec_required_document_policy( + &self, + ) -> Result, HttpSecRequiredDocumentPolicyParseError> { + let values: Vec<&str> = self + .headers + .iter() + .filter(|header| { + header + .name + .eq_ignore_ascii_case("Sec-Required-Document-Policy") + }) + .map(|header| header.value.as_str()) + .collect(); + if values.is_empty() { + return Ok(None); + } + HttpSecRequiredDocumentPolicy::parse_values(values).map(Some) + } + /// Parses received `Upgrade-Insecure-Requests` request metadata without /// rewriting URLs, redirecting, or enforcing Content-Security-Policy. pub fn upgrade_insecure_requests( diff --git a/crates/rttp-server/src/server/server_tests.rs b/crates/rttp-server/src/server/server_tests.rs index a7df10ad..5cd0247c 100644 --- a/crates/rttp-server/src/server/server_tests.rs +++ b/crates/rttp-server/src/server/server_tests.rs @@ -479,6 +479,113 @@ fn request_sec_gpc_parses_request_metadata_without_policy() { assert!(oversized.header("Sec-GPC").is_some()); } +#[test] +fn request_sec_required_document_policy_parses_request_metadata_without_policy() { + let absent_raw = "GET /doc HTTP/1.1\r\nHost: example.test\r\n\r\n"; + let mut absent_reader = BufReader::new(Cursor::new(absent_raw.as_bytes())); + let absent = Request::read_next_from(&mut absent_reader) + .expect("absent request should parse") + .expect("absent request should be present"); + assert_eq!( + None, + absent + .sec_required_document_policy() + .expect("missing Sec-Required-Document-Policy should be accepted") + ); + assert_eq!(None, absent.header("Sec-Required-Document-Policy")); + + let valid_raw = concat!( + "GET /doc HTTP/1.1\r\n", + "Host: example.test\r\n", + "Sec-Required-Document-Policy: oversized-images=2.0, unsized-media=?0, *;report-to=default\r\n", + "\r\n" + ); + let mut valid_reader = BufReader::new(Cursor::new(valid_raw.as_bytes())); + let valid = Request::read_next_from(&mut valid_reader) + .expect("valid request should parse") + .expect("valid request should be present"); + let policy = valid + .sec_required_document_policy() + .expect("Sec-Required-Document-Policy should parse") + .expect("Sec-Required-Document-Policy should be present"); + assert_eq!( + "oversized-images=2.0, unsized-media=?0, *;report-to=default", + policy.header_value() + ); + assert_eq!( + Some("oversized-images=2.0, unsized-media=?0, *;report-to=default"), + valid.header("Sec-Required-Document-Policy") + ); + + let malformed_raw = concat!( + "GET /doc HTTP/1.1\r\n", + "Host: example.test\r\n", + "Sec-Required-Document-Policy: oversized-images=1;foo=bar\r\n", + "\r\n" + ); + let mut malformed_reader = BufReader::new(Cursor::new(malformed_raw.as_bytes())); + let malformed = Request::read_next_from(&mut malformed_reader) + .expect("malformed metadata should not reject the request frame") + .expect("malformed request should be present"); + assert!(malformed.sec_required_document_policy().is_err()); + assert_eq!( + Some("oversized-images=1;foo=bar"), + malformed.header("Sec-Required-Document-Policy") + ); + + let control = HttpRequest { + method: "GET".to_string(), + path: "/doc".to_string(), + query: None, + version: "HTTP/1.1".to_string(), + headers: vec![HttpHeader::new( + "Sec-Required-Document-Policy", + "oversized-images=2.0\r, unsized-media=?0", + )], + body: Vec::new(), + content_length: None, + }; + assert!(control.sec_required_document_policy().is_err()); + assert_eq!( + Some("oversized-images=2.0\r, unsized-media=?0"), + control.header("Sec-Required-Document-Policy") + ); + + let duplicate_raw = concat!( + "GET /doc HTTP/1.1\r\n", + "Host: example.test\r\n", + "Sec-Required-Document-Policy: oversized-images=2.0\r\n", + "sec-required-document-policy: oversized-images=3.0\r\n", + "\r\n" + ); + let mut duplicate_reader = BufReader::new(Cursor::new(duplicate_raw.as_bytes())); + let duplicate = Request::read_next_from(&mut duplicate_reader) + .expect("duplicate metadata should not reject the request frame") + .expect("duplicate request should be present"); + assert!(duplicate.sec_required_document_policy().is_err()); + assert_eq!( + Some("oversized-images=2.0"), + duplicate.header("Sec-Required-Document-Policy") + ); + + let oversized_value = + "x".repeat(rttp_protocol::document_policy::MAX_DOCUMENT_POLICY_VALUE_BYTES + 1); + let oversized = HttpRequest { + method: "GET".to_string(), + path: "/doc".to_string(), + query: None, + version: "HTTP/1.1".to_string(), + headers: vec![HttpHeader::new( + "Sec-Required-Document-Policy", + oversized_value, + )], + body: Vec::new(), + content_length: None, + }; + assert!(oversized.sec_required_document_policy().is_err()); + assert!(oversized.header("Sec-Required-Document-Policy").is_some()); +} + #[test] fn request_pragma_parses_request_metadata_without_policy() { let absent_raw = "GET /asset HTTP/1.1\r\nHost: example.test\r\n\r\n"; diff --git a/crates/rttp-server/tests/metadata_facade.rs b/crates/rttp-server/tests/metadata_facade.rs index 4c7ccacd..0a4112c9 100644 --- a/crates/rttp-server/tests/metadata_facade.rs +++ b/crates/rttp-server/tests/metadata_facade.rs @@ -46,6 +46,8 @@ use rttp_server::server::{ HttpResponseDateParseError, HttpResponseExpires, HttpResponseLastModified, HttpResponseLastModifiedParseError, HttpRetryAfter, HttpRetryAfterParseError, HttpSameSite, HttpSaveData, HttpSaveDataParseError, HttpScheduleTag, HttpSecGpc, HttpSecGpcParseError, + HttpSecRequiredDocumentPolicy, HttpSecRequiredDocumentPolicyDirective, + HttpSecRequiredDocumentPolicyParseError, HttpSecRequiredDocumentPolicyValue, HttpSecWebSocketAccept, HttpSecWebSocketAcceptParseError, HttpSecWebSocketExtensions, HttpSecWebSocketExtensionsParseError, HttpSecWebSocketKey, HttpSecWebSocketKeyParseError, HttpSecWebSocketProtocol, HttpSecWebSocketProtocolParseError, HttpSecWebSocketVersion, @@ -262,6 +264,14 @@ fn server_facade_exports_representative_bounded_metadata_types() { HttpUserAgent::parse("product/"); let sec_gpc: HttpSecGpc = HttpSecGpc::parse("1").expect("Sec-GPC should parse"); let sec_gpc_error: Result = HttpSecGpc::parse("0"); + let sec_required_document_policy: HttpSecRequiredDocumentPolicy = + HttpSecRequiredDocumentPolicy::parse( + "oversized-images=2.0, unsized-media=?0, *;report-to=default", + ) + .expect("Sec-Required-Document-Policy should parse"); + let _: HttpSecRequiredDocumentPolicyParseError = + HttpSecRequiredDocumentPolicy::parse("unsized-media=src;foo=bar") + .expect_err("unknown Sec-Required-Document-Policy parameter should be rejected"); let upgrade_insecure_requests: HttpUpgradeInsecureRequests = HttpUpgradeInsecureRequests::parse("1").expect("Upgrade-Insecure-Requests should parse"); let upgrade_insecure_requests_error: Result< @@ -594,6 +604,20 @@ fn server_facade_exports_representative_bounded_metadata_types() { assert!(user_agent_error.is_err()); assert_eq!(sec_gpc.header_value(), "1"); assert!(sec_gpc_error.is_err()); + assert_eq!(sec_required_document_policy.directives().len(), 3); + assert_eq!( + sec_required_document_policy + .directive("oversized-images") + .unwrap() + .value(), + &HttpSecRequiredDocumentPolicyValue::Decimal("2.0".to_string()) + ); + let _: &HttpSecRequiredDocumentPolicyDirective = + sec_required_document_policy.directive("*").unwrap(); + assert_eq!( + sec_required_document_policy.header_value(), + "oversized-images=2.0, unsized-media=?0, *;report-to=default" + ); assert_eq!(upgrade_insecure_requests.header_value(), "1"); assert!(upgrade_insecure_requests_error.is_err()); assert_eq!(authorization.scheme(), "Bearer"); diff --git a/crates/rttp/README.md b/crates/rttp/README.md index 273e77a5..b34a7c54 100644 --- a/crates/rttp/README.md +++ b/crates/rttp/README.md @@ -1118,6 +1118,23 @@ available. These helpers only declare or parse request metadata. RTTP does not infer or enforce consent, tracking, legal, or serving policy. +## Bounded Sec-Required-Document-Policy request metadata + +`HttpClient::sec_required_document_policy(value)` emits bounded +`Sec-Required-Document-Policy` request metadata through the shared protocol +`SecRequiredDocumentPolicy` type, replacing any existing same-name field with +the canonical value before connecting. Server-side +`Request::sec_required_document_policy()` and +`HttpRequest::sec_required_document_policy()` parse the same Document Policy +Structured Fields dictionary model and bounds into +`HttpSecRequiredDocumentPolicy`, returning `Ok(None)` when absent. +Malformed, control-bearing, duplicate, or oversized values return a parser +error while raw request headers remain available. + +These helpers only declare or parse request metadata. RTTP does not enforce +document policy, compare values against `Document-Policy`, block document +loads, or send reports. + ## Bounded Pragma metadata `HttpClient::pragma(value)` and `HttpClient::pragma_no_cache()` emit bounded @@ -1785,6 +1802,7 @@ scheduling, or async accept loops. | Metadata HTTP/1.1 interoperability matrix | Workspace integration tests cover live HTTP/1.1 client/server/facade roundtrips for `From`, `Referer`, `Accept-Patch`, `Accept-Post`, and `RateLimit-*`, including canonical wire values, ordered typed values, raw escape hatches, absence as `Ok(None)`, malformed peer input with raw preservation, pre-connect builder rejection, and sync/async client parity | No identity, referrer, method-negotiation, or quota policy | | Accept-Encoding | `HttpRequestAcceptEncodings`, `Request::accept_encoding`, and `HttpRequest::accept_encoding` parse bounded `Accept-Encoding` request metadata through the shared `rttp-protocol` type | No compression, decompression, content negotiation, retries, or transport changes | | Sec-GPC | `HttpClient::sec_gpc`, `Request::sec_gpc`, and `HttpRequest::sec_gpc` share the bounded protocol `Sec-GPC` `1`-signal representation and preserve raw values on errors | No consent inference, tracking-policy enforcement, legal policy, serving policy, retries, or browser state | +| Sec-Required-Document-Policy | `HttpClient::sec_required_document_policy`, `Request::sec_required_document_policy`, and `HttpRequest::sec_required_document_policy` share the bounded Document Policy Structured Fields request metadata type, replace existing same-name fields on emission, and preserve raw values on errors | No document-policy enforcement, required-policy comparison against `Document-Policy`, document-load blocking, feature enablement, or report sending | | Pragma | `HttpClient::pragma`/`pragma_no_cache`, `Request::pragma`, `HttpRequest::pragma`, `HttpResponse::with_pragma`, and `HttpResponse::pragma` share the bounded protocol `Pragma` representation across client construction, server access, server response declaration, and client response access, combining fields in wire order and preserving raw headers on errors | No translation into `Cache-Control`, cache storage, freshness checks, revalidation, or cache/intermediary policy | | Content-Location | `HttpResponse::with_content_location` declares one bounded singleton `Content-Location` header, and `HttpResponse::content_location` parses attached singleton response metadata while preserving raw headers | No redirect behavior, cache variant selection, representation replacement, retry/replay, route generation, or status-policy behavior | | Service-Worker-Allowed | `HttpResponse::with_service_worker_allowed` declares one bounded singleton `Service-Worker-Allowed` header, and `HttpResponse::service_worker_allowed` plus client `Response::service_worker_allowed` parse attached singleton path metadata while preserving raw headers | No service-worker registration, scope evaluation, script-URL resolution, or application routing policy | diff --git a/crates/rttp/src/lib.rs b/crates/rttp/src/lib.rs index cf36e825..ed983881 100644 --- a/crates/rttp/src/lib.rs +++ b/crates/rttp/src/lib.rs @@ -62,7 +62,9 @@ pub use rttp_client::{ IfScheduleTagMatchParseError, IfStateToken, LockToken, LockTokenParseError, Negotiate, NegotiateDirective, NegotiateParseError, Overwrite, OverwriteParseError, Referer, RefererParseError, SecFetchDest, SecFetchMode, SecFetchSite, SecFetchUser, SecPurpose, - SecWebSocketKey, SecWebSocketKeyParseError, Timeout, TimeoutParseError, TimeoutType, TraceParent, + SecRequiredDocumentPolicy, SecRequiredDocumentPolicyDirective, + SecRequiredDocumentPolicyParseError, SecRequiredDocumentPolicyValue, SecWebSocketKey, + SecWebSocketKeyParseError, Timeout, TimeoutParseError, TimeoutType, TraceParent, TraceParentParseError, TraceState, TraceStateMember, TraceStateParseError, UserAgent, UserAgentMember, UserAgentParseError, Via, ViaMember, ViaParseError, XForwardedFor, XForwardedForNode, XForwardedForNodeKind, XForwardedForParseError, XForwardedHost, diff --git a/crates/rttp/tests/metadata_facade.rs b/crates/rttp/tests/metadata_facade.rs index 0c35ad3a..dfb27ac8 100644 --- a/crates/rttp/tests/metadata_facade.rs +++ b/crates/rttp/tests/metadata_facade.rs @@ -24,20 +24,22 @@ use rttp::server::{ HttpRateLimitLimitParseError, HttpRateLimitParseError, HttpRateLimitRemaining, HttpRateLimitRemainingParseError, HttpRateLimitReset, HttpRateLimitResetParseError, HttpReferer, HttpRefererParseError, HttpRequest, HttpRequestAcceptCharsets, HttpResponse, HttpSameSite, - HttpSaveData, HttpScheduleTag, HttpSecGpc, HttpSecGpcParseError, HttpSecWebSocketAccept, - HttpSecWebSocketAcceptParseError, HttpSecWebSocketExtensions, - HttpSecWebSocketExtensionsParseError, HttpSecWebSocketKey, HttpSecWebSocketKeyParseError, - HttpSecWebSocketProtocol, HttpSecWebSocketProtocolParseError, HttpSecWebSocketVersion, - HttpSecWebSocketVersionParseError, HttpServiceWorkerAllowed, HttpServiceWorkerAllowedParseError, - HttpSetCookie, HttpSetCookies, HttpSignature, HttpSignatureInput, HttpSignatureInputBareItem, - HttpSignatureInputComponent, HttpSignatureInputEntry, HttpSignatureInputParameter, - HttpSignatureInputParseError, HttpSignatureParseError, HttpSpeculationRules, - HttpSpeculationRulesParseError, HttpSunsetParseError, HttpSupportsLoadingMode, - HttpSupportsLoadingModeParseError, HttpTcn, HttpTcnDirective, HttpTcnParseError, HttpTimeout, - HttpTimeoutParseError, HttpTimeoutType, HttpUpgrade, HttpUpgradeInsecureRequests, - HttpUpgradeInsecureRequestsParseError, HttpUpgradeParseError, HttpUserAgent, HttpUserAgentMember, - HttpUserAgentParseError, HttpVariantVary, HttpVariantVaryParseError, HttpVia, HttpViaParseError, - HttpXForwardedFor, HttpXForwardedForParseError, HttpXForwardedHost, HttpXForwardedHostParseError, + HttpSaveData, HttpScheduleTag, HttpSecGpc, HttpSecGpcParseError, HttpSecRequiredDocumentPolicy, + HttpSecRequiredDocumentPolicyDirective, HttpSecRequiredDocumentPolicyParseError, + HttpSecRequiredDocumentPolicyValue, HttpSecWebSocketAccept, HttpSecWebSocketAcceptParseError, + HttpSecWebSocketExtensions, HttpSecWebSocketExtensionsParseError, HttpSecWebSocketKey, + HttpSecWebSocketKeyParseError, HttpSecWebSocketProtocol, HttpSecWebSocketProtocolParseError, + HttpSecWebSocketVersion, HttpSecWebSocketVersionParseError, HttpServiceWorkerAllowed, + HttpServiceWorkerAllowedParseError, HttpSetCookie, HttpSetCookies, HttpSignature, + HttpSignatureInput, HttpSignatureInputBareItem, HttpSignatureInputComponent, + HttpSignatureInputEntry, HttpSignatureInputParameter, HttpSignatureInputParseError, + HttpSignatureParseError, HttpSpeculationRules, HttpSpeculationRulesParseError, + HttpSunsetParseError, HttpSupportsLoadingMode, HttpSupportsLoadingModeParseError, HttpTcn, + HttpTcnDirective, HttpTcnParseError, HttpTimeout, HttpTimeoutParseError, HttpTimeoutType, + HttpUpgrade, HttpUpgradeInsecureRequests, HttpUpgradeInsecureRequestsParseError, + HttpUpgradeParseError, HttpUserAgent, HttpUserAgentMember, HttpUserAgentParseError, + HttpVariantVary, HttpVariantVaryParseError, HttpVia, HttpViaParseError, HttpXForwardedFor, + HttpXForwardedForParseError, HttpXForwardedHost, HttpXForwardedHostParseError, HttpXForwardedProto, HttpXForwardedProtoParseError, }; use std::io::Write; @@ -525,6 +527,14 @@ fn compatibility_facade_exports_client_metadata_types() { let dnt: rttp::Dnt = rttp_client::Dnt::parse("1").expect("DNT should parse"); let _: rttp::DntParseError = rttp_client::Dnt::parse("on").expect_err("invalid DNT should be rejected"); + let sec_required_document_policy: rttp::SecRequiredDocumentPolicy = + rttp_client::SecRequiredDocumentPolicy::parse( + "oversized-images=2.0, unsized-media=?0, *;report-to=default", + ) + .expect("Sec-Required-Document-Policy should parse"); + let _: rttp::SecRequiredDocumentPolicyParseError = + rttp_client::SecRequiredDocumentPolicy::parse("unsized-media=src;foo=bar") + .expect_err("unknown Sec-Required-Document-Policy parameter should be rejected"); let referer: rttp::Referer = rttp_client::Referer::parse("https://shop.example/checkout?step=pay") .expect("Referer should parse"); @@ -802,6 +812,18 @@ fn compatibility_facade_exports_client_metadata_types() { .value(), &rttp::DocumentPolicyReportOnlyValue::Decimal("2.0".to_string()) ); + assert_eq!(sec_required_document_policy.directives().len(), 3); + assert_eq!( + sec_required_document_policy + .directive("oversized-images") + .unwrap() + .value(), + &rttp::SecRequiredDocumentPolicyValue::Decimal("2.0".to_string()) + ); + assert_eq!( + sec_required_document_policy.header_value(), + "oversized-images=2.0, unsized-media=?0, *;report-to=default" + ); assert_eq!( supports_loading_mode.tokens(), ["fenced-frame", "credentialed-prerender"] @@ -1244,6 +1266,56 @@ fn compatibility_facade_rejects_invalid_sec_gpc_request_metadata() { ); } +#[test] +#[cfg(feature = "client")] +fn compatibility_facade_roundtrips_sec_required_document_policy_request_metadata() { + let (addr, handle) = spawn_representation_metadata_response_server( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK".to_vec(), + ); + let response = rttp::Http::client() + .get() + .url(format!("http://{addr}/doc")) + .sec_required_document_policy("oversized-images=2.0, unsized-media=?0, *;report-to=default") + .expect("Sec-Required-Document-Policy should be accepted") + .emit() + .expect("client request should complete"); + let captured_request = handle + .join() + .expect("Sec-Required-Document-Policy capture server should join"); + let captured_request_text = + String::from_utf8(captured_request.clone()).expect("request should be utf-8"); + + assert_eq!(response.body().string().expect("response body"), "OK"); + assert_eq!( + Some("oversized-images=2.0, unsized-media=?0, *;report-to=default"), + header_value(&captured_request_text, "Sec-Required-Document-Policy") + ); + let server_request = + rttp::server::HttpRequest::parse(&captured_request).expect("server request should parse"); + let policy = server_request + .sec_required_document_policy() + .expect("server Sec-Required-Document-Policy should parse") + .expect("server Sec-Required-Document-Policy should be present"); + assert_eq!( + policy.header_value(), + "oversized-images=2.0, unsized-media=?0, *;report-to=default" + ); + assert_eq!(policy.len(), 3); + + let malformed = rttp::server::HttpRequest::parse( + b"GET /doc HTTP/1.1\r\nHost: example.test\r\nSec-Required-Document-Policy: oversized-images=1;foo=bar\r\n\r\n", + ) + .expect("malformed Sec-Required-Document-Policy request should still parse"); + assert_eq!( + malformed.header("Sec-Required-Document-Policy"), + Some("oversized-images=1;foo=bar") + ); + assert!( + malformed.sec_required_document_policy().is_err(), + "malformed Sec-Required-Document-Policy values must fail closed" + ); +} + #[test] #[cfg(feature = "client")] fn compatibility_facade_roundtrips_depth_request_metadata_without_policy() { @@ -2036,6 +2108,14 @@ fn compatibility_facade_keeps_server_metadata_in_the_server_module() { let sec_gpc: HttpSecGpc = HttpSecGpc::parse("1").expect("Sec-GPC should parse"); let _: HttpSecGpcParseError = HttpSecGpc::parse("0").expect_err("invalid Sec-GPC should be rejected"); + let sec_required_document_policy: HttpSecRequiredDocumentPolicy = + HttpSecRequiredDocumentPolicy::parse( + "oversized-images=2.0, unsized-media=?0, *;report-to=default", + ) + .expect("Sec-Required-Document-Policy should parse"); + let _: HttpSecRequiredDocumentPolicyParseError = + HttpSecRequiredDocumentPolicy::parse("unsized-media=src;foo=bar") + .expect_err("unknown Sec-Required-Document-Policy parameter should be rejected"); let upgrade_insecure_requests: HttpUpgradeInsecureRequests = HttpUpgradeInsecureRequests::parse("1").expect("Upgrade-Insecure-Requests should parse"); let _: Result = @@ -2235,6 +2315,20 @@ fn compatibility_facade_keeps_server_metadata_in_the_server_module() { ); assert!(referer_error.is_err()); assert_eq!(sec_gpc.header_value(), "1"); + assert_eq!(sec_required_document_policy.directives().len(), 3); + assert_eq!( + sec_required_document_policy + .directive("oversized-images") + .unwrap() + .value(), + &HttpSecRequiredDocumentPolicyValue::Decimal("2.0".to_string()) + ); + let _: &HttpSecRequiredDocumentPolicyDirective = + sec_required_document_policy.directive("*").unwrap(); + assert_eq!( + sec_required_document_policy.header_value(), + "oversized-images=2.0, unsized-media=?0, *;report-to=default" + ); assert_eq!(upgrade_insecure_requests.header_value(), "1"); assert_eq!(authorization.header_value(), "Bearer origin-token"); assert_eq!(proxy_authorization.header_value(), "Basic cHJveHk6c2VjcmV0"); diff --git a/tests/http11_client_server_matrix.rs b/tests/http11_client_server_matrix.rs index ac89d03c..dee4376c 100644 --- a/tests/http11_client_server_matrix.rs +++ b/tests/http11_client_server_matrix.rs @@ -15,8 +15,8 @@ use rttp_server::server::{ HttpByteRangeError, HttpConditionalMetadata, HttpConditionalRequestOutcome, HttpContentDisposition, HttpContentType, HttpDav, HttpDepth, HttpDestination, HttpEntityTag, HttpIf, HttpIfRangeRequestOutcome, HttpIfScheduleTagMatch, HttpLockToken, HttpOverwrite, - HttpResponse, HttpScheduleTag, HttpTimeout, Request, SecFetchDest, SecFetchMode, SecFetchSite, - SecPurpose, + HttpResponse, HttpScheduleTag, HttpSecRequiredDocumentPolicyValue, HttpTimeout, Request, + SecFetchDest, SecFetchMode, SecFetchSite, SecPurpose, }; use rttp_test_support as fixtures; @@ -2284,6 +2284,162 @@ fn sync_client_dnt_is_observed_by_server_helpers() { } } +#[derive(Debug, PartialEq)] +struct ObservedSecRequiredDocumentPolicy { + target: String, + raw: Option, + parsed: Result, String>, + directives: Option>, +} + +fn observe_sec_required_document_policy(request: &Request) -> ObservedSecRequiredDocumentPolicy { + let parsed = request.sec_required_document_policy(); + let directives = parsed.as_ref().ok().and_then(|policy| { + policy.as_ref().map(|policy| { + policy + .directives() + .iter() + .map(|directive| { + let value = match directive.value() { + HttpSecRequiredDocumentPolicyValue::Boolean(value) => format!("bool:{value}"), + HttpSecRequiredDocumentPolicyValue::Integer(value) => format!("int:{value}"), + HttpSecRequiredDocumentPolicyValue::Decimal(value) => { + format!("decimal:{value}") + } + HttpSecRequiredDocumentPolicyValue::Token(value) => format!("token:{value}"), + }; + (directive.name().to_string(), value) + }) + .collect() + }) + }); + ObservedSecRequiredDocumentPolicy { + target: request.target().to_string(), + raw: request + .header("Sec-Required-Document-Policy") + .map(str::to_string), + parsed: parsed + .map(|metadata| metadata.map(|metadata| metadata.header_value())) + .map_err(|error| error.to_string()), + directives, + } +} + +fn spawn_sec_required_document_policy_observer() -> ( + std::net::SocketAddr, + mpsc::Receiver, + thread::JoinHandle<()>, +) { + let server = rttp_server::server::HttpServer::bind("127.0.0.1:0") + .expect("bind Sec-Required-Document-Policy metadata server"); + let addr = server + .local_addr() + .expect("Sec-Required-Document-Policy metadata server addr"); + let (observed_tx, observed_rx) = mpsc::channel(); + let handle = thread::spawn(move || { + server + .accept_one(|request| { + observed_tx + .send(observe_sec_required_document_policy(&request)) + .expect("send observed Sec-Required-Document-Policy metadata"); + HttpResponse::ok("OK") + }) + .expect("serve Sec-Required-Document-Policy request"); + }); + + (addr, observed_rx, handle) +} + +#[test] +fn facade_client_and_server_exchange_sec_required_document_policy_request_metadata() { + let (addr, observed_rx, handle) = spawn_sec_required_document_policy_observer(); + + let response = client() + .get() + .url(format!("http://{addr}/doc")) + .sec_required_document_policy("oversized-images=2.0, unsized-media=?0, *;report-to=default") + .expect("Sec-Required-Document-Policy should be accepted") + .emit() + .expect("Sec-Required-Document-Policy request should succeed"); + + assert_eq!("OK", response.body().string().expect("response body")); + assert_eq!( + ObservedSecRequiredDocumentPolicy { + target: "/doc".to_string(), + raw: Some("oversized-images=2.0, unsized-media=?0, *;report-to=default".to_string()), + parsed: Ok(Some( + "oversized-images=2.0, unsized-media=?0, *;report-to=default".to_string() + )), + directives: Some(vec![ + ("oversized-images".to_string(), "decimal:2.0".to_string()), + ("unsized-media".to_string(), "bool:false".to_string()), + ("*".to_string(), "bool:true".to_string()), + ]), + }, + observed_rx + .recv_timeout(Duration::from_secs(1)) + .expect("server should observe Sec-Required-Document-Policy metadata") + ); + handle + .join() + .expect("Sec-Required-Document-Policy server thread"); +} + +#[test] +fn facade_server_rejects_malformed_sec_required_document_policy_without_losing_raw_headers() { + let (addr, observed_rx, handle) = spawn_sec_required_document_policy_observer(); + + let mut stream = + TcpStream::connect(addr).expect("connect malformed Sec-Required-Document-Policy"); + stream + .write_all( + b"GET /doc HTTP/1.1\r\nHost: example.test\r\nSec-Required-Document-Policy: oversized-images=1;foo=bar\r\nConnection: close\r\n\r\n", + ) + .expect("write malformed Sec-Required-Document-Policy request"); + + assert_eq!( + ObservedSecRequiredDocumentPolicy { + target: "/doc".to_string(), + raw: Some("oversized-images=1;foo=bar".to_string()), + parsed: Err("invalid Sec-Required-Document-Policy dictionary member".to_string()), + directives: None, + }, + observed_rx + .recv_timeout(Duration::from_secs(1)) + .expect("server should observe malformed Sec-Required-Document-Policy") + ); + handle + .join() + .expect("malformed Sec-Required-Document-Policy server thread"); +} + +#[test] +fn facade_server_reports_absent_sec_required_document_policy_metadata() { + let (addr, observed_rx, handle) = spawn_sec_required_document_policy_observer(); + + let response = client() + .get() + .url(format!("http://{addr}/doc")) + .emit() + .expect("request without Sec-Required-Document-Policy should succeed"); + + assert_eq!("OK", response.body().string().expect("response body")); + assert_eq!( + ObservedSecRequiredDocumentPolicy { + target: "/doc".to_string(), + raw: None, + parsed: Ok(None), + directives: None, + }, + observed_rx + .recv_timeout(Duration::from_secs(1)) + .expect("server should observe absent Sec-Required-Document-Policy") + ); + handle + .join() + .expect("absent Sec-Required-Document-Policy server thread"); +} + #[test] fn facade_server_rejects_malformed_dnt_without_losing_raw_headers() { let (addr, observed_rx, handle) = spawn_dnt_observer();