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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions crates/rttp-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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 |
Expand Down
25 changes: 25 additions & 0 deletions crates/rttp-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<S: AsRef<str>>(
&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
Expand Down
4 changes: 4 additions & 0 deletions crates/rttp-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 27 additions & 7 deletions crates/rttp-client/tests/metadata_facade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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());
Expand Down
83 changes: 83 additions & 0 deletions crates/rttp-client/tests/test_raw_request_capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
16 changes: 16 additions & 0 deletions crates/rttp-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/rttp-protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
86 changes: 86 additions & 0 deletions crates/rttp-protocol/src/sec_required_document_policy.rs
Original file line number Diff line number Diff line change
@@ -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<DocumentPolicyDirective>,
}

/// 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<String>) -> 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<str>) -> Result<Self, SecRequiredDocumentPolicyParseError> {
Self::parse_values([value.as_ref()])
}

pub fn parse_values<'a, I>(values: I) -> Result<Self, SecRequiredDocumentPolicyParseError>
where
I: IntoIterator<Item = &'a str>,
{
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<str>) -> 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)
}
}
Loading