From 1517df6182ecb757753472b0112c1171f5bd08fe Mon Sep 17 00:00:00 2001 From: fewensa Date: Fri, 11 Sep 2026 17:02:12 +0800 Subject: [PATCH] Add bounded Sec-Required-Document-Policy request metadata codeon: version: 1 authority: FWN-371 description: |- Reuse the Document-Policy Structured Fields dictionary parser for Sec-Required-Document-Policy with distinct types and errors. Wire client pre-I/O validation, server request accessors with raw preservation, facade exports, docs, and HTTP/1.1 round-trip coverage without enforcement or Document-Policy comparison. --- README.md | 48 +++++ crates/rttp-client/README.md | 17 ++ crates/rttp-client/src/client.rs | 36 ++++ crates/rttp-client/src/lib.rs | 4 + crates/rttp-client/tests/metadata_facade.rs | 32 +++- .../tests/test_raw_request_capture.rs | 178 ++++++++++++++++++ crates/rttp-protocol/README.md | 6 + crates/rttp-protocol/src/lib.rs | 1 + .../src/sec_required_document_policy.rs | 87 +++++++++ crates/rttp-protocol/tests/metadata_facade.rs | 22 +++ .../tests/sec_required_document_policy.rs | 113 +++++++++++ crates/rttp-server/README.md | 20 ++ crates/rttp-server/src/server/request.rs | 39 ++++ crates/rttp-server/src/server/server_tests.rs | 111 +++++++++++ crates/rttp-server/tests/metadata_facade.rs | 24 +++ crates/rttp/src/lib.rs | 4 +- crates/rttp/tests/metadata_facade.rs | 16 ++ crates/rttp/tests/test_server_models.rs | 60 ++++++ tests/http11_client_server_matrix.rs | 52 +++++ 19 files changed, 863 insertions(+), 7 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/README.md b/README.md index 0d95425b..c5fb58aa 100644 --- a/README.md +++ b/README.md @@ -1656,6 +1656,28 @@ These helpers only declare or parse metadata. RTTP does not translate `Pragma` into `Cache-Control`, store cache entries, or apply cache, freshness, revalidation, intermediary, or HTTP/1.0 compatibility policy. +### Bounded Sec-Required-Document-Policy request metadata + +`rttp-protocol` owns the shared `SecRequiredDocumentPolicy` primitive. Client +helpers format through that type, and server `Request` / `HttpRequest` helpers +parse with the same Document Policy Structured Fields dictionary rules. + +`HttpClient::sec_required_document_policy(value)` emits bounded +`Sec-Required-Document-Policy` request metadata, combining and replacing +already-attached same-name fields with one canonical value. On the server, +`Request::sec_required_document_policy()` and +`HttpRequest::sec_required_document_policy()` parse received fields into +`HttpSecRequiredDocumentPolicy`. Absent fields return `Ok(None)`. Multiple +fields are combined in wire order, duplicate directive names are rejected, +each field value is bounded to 64 KiB, combined raw bytes are bounded to +64 KiB, and the combined directive count is bounded to 256. Malformed members, +control bytes, unknown parameters, empty dictionaries, and bound violations +return a parser error while raw headers remain available. + +These helpers only declare or parse request metadata. RTTP does not enforce +document policy, compare required policies with `Document-Policy`, block +document loads, disable browser features, or echo response fields. + ### Bounded Upgrade-Insecure-Requests request metadata `HttpClient::upgrade_insecure_requests()` emits `Upgrade-Insecure-Requests: 1`. @@ -1908,6 +1930,7 @@ gain additional HTTP/2 header-block handling. | Permissions-Policy | `Response::permissions_policy` parses bounded W3C Permissions Policy dictionary metadata through the shared protocol type, combining fields in wire order and preserving raw headers on parse failures | No browser permission grants or denials, origin comparison, `self` resolution, API enablement, origin-policy enforcement, or report sending | | Document-Policy | `Response::document_policy` parses bounded WICG Document Policy dictionary metadata through the shared protocol type, combining fields in wire order, retaining `*` and `report-to`, and preserving raw headers on parse failures | No configuration-point execution, document-load blocking, required-policy comparison, `Sec-Required-Document-Policy` echoing, feature enablement, or report sending | | Document-Policy-Report-Only | `Response::document_policy_report_only` parses bounded WICG Document Policy Report-Only dictionary metadata through the same shared protocol parser and formatter, retaining report-only type identity, `*`, and `report-to`, and preserving raw headers on parse failures | No policy enforcement, document-load blocking, required-policy comparison, `Sec-Required-Document-Policy` echoing, feature enablement, report delivery, scheduling, retry, or endpoint validation | +| Sec-Required-Document-Policy | Client `sec_required_document_policy` and server `Request::sec_required_document_policy` / `HttpRequest::sec_required_document_policy` share the bounded protocol `SecRequiredDocumentPolicy` representation, combining fields in wire order and preserving raw headers on errors | No document-policy enforcement, required-policy comparison with `Document-Policy`, document-load blocking, feature enablement, or response echoing | | Supports-Loading-Mode | `Response::supports_loading_mode` parses bounded Structured Fields token-list response metadata through the shared protocol type, combining fields in wire order, retaining unknown tokens, and preserving raw headers on parse failures | No prerendering, fenced-frame admission, navigation changes, redirects, retries, or resource-loading behavior | | Trailers | Chunked response trailers are exposed for blocking and async APIs; streaming chunked uploads can send declared request trailers | Application metadata trailers such as `X-Trace` are allowed; pseudo-header, connection-specific, routing, authentication/cookie, and framing trailer fields are rejected | | Bounded h2c client | With `http2`, direct `socket2` h2c sends GET, HEAD, bodyless DELETE, OPTIONS, or TRACE, buffered POST, PUT, or PATCH requests, and opt-in RFC 8441 extended CONNECT request HEADERS via `http2_extended_connect`, opens at most one request stream, supports prior-knowledge with `emit_http2_prior_knowledge`, supports explicit HTTP/1.1 `Upgrade: h2c` negotiation with `emit_http2_upgrade`, advertises `SETTINGS_ENABLE_PUSH = 0`, advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL = 1` only for the explicit extended CONNECT path, validates received `SETTINGS_ENABLE_PUSH` values as only `0` or `1`, honors initial peer `SETTINGS_MAX_CONCURRENT_STREAMS` by failing before request HEADERS when the peer allows zero streams, honors peer-advertised `SETTINGS_MAX_HEADER_LIST_SIZE` request metadata limits, accepts only legal `SETTINGS_MAX_FRAME_SIZE` values from 16,384 through 16,777,215 bytes, splits outbound HEADERS, DATA, and trailers to the active peer frame-size limit, rejects oversized inbound frames when a configured local frame-size limit is exceeded, bounds HPACK dynamic table use with `SETTINGS_HEADER_TABLE_SIZE`, strips HTTP/1.x connection-specific request fields before emission, rejects connection-specific peer response fields, suppresses HEAD response bodies, treats `RST_STREAM` on the active stream as a bounded reset/cancellation signal, acknowledges inbound PING without ACK on stream 0 and exactly 8 octets with matching opaque data, ignores inbound PING ACK, rejects malformed PING frames, DATA bodies, trailers, HPACK static Huffman strings, bounded large header blocks, padded incoming frames, `GOAWAY` shutdown boundaries, PRIORITY metadata validation without scheduling, HTTP/2-allowed unknown/extension frame ignoring inside this bounded path, reserved stream-id high-bit normalization, and conservative DATA flow control | Ordinary `CONNECT`, header-configured `:protocol` metadata, non-h2c HTTP/1.1 `Upgrade` handoff requests, and proxies are rejected deterministically, and `PUSH_PROMISE`/server push is rejected instead of managed; bounded direct h2c only, with no keepalive timers, no automatic client/server initiated PING policy, no public cancellation callback API, no dynamic policy API, no extension callback API, no full extension negotiation, TLS ALPN, external h2 integration, proxy tunneling to h2, proxy h2, tunnel handoff, connection pooling, persistent HTTP/2 session management, automatic retry/replay, server push, full session manager, full stream state machine, full multiplex scheduler, unbounded multiplex scheduling, general multiplexing, priority scheduling, request bodies or trailers for extended CONNECT, or request bodies for GET, HEAD, DELETE, OPTIONS, or TRACE | @@ -2563,6 +2586,30 @@ These helpers declare and parse metadata only. They do not translate `Pragma` into `Cache-Control`, store cache entries, or apply cache, freshness, revalidation, intermediary, or HTTP/1.0 compatibility policy. +### Bounded Sec-Required-Document-Policy request metadata + +Server-side `Sec-Required-Document-Policy` helpers expose request metadata +through the shared `rttp-protocol` primitive. +`Request::sec_required_document_policy()` and +`HttpRequest::sec_required_document_policy()` parse received fields in wire +order into `HttpSecRequiredDocumentPolicy`. Absent fields return `Ok(None)`. +HTTP/1.1 and HTTP/2 share the same `Request` helpers. The shared protocol type +is the authority for directive, parameter, duplicate, member-count, and size +validation. + +Parsing is bounded and validation-oriented. Each field value is limited to +64 KiB, combined raw bytes are limited to 64 KiB, and the combined directive +count is limited to 256. Empty dictionaries, malformed members, unknown +parameters, duplicate names, control bytes, and bound violations return +`HttpSecRequiredDocumentPolicyParseError` from the helper. Raw +`Request::header("Sec-Required-Document-Policy")` values remain preserved +exactly as ordinary headers; helper parse errors do not remove existing +headers. + +These helpers parse request metadata only. They do not enforce document +policy, compare required policies with `Document-Policy`, block document +loads, disable browser features, or echo response fields. + ### Bounded Accept-Encoding request metadata Server-side `Accept-Encoding` helpers expose request metadata through the @@ -3165,6 +3212,7 @@ TLS or async accept loops. | Permissions-Policy | `HttpPermissionsPolicy`, `HttpResponse::with_permissions_policy`, and `HttpResponse::permissions_policy` parse and declare bounded W3C Permissions Policy dictionary response metadata through the shared protocol type, replacing raw duplicates on declaration and preserving raw headers on parse failures | No browser permission grants or denials, origin comparison, `self` resolution, API enablement, origin-policy enforcement, or report sending | | Document-Policy | `HttpDocumentPolicy`, `HttpResponse::with_document_policy`, and `HttpResponse::document_policy` parse and declare bounded WICG Document Policy dictionary response metadata through the shared protocol type, replacing raw duplicates on declaration, retaining `*` and `report-to`, and preserving raw headers on parse failures | No configuration-point execution, document-load blocking, required-policy comparison, `Sec-Required-Document-Policy` echoing, feature enablement, or report sending | | Document-Policy-Report-Only | `HttpDocumentPolicyReportOnly`, `HttpResponse::with_document_policy_report_only`, and `HttpResponse::document_policy_report_only` parse and declare bounded WICG Document Policy Report-Only dictionary metadata through the same shared protocol parser and formatter, replacing raw duplicates on declaration, retaining report-only type identity, `*`, and `report-to`, and preserving raw headers on parse failures | No policy enforcement, document-load blocking, required-policy comparison, `Sec-Required-Document-Policy` echoing, feature enablement, report delivery, scheduling, retry, or endpoint validation | +| Sec-Required-Document-Policy | `HttpSecRequiredDocumentPolicy`, `Request::sec_required_document_policy`, and `HttpRequest::sec_required_document_policy` parse bounded WICG required Document Policy dictionary request metadata through the shared protocol type, combining fields in wire order and preserving raw headers on parse failures | No document-policy enforcement, required-policy comparison with `Document-Policy`, document-load blocking, feature enablement, or response echoing | | Supports-Loading-Mode | `HttpSupportsLoadingMode`, `HttpResponse::with_supports_loading_mode`, and `HttpResponse::supports_loading_mode` parse and declare bounded Structured Fields token-list response metadata through the shared protocol type, replacing raw duplicates on declaration, retaining unknown tokens, and preserving raw headers on parse failures | No prerendering, fenced-frame admission, navigation changes, redirects, retries, or resource-loading behavior | | Allow | `HttpAllowedMethods`, `HttpResponse::with_allow`, and `HttpResponse::allow` declare and parse bounded `Allow` method-list metadata | No route dispatch, automatic `405` generation, `OPTIONS` policy, fallback method selection, retry/replay, or status-code policy engine | | Content-Security-Policy-Report-Only | `HttpContentSecurityPolicyReportOnly`, `HttpResponse::with_content_security_policy_report_only`, `content_security_policy_report_only`, and client `Response::content_security_policy_report_only` parse or declare bounded opaque `Content-Security-Policy-Report-Only` response metadata while preserving repeated fields in wire order and raw headers on parse failures | No CSP enforcement, directive evaluation, report delivery, browser policy state, retry, redirect, cache behavior, or status-policy behavior | diff --git a/crates/rttp-client/README.md b/crates/rttp-client/README.md index 448768e0..a60b321a 100644 --- a/crates/rttp-client/README.md +++ b/crates/rttp-client/README.md @@ -1388,6 +1388,23 @@ into `Cache-Control`, store cache entries, or apply cache, intermediary, or HTTP/1.0 compatibility policy. Callers that need unusual values can retain raw-header control with `header(("Pragma", "..."))`. +## Bounded Sec-Required-Document-Policy request metadata + +`HttpClient::sec_required_document_policy(value)` validates WICG Document +Policy Structured Fields dictionary metadata through the shared protocol +`SecRequiredDocumentPolicy` type and emits one normalized +`Sec-Required-Document-Policy` field. Already-attached +`Sec-Required-Document-Policy` fields are combined in wire order and replaced +by that single canonical field, so duplicate directive names, unknown +parameters, control bytes, empty dictionaries, and per-field or combined-size +bound violations fail before a socket opens. + +This helper only declares request metadata. RTTP does not enforce document +policy, compare required policies with `Document-Policy`, block document +loads, disable browser features, or echo response fields. Callers that need +unusual values can retain raw-header control with +`header(("Sec-Required-Document-Policy", "..."))`. + ## Bounded HTTP/1.1 Content-Disposition behavior `Response::content_disposition()` parses a singleton response diff --git a/crates/rttp-client/src/client.rs b/crates/rttp-client/src/client.rs index 5f8162c0..0898cde4 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; @@ -577,6 +578,41 @@ impl HttpClient { self.pragma("no-cache") } + /// 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 dictionary model and bounds as `Document-Policy`. Any + /// already-attached `Sec-Required-Document-Policy` fields are combined in + /// wire order and replaced by one canonical field. Malformed members, + /// control bytes, duplicate directive names, and per-field or combined-size + /// bound violations fail before a socket opens. This declares request + /// metadata only; it does not enforce document policy, compare required + /// policies with `Document-Policy`, or echo response fields. Use `header` + /// directly for unusual values. + pub fn sec_required_document_policy>( + &mut self, + value: S, + ) -> error::Result<&mut Self> { + let mut values: Vec = self + .request + .headers() + .iter() + .filter(|header| header.name().eq_ignore_ascii_case("Sec-Required-Document-Policy")) + .map(|header| header.value().clone()) + .collect(); + values.push(value.as_ref().to_string()); + let policy = SecRequiredDocumentPolicy::parse_values(values.iter().map(String::as_str)) + .map_err(|error| error::builder_with_message(error.to_string()))?; + let headers = self.request.headers_mut(); + headers.retain(|header| !header.name().eq_ignore_ascii_case("Sec-Required-Document-Policy")); + headers.push(Header::new( + "Sec-Required-Document-Policy", + policy.header_value(), + )); + Ok(self) + } + /// Append a validated `Accept` media range with its supplied quality value. /// /// This declares request metadata only; it does not select a response 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 7f0482d7..4090c8b1 100644 --- a/crates/rttp-client/tests/metadata_facade.rs +++ b/crates/rttp-client/tests/metadata_facade.rs @@ -49,12 +49,13 @@ 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, + SecFetchUser, SecGpc, SecGpcParseError, SecPurpose, SecRequiredDocumentPolicy, + 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; @@ -331,6 +332,13 @@ fn response_facade_exports_representative_bounded_metadata_types() { let _: DocumentPolicyReportOnlyParseError = DocumentPolicyReportOnly::parse("unsized-media=src;foo=bar") .expect_err("unknown Document-Policy-Report-Only parameter 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 supports_loading_mode = SupportsLoadingMode::parse("fenced-frame, credentialed-prerender") .expect("Supports-Loading-Mode should parse"); let _: SupportsLoadingModeParseError = @@ -648,6 +656,18 @@ fn response_facade_exports_representative_bounded_metadata_types() { document_policy_report_only.header_value(), "oversized-images=2.0, unsized-media=?0, *;report-to=default" ); + 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()) + ); + 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"] diff --git a/crates/rttp-client/tests/test_raw_request_capture.rs b/crates/rttp-client/tests/test_raw_request_capture.rs index e74e0ea1..75a4604d 100644 --- a/crates/rttp-client/tests/test_raw_request_capture.rs +++ b/crates/rttp-client/tests/test_raw_request_capture.rs @@ -4681,6 +4681,184 @@ fn pragma_helper_rejects_combined_fields_that_exceed_total_size() { ); } +#[test] +fn sec_required_document_policy_helper_emits_canonical_metadata() { + let request = capture_request(|base_url| { + client() + .get() + .url(format!("{}/asset", 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") + ); +} + +#[test] +fn sec_required_document_policy_helper_combines_existing_fields_and_replaces_them() { + let request = capture_request(|base_url| { + client() + .get() + .url(format!("{}/asset", base_url)) + .sec_required_document_policy("oversized-images=2.0, unsized-media=?0") + .expect("first Sec-Required-Document-Policy should be accepted") + .sec_required_document_policy("*;report-to=default") + .expect("second 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.matches("Sec-Required-Document-Policy:").count(), + "combined Sec-Required-Document-Policy fields must be replaced by one field" + ); +} + +#[test] +fn sec_required_document_policy_helper_combines_existing_raw_fields_into_one_field() { + let request = capture_request(|base_url| { + client() + .get() + .url(format!("{}/asset", base_url)) + .header(("Sec-Required-Document-Policy", "oversized-images=2.0")) + .sec_required_document_policy("unsized-media=?0") + .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"), + header_value(&request, "Sec-Required-Document-Policy"), + "the typed helper must combine existing same-name fields with the new value" + ); + assert_eq!( + 1, + request.matches("Sec-Required-Document-Policy:").count(), + "combined Sec-Required-Document-Policy fields must be replaced by one field" + ); +} + +#[test] +fn sec_required_document_policy_helper_rejects_invalid_values_before_connecting() { + for value in [ + "", + " ", + "oversized-images=()", + "oversized-images=src;foo=bar", + "Oversized-Images=2.0", + "oversized-images=2.0, oversized-images=3.0", + "oversized-images=2.0\r\nX-Injected: 1", + "oversized-images=2.0\u{1}", + ] { + let request = capture_optional_request(|base_url| { + let mut client = client(); + let error = client + .get() + .url(format!("{}/asset", base_url)) + .sec_required_document_policy(value) + .expect_err("invalid Sec-Required-Document-Policy should be rejected"); + assert!(error.is_builder()); + if !value.trim().is_empty() { + assert!(!error.to_string().contains(value)); + } + }); + assert!( + request.is_empty(), + "invalid Sec-Required-Document-Policy must not open a socket" + ); + } +} + +#[test] +fn sec_required_document_policy_helper_rejects_invalid_existing_fields_before_connecting() { + let request = capture_optional_request(|base_url| { + let mut client = client(); + let error = client + .get() + .url(format!("{}/asset", base_url)) + .header(("Sec-Required-Document-Policy", "oversized-images=src;foo=bar")) + .sec_required_document_policy("unsized-media=?0") + .expect_err("combined invalid Sec-Required-Document-Policy should be rejected"); + assert!(error.is_builder()); + }); + assert!( + request.is_empty(), + "invalid combined Sec-Required-Document-Policy must not open a socket" + ); +} + +#[test] +fn sec_required_document_policy_helper_rejects_oversized_values_before_connecting() { + let oversized = "x".repeat(64 * 1024 + 1); + let request = capture_optional_request(|base_url| { + let mut client = client(); + let error = client + .get() + .url(format!("{}/asset", base_url)) + .sec_required_document_policy(oversized.as_str()) + .expect_err("oversized Sec-Required-Document-Policy should be rejected"); + assert!(error.is_builder()); + assert!(!error.to_string().contains(&oversized[..64])); + }); + assert!( + request.is_empty(), + "oversized Sec-Required-Document-Policy must not open a socket" + ); +} + +#[test] +fn sec_required_document_policy_helper_rejects_combined_fields_that_exceed_total_size() { + let first = format!("first={}", "a".repeat(40 * 1024)); + let second = format!("second={}", "b".repeat(40 * 1024)); + let request = capture_optional_request(|base_url| { + let mut client = client(); + let error = client + .get() + .url(format!("{}/asset", base_url)) + .header(("Sec-Required-Document-Policy", first.as_str())) + .sec_required_document_policy(second.as_str()) + .expect_err("combined oversized Sec-Required-Document-Policy should be rejected"); + assert!(error.is_builder()); + }); + assert!( + request.is_empty(), + "combined oversized Sec-Required-Document-Policy must not open a socket" + ); +} + +#[test] +fn raw_sec_required_document_policy_header_remains_available_as_escape_hatch() { + let request = capture_request(|base_url| { + client() + .get() + .url(format!("{}/asset", base_url)) + .header(( + "Sec-Required-Document-Policy", + "opaque custom document policy", + )) + .emit() + .expect("manual Sec-Required-Document-Policy header should succeed"); + }); + let request = request_text(&request); + assert_eq!( + Some("opaque custom document policy"), + header_value(&request, "Sec-Required-Document-Policy") + ); +} + #[test] fn raw_pragma_header_remains_available_as_escape_hatch() { let request = capture_request(|base_url| { diff --git a/crates/rttp-protocol/README.md b/crates/rttp-protocol/README.md index 5a64d5fd..1caaa74d 100644 --- a/crates/rttp-protocol/README.md +++ b/crates/rttp-protocol/README.md @@ -1520,6 +1520,12 @@ 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` parses `Sec-Required-Document-Policy` request +metadata with the same directive model, parser, formatter, and bounds while +exposing distinct required-policy types and parse errors. It is metadata-only: +it does not enforce document policy, compare required policies with +`Document-Policy`, block document loads, or echo response fields. + ## 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 67c28b8a..8886a668 100644 --- a/crates/rttp-protocol/src/lib.rs +++ b/crates/rttp-protocol/src/lib.rs @@ -109,6 +109,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..a26f0fd5 --- /dev/null +++ b/crates/rttp-protocol/src/sec_required_document_policy.rs @@ -0,0 +1,87 @@ +//! 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, compare required policies with +//! `Document-Policy`, block document loads, disable browser features, or echo +//! response fields. + +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/metadata_facade.rs b/crates/rttp-protocol/tests/metadata_facade.rs index 506d35a0..2cb0717a 100644 --- a/crates/rttp-protocol/tests/metadata_facade.rs +++ b/crates/rttp-protocol/tests/metadata_facade.rs @@ -80,6 +80,9 @@ use rttp_protocol::retry_after::RetryAfter; use rttp_protocol::save_data::SaveData; use rttp_protocol::schedule_tag::ScheduleTag; use rttp_protocol::sec_gpc::SecGpc; +use rttp_protocol::sec_required_document_policy::{ + SecRequiredDocumentPolicy, SecRequiredDocumentPolicyParseError, +}; use rttp_protocol::sec_websocket_accept::SecWebSocketAccept; use rttp_protocol::sec_websocket_extensions::{ SecWebSocketExtensions, SecWebSocketExtensionsParseError, @@ -296,6 +299,13 @@ fn protocol_exports_representative_bounded_metadata_types() { let _: DocumentPolicyReportOnlyParseError = DocumentPolicyReportOnly::parse("unsized-media=src;foo=bar") .expect_err("Document-Policy-Report-Only with an unknown parameter 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("Sec-Required-Document-Policy with an unknown parameter should be rejected"); let connection = Connection::parse("keep-alive, TE").expect("Connection should parse"); let content_encoding = ContentEncoding::parse("gzip, br").expect("Content-Encoding should parse"); let content_security_policy = @@ -454,6 +464,18 @@ fn protocol_exports_representative_bounded_metadata_types() { .report_to(), Some("default") ); + assert_eq!(sec_required_document_policy.directives().len(), 3); + assert_eq!( + sec_required_document_policy.header_value(), + "oversized-images=2.0, unsized-media=?0, *;report-to=default" + ); + assert_eq!( + sec_required_document_policy + .directive("*") + .unwrap() + .report_to(), + Some("default") + ); assert_eq!(location.as_str(), "../login?next=%2Fdashboard"); assert_eq!(max_forwards.value(), 0); assert_eq!(max_forwards.header_value(), "0"); 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..ad4af42e --- /dev/null +++ b/crates/rttp-protocol/tests/sec_required_document_policy.rs @@ -0,0 +1,113 @@ +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_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\nX-Injected: 1", + "oversized-images=2.0\u{1}", + ] { + 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 8f91165e..bce52fb6 100644 --- a/crates/rttp-server/README.md +++ b/crates/rttp-server/README.md @@ -1343,6 +1343,26 @@ These helpers declare and parse metadata only. They do not translate `Pragma` into `Cache-Control`, store cache entries, or apply cache, intermediary, or HTTP/1.0 compatibility 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. Absent fields return `Ok(None)`. The +helpers reuse the Document Policy Structured Fields dictionary model: opaque +lowercase directive names or `*`, boolean/integer/decimal/token values, and a +retained `report-to` parameter. Multiple fields are combined in wire order, +duplicate directive names are rejected, each field value is bounded to 64 KiB, +combined raw bytes are bounded to 64 KiB, and the combined directive count is +bounded to 256. Empty dictionaries, malformed members, forbidden ASCII control +bytes, unknown parameters, and bound violations return a parser error while +`Request::header()` and `HttpRequest::header()` continue to expose the +original raw fields. + +These helpers parse request metadata only. They do not enforce document +policy, compare required policies with `Document-Policy`, block document +loads, disable browser features, or echo response fields. + ## W3C Trace Context request metadata Handlers can call `Request::traceparent()`, `Request::tracestate()`, and the diff --git a/crates/rttp-server/src/server/request.rs b/crates/rttp-server/src/server/request.rs index c50bfdd2..fd567ede 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, @@ -942,6 +948,18 @@ impl Request { HttpPragma::parse_values(values).map(Some) } + /// Parses received `Sec-Required-Document-Policy` request metadata without + /// enforcing document policy or comparing it with `Document-Policy`. + 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 `Content-Type` representation metadata without sniffing /// MIME types or interpreting the body. pub fn content_type(&self) -> Result, HttpContentTypeParseError> { @@ -2937,6 +2955,27 @@ impl HttpRequest { HttpPragma::parse_values(values).map(Some) } + /// Parses received `Sec-Required-Document-Policy` request metadata without + /// enforcing document policy or comparing it with `Document-Policy`. + 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 `Content-Type` representation metadata without sniffing /// MIME types or interpreting the body. pub fn content_type(&self) -> Result, HttpContentTypeParseError> { diff --git a/crates/rttp-server/src/server/server_tests.rs b/crates/rttp-server/src/server/server_tests.rs index a7df10ad..71733354 100644 --- a/crates/rttp-server/src/server/server_tests.rs +++ b/crates/rttp-server/src/server/server_tests.rs @@ -576,6 +576,117 @@ fn request_pragma_parses_request_metadata_without_policy() { assert!(combined.header("Pragma").is_some()); } +#[test] +fn request_sec_required_document_policy_parses_request_metadata_without_policy() { + let absent_raw = "GET /asset 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 /asset HTTP/1.1\r\n", + "Host: example.test\r\n", + "Sec-Required-Document-Policy: oversized-images=2.0, unsized-media=?0\r\n", + "Sec-Required-Document-Policy: *;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!(3, policy.len()); + 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"), + valid.header("Sec-Required-Document-Policy") + ); + + let malformed_raw = concat!( + "GET /asset HTTP/1.1\r\n", + "Host: example.test\r\n", + "Sec-Required-Document-Policy: oversized-images=src;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=src;foo=bar"), + malformed.header("Sec-Required-Document-Policy") + ); + + let duplicate_raw = concat!( + "GET /asset 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::sec_required_document_policy::MAX_DOCUMENT_POLICY_VALUE_BYTES + 1); + let oversized = HttpRequest { + method: "GET".to_string(), + path: "/asset".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()); + + let first = format!( + "first={}", + "a".repeat(rttp_protocol::sec_required_document_policy::MAX_DOCUMENT_POLICY_VALUE_BYTES / 2) + ); + let second = format!( + "second={}", + "b".repeat(rttp_protocol::sec_required_document_policy::MAX_DOCUMENT_POLICY_VALUE_BYTES / 2) + ); + let combined = HttpRequest { + method: "GET".to_string(), + path: "/asset".to_string(), + query: None, + version: "HTTP/1.1".to_string(), + headers: vec![ + HttpHeader::new("Sec-Required-Document-Policy", first), + HttpHeader::new("Sec-Required-Document-Policy", second), + ], + body: Vec::new(), + content_length: None, + }; + assert!(combined.sec_required_document_policy().is_err()); + assert!(combined.header("Sec-Required-Document-Policy").is_some()); +} + #[test] fn request_upgrade_insecure_requests_parses_request_metadata_without_policy() { let absent_raw = "GET /page 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 c5170bbf..1c083657 100644 --- a/crates/rttp-server/tests/metadata_facade.rs +++ b/crates/rttp-server/tests/metadata_facade.rs @@ -44,6 +44,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, @@ -447,6 +449,14 @@ fn server_facade_exports_representative_bounded_metadata_types() { let document_policy_report_only_response = HttpResponse::ok("") .with_document_policy_report_only("oversized-images=2.0, unsized-media=?0, *;report-to=default") .expect("Document-Policy-Report-Only should be accepted"); + 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 supports_loading_mode: HttpSupportsLoadingMode = HttpSupportsLoadingMode::parse("fenced-frame, credentialed-prerender") .expect("Supports-Loading-Mode should parse"); @@ -790,6 +800,20 @@ fn server_facade_exports_representative_bounded_metadata_types() { .expect("Document-Policy-Report-Only should be present") .header_value() ); + 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!( supports_loading_mode.tokens(), ["fenced-frame", "credentialed-prerender"] diff --git a/crates/rttp/src/lib.rs b/crates/rttp/src/lib.rs index c0738c47..ef149661 100644 --- a/crates/rttp/src/lib.rs +++ b/crates/rttp/src/lib.rs @@ -61,7 +61,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 b1b7b250..bdcae0fc 100644 --- a/crates/rttp/tests/metadata_facade.rs +++ b/crates/rttp/tests/metadata_facade.rs @@ -492,6 +492,12 @@ fn compatibility_facade_exports_client_metadata_types() { let _: rttp::DocumentPolicyReportOnlyParseError = rttp_client::response::DocumentPolicyReportOnly::parse("unsized-media=src;foo=bar") .expect_err("unknown Document-Policy-Report-Only parameter should be rejected"); + let sec_required_document_policy: rttp::SecRequiredDocumentPolicy = + rttp::SecRequiredDocumentPolicy::parse("oversized-images=2.0, unsized-media=?0") + .expect("Sec-Required-Document-Policy should parse"); + let _: rttp::SecRequiredDocumentPolicyParseError = + rttp::SecRequiredDocumentPolicy::parse("unsized-media=src;foo=bar") + .expect_err("unknown Sec-Required-Document-Policy parameter should be rejected"); let supports_loading_mode: rttp::SupportsLoadingMode = rttp_client::response::SupportsLoadingMode::parse("fenced-frame, credentialed-prerender") .expect("Supports-Loading-Mode should parse"); @@ -795,6 +801,16 @@ fn compatibility_facade_exports_client_metadata_types() { .value(), &rttp::DocumentPolicyReportOnlyValue::Decimal("2.0".to_string()) ); + assert_eq!(sec_required_document_policy.directives().len(), 2); + assert_eq!( + sec_required_document_policy + .directive("oversized-images") + .unwrap() + .value(), + &rttp::SecRequiredDocumentPolicyValue::Decimal("2.0".to_string()) + ); + let _: &rttp::SecRequiredDocumentPolicyDirective = + sec_required_document_policy.directive("unsized-media").unwrap(); assert_eq!( supports_loading_mode.tokens(), ["fenced-frame", "credentialed-prerender"] diff --git a/crates/rttp/tests/test_server_models.rs b/crates/rttp/tests/test_server_models.rs index d5ae32f0..5ede09cd 100644 --- a/crates/rttp/tests/test_server_models.rs +++ b/crates/rttp/tests/test_server_models.rs @@ -1896,6 +1896,66 @@ fn request_pragma_parses_bounded_metadata_without_cache_policy() { ); } +#[test] +fn request_sec_required_document_policy_parses_bounded_metadata_without_policy() { + let absent = parse_request("GET / HTTP/1.1\r\nHost: example.test\r\n\r\n"); + assert_eq!( + None, + absent + .sec_required_document_policy() + .expect("missing Sec-Required-Document-Policy should be valid") + ); + + let valid = parse_request(concat!( + "GET /asset HTTP/1.1\r\n", + "Host: example.test\r\n", + "Sec-Required-Document-Policy: oversized-images=2.0, unsized-media=?0\r\n", + "Sec-Required-Document-Policy: *;report-to=default\r\n", + "\r\n" + )); + let policy = valid + .sec_required_document_policy() + .expect("Sec-Required-Document-Policy should parse") + .expect("Sec-Required-Document-Policy should be present"); + assert_eq!(3, policy.len()); + 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"), + valid.header("Sec-Required-Document-Policy") + ); + + for value in [ + "oversized-images=src;foo=bar", + "Oversized-Images=2.0", + "oversized-images=2.0, oversized-images=3.0", + ] { + let request = parse_request(&format!( + "GET /asset HTTP/1.1\r\nHost: example.test\r\nSec-Required-Document-Policy: {value}\r\n\r\n" + )); + assert!( + request.sec_required_document_policy().is_err(), + "should reject {value:?}" + ); + assert_eq!(Some(value), request.header("Sec-Required-Document-Policy")); + } + + let duplicate = parse_request(concat!( + "GET /asset 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" + )); + assert!(duplicate.sec_required_document_policy().is_err()); + assert_eq!( + Some("oversized-images=2.0"), + duplicate.header("Sec-Required-Document-Policy") + ); +} + #[test] fn request_idempotency_key_is_optional_and_rejects_invalid_metadata() { let absent = parse_request("GET / HTTP/1.1\r\nHost: example.test\r\n\r\n"); diff --git a/tests/http11_client_server_matrix.rs b/tests/http11_client_server_matrix.rs index bacb5133..ef24df99 100644 --- a/tests/http11_client_server_matrix.rs +++ b/tests/http11_client_server_matrix.rs @@ -4690,6 +4690,58 @@ fn sync_client_and_server_exchange_bounded_pragma_metadata_without_policy() { handle.join().expect("pragma server thread"); } +#[test] +fn sync_client_and_server_exchange_bounded_sec_required_document_policy_metadata_without_policy() { + const POLICY: &str = "oversized-images=2.0, unsized-media=?0, *;report-to=default"; + + let server = rttp_server::server::HttpServer::bind("127.0.0.1:0") + .expect("bind Sec-Required-Document-Policy server"); + let addr = server + .local_addr() + .expect("Sec-Required-Document-Policy server addr"); + let (observed_tx, observed_rx) = mpsc::channel(); + let handle = thread::spawn(move || { + server + .accept_one(|request| { + let observed = ( + request + .sec_required_document_policy() + .expect("Sec-Required-Document-Policy should parse") + .map(|policy| policy.header_value()), + request + .header("Sec-Required-Document-Policy") + .map(str::to_string), + request.header("Document-Policy").map(str::to_string), + ); + observed_tx + .send(observed) + .expect("send observed Sec-Required-Document-Policy metadata"); + HttpResponse::new(200, "OK") + }) + .expect("serve Sec-Required-Document-Policy request"); + }); + + let response = client() + .get() + .url(format!("http://{addr}/matrix/document")) + .sec_required_document_policy(POLICY) + .expect("Sec-Required-Document-Policy should be accepted") + .emit() + .expect("Sec-Required-Document-Policy response should parse"); + + let (typed, raw, document_policy) = observed_rx + .recv_timeout(Duration::from_secs(1)) + .expect("server should observe Sec-Required-Document-Policy metadata"); + assert_eq!(Some(POLICY.to_string()), typed); + assert_eq!(Some(POLICY.to_string()), raw); + assert_eq!( + None, document_policy, + "Sec-Required-Document-Policy exchange must not invent Document-Policy" + ); + assert_eq!(200, response.code()); + handle.join().expect("Sec-Required-Document-Policy server thread"); +} + #[test] fn sync_client_and_server_observe_pragma_and_cache_control_independently() { let server =