From 201f2021af2eb79cbc964e08f97e8ae7a7c4d3f1 Mon Sep 17 00:00:00 2001 From: fewensa Date: Fri, 11 Sep 2026 17:17:27 +0800 Subject: [PATCH] feat: add private-network response metadata codeon: version: 1 authority: FWN-367 description: |- Add bounded Access-Control-Allow-Private-Network response metadata across the protocol, server, client, and compatibility facade. Preserve raw headers when typed parsing fails, keep declarations and accessors metadata-only, and document the 64 KiB and policy boundaries. Add protocol, facade, client response, server response, and live HTTP/1.1 interoperability coverage. --- README.md | 21 +++- crates/rttp-client/README.md | 21 +++- crates/rttp-client/src/response/mod.rs | 3 + crates/rttp-client/src/response/response.rs | 15 +++ crates/rttp-client/tests/metadata_facade.rs | 9 +- crates/rttp-client/tests/test_response.rs | 74 ++++++++++++ crates/rttp-protocol/README.md | 12 ++ .../access_control_allow_private_network.rs | 109 ++++++++++++++++++ crates/rttp-protocol/src/lib.rs | 1 + .../access_control_allow_private_network.rs | 50 ++++++++ crates/rttp-protocol/tests/package_list.rs | 1 + crates/rttp-server/README.md | 16 +++ crates/rttp-server/src/server/response.rs | 47 ++++++++ crates/rttp-server/tests/metadata_facade.rs | 73 +++++++++--- crates/rttp/README.md | 17 +++ crates/rttp/src/lib.rs | 3 +- crates/rttp/tests/metadata_facade.rs | 7 ++ tests/http11_client_server_matrix.rs | 48 ++++++++ 18 files changed, 508 insertions(+), 19 deletions(-) create mode 100644 crates/rttp-protocol/src/access_control_allow_private_network.rs create mode 100644 crates/rttp-protocol/tests/access_control_allow_private_network.rs diff --git a/README.md b/README.md index 0d95425b..389d48a6 100644 --- a/README.md +++ b/README.md @@ -1123,6 +1123,24 @@ on demand without changing them. These helpers expose credentials metadata only. RTTP does not evaluate CORS requests, attach credentials to requests, or grant credentials automatically. +### Bounded Access-Control-Allow-Private-Network response metadata + +`Response::access_control_allow_private_network()` parses a singleton +`Access-Control-Allow-Private-Network` response field into bounded +`AccessControlAllowPrivateNetwork` metadata. The value is the exact, +case-sensitive `true` token after surrounding SP and HTAB trimming and is +serialized canonically as `true`. Empty, malformed, duplicate, control-byte, +or over-64-KiB values return an error while raw response fields remain +available through `Response::header_value()` and `Response::header_values()`. + +On the server, `HttpAccessControlAllowPrivateNetwork::parse()` validates the +same syntax, `HttpResponse::with_access_control_allow_private_network()` +replaces same-name fields only after successful validation, and +`HttpResponse::access_control_allow_private_network()` parses attached fields +without changing them. These helpers expose metadata only; RTTP does not grant +private-network access, decide preflight behavior, or apply CORS or Private +Network Access policy. + ### Bounded NEL response metadata `Response::nel()` parses the `NEL` response field as bounded W3C Network Error @@ -1865,8 +1883,9 @@ gain additional HTTP/2 header-block handling. | X-Forwarded compatibility metadata | Client `x_forwarded_for`, `x_forwarded_host`, and `x_forwarded_proto` emit bounded compatibility request metadata through shared protocol types; server `Request`/`HttpRequest` helpers parse ordered node, authority, and scheme values while preserving raw headers on errors | No forwarded identity trust, client address selection, routing rewrite, scheme rewrite, redirect, upgrade, enforcement, or trusted-proxy selection; applications must choose trusted proxies | | Via | Client `via` emits bounded HTTP `Via` hop metadata through the shared protocol type; `Response::via` parses received hops; server `Request`/`HttpRequest` helpers and `HttpResponse::with_via`/`via` parse or declare caller-supplied chains while preserving raw headers on errors | No automatic hop insertion or removal, trusted-proxy inference, identity rewrite, or HTTP/1.1 or HTTP/2 proxy-policy changes | | Accept-Language | Client `accept_language` emits bounded `Accept-Language` request metadata through the protocol `AcceptLanguage` type; server `Request::accept_language()` and `HttpRequest::accept_language()` parse typed received values as `HttpAcceptLanguages` while preserving raw headers on errors | No locale matching, fallback selection, translation lookup, routing, or automatic response choice | -| Preflight request metadata | Client `origin`, `access_control_request_method`, `access_control_request_headers`, and `access_control_request_private_network` emit bounded `Origin`, `Access-Control-Request-Method`, `Access-Control-Request-Headers`, and `Access-Control-Request-Private-Network` request metadata and reject invalid input before connecting | No automatic preflight decision, `Access-Control-Allow-*` response parsing, CORS policy, or Private Network Access policy | +| Preflight request metadata | Client `origin`, `access_control_request_method`, `access_control_request_headers`, and `access_control_request_private_network` emit bounded `Origin`, `Access-Control-Request-Method`, `Access-Control-Request-Headers`, and `Access-Control-Request-Private-Network` request metadata and reject invalid input before connecting | No automatic preflight decision, CORS policy, or Private Network Access policy | | Access-Control-Allow-Credentials | Client `Response::access_control_allow_credentials` and server `HttpAccessControlAllowCredentials`, `HttpResponse::with_access_control_allow_credentials`, and `HttpResponse::access_control_allow_credentials` parse or declare bounded singleton `Access-Control-Allow-Credentials` `true`-token metadata while preserving raw headers on parse failures | No CORS request evaluation, automatic credential attachment, or automatic credentials granting | +| Access-Control-Allow-Private-Network | Client `Response::access_control_allow_private_network` and server `HttpAccessControlAllowPrivateNetwork`, `HttpResponse::with_access_control_allow_private_network`, and `HttpResponse::access_control_allow_private_network` parse or declare bounded singleton `Access-Control-Allow-Private-Network` `true`-token metadata while preserving raw headers on parse failures | No private-network access grant, preflight decision, CORS policy, or Private Network Access policy | | Digest preferences | `want_content_digest`, `want_content_digest_with_q`, `want_repr_digest`, and `want_repr_digest_with_q` emit bounded `Want-Content-Digest` and `Want-Repr-Digest` request metadata; server `Request::want_content_digest()`, `HttpRequest::want_content_digest()`, `Request::want_repr_digest()`, and `HttpRequest::want_repr_digest()` parse received preference fields | No algorithm selection, digest computation, response body hash validation, retries, or signing | | Accept-Encoding | Client `accept_encoding`, `accept_encoding_with_q`, and gzip/deflate/br/identity helpers format bounded `Accept-Encoding` request metadata through the shared `rttp-protocol` type; server `Request::accept_encoding()` and `HttpRequest::accept_encoding()` parse received fields into `HttpRequestAcceptEncodings` | No compression, decompression, content negotiation, retries, or transport changes | | Upgrade and tunnel handoff | `CONNECT` returns the tunnel socket after a successful `200`; `upgrade()` returns the socket after `101 Switching Protocols` and skips interim `1xx` responses | Upgraded protocols are handed to the caller and are not parsed by `rttp_client` | diff --git a/crates/rttp-client/README.md b/crates/rttp-client/README.md index 448768e0..3fb57853 100644 --- a/crates/rttp-client/README.md +++ b/crates/rttp-client/README.md @@ -1329,8 +1329,22 @@ retain raw-header control with `header(("Origin", "..."))` and the other `header` forms. These are declaration helpers only. RTTP does not decide whether a preflight -is needed, read `Access-Control-Allow-*` response fields, apply CORS policy, or -apply Private Network Access policy. +is needed, apply CORS policy, or apply Private Network Access policy. + +## Bounded Access-Control-Allow-Private-Network response metadata + +`Response::access_control_allow_private_network()` parses a singleton +`Access-Control-Allow-Private-Network` response field through the shared +`AccessControlAllowPrivateNetwork` protocol type. The value is the exact, +case-sensitive `true` token with surrounding SP or HTAB trimmed and canonical +lowercase `true` serialization. Values and duplicate fields are bounded and +validated to reject malformed, control-byte, and oversized input. Absent +metadata returns `Ok(None)`; parse errors leave the original raw field +available through `Response::header_value()` and `Response::header_values()`. + +This accessor exposes response metadata only. It does not grant private-network +access, decide whether a preflight is needed, apply CORS policy, or apply +Private Network Access policy. ## Bounded Save-Data request metadata @@ -1802,7 +1816,8 @@ header-block model. | W3C Baggage | `baggage` validates and emits bounded W3C Baggage request metadata through the shared protocol type, replacing an existing same-name field and redacting member and property values from typed debug output | No application-data interpretation, request-context storage, tracing backend, span model, or automatic propagation | | CDN-Loop | `cdn_loop` validates and emits bounded RFC 8586 `CDN-Loop` request metadata through the shared protocol type, combining an existing same-name field with the new member in wire order and rejecting malformed or oversized values before connecting | No CDN identifier insertion, loop detection or rejection, automatic forwarding, or hop-by-hop handling | | Via | `via` validates and emits bounded HTTP `Via` request metadata through the shared protocol type, combining an existing same-name field with the new hops in wire order and rejecting malformed or oversized values before connecting; `Response::via` parses received hop chains while preserving raw headers on parse failures | No automatic hop insertion or removal, trusted-proxy inference, identity rewrite, or HTTP/1.1 or HTTP/2 proxy-policy changes | -| Preflight request metadata | `origin`, `access_control_request_method`, `access_control_request_headers`, and `access_control_request_private_network` emit bounded `Origin`, `Access-Control-Request-Method`, `Access-Control-Request-Headers`, and `Access-Control-Request-Private-Network` request metadata and reject invalid input before connecting | No automatic preflight decision, `Access-Control-Allow-*` response parsing, CORS policy, or Private Network Access policy | +| Preflight request metadata | `origin`, `access_control_request_method`, `access_control_request_headers`, and `access_control_request_private_network` emit bounded `Origin`, `Access-Control-Request-Method`, `Access-Control-Request-Headers`, and `Access-Control-Request-Private-Network` request metadata and reject invalid input before connecting | No automatic preflight decision, CORS policy, or Private Network Access policy | +| Access-Control-Allow-Private-Network | `Response::access_control_allow_private_network` parses bounded singleton `Access-Control-Allow-Private-Network` `true` metadata while preserving raw headers on parse failures | No private-network access grant, preflight decision, CORS policy, or Private Network Access policy | | Digest preferences | `want_content_digest`, `want_content_digest_with_q`, `want_repr_digest`, and `want_repr_digest_with_q` emit bounded `Want-Content-Digest` and `Want-Repr-Digest` request metadata; server `Request::want_content_digest()`, `HttpRequest::want_content_digest()`, `Request::want_repr_digest()`, and `HttpRequest::want_repr_digest()` parse received preference fields | No algorithm selection, digest computation, response body hash validation, retries, or signing | | Accept | `accept` and `accept_with_q` format bounded `Accept` request metadata through the shared `rttp-protocol` type, replacing existing same-name fields after validating helper-built and existing raw values | No content negotiation, representation selection, MIME sniffing, body decoding, cache `Vary` synthesis, or response choice | | Accept-Charset | `accept_charset` and `accept_charset_with_q` format bounded `Accept-Charset` request metadata through the shared `rttp-protocol` type | No content negotiation, charset transcoding, body decoding, MIME sniffing, or response selection | diff --git a/crates/rttp-client/src/response/mod.rs b/crates/rttp-client/src/response/mod.rs index 5d749dc3..75672832 100644 --- a/crates/rttp-client/src/response/mod.rs +++ b/crates/rttp-client/src/response/mod.rs @@ -46,6 +46,9 @@ pub use rttp_protocol::access_control_allow_methods::{ pub use rttp_protocol::access_control_allow_origin::{ AccessControlAllowOrigin, AccessControlAllowOriginParseError, }; +pub use rttp_protocol::access_control_allow_private_network::{ + AccessControlAllowPrivateNetwork, AccessControlAllowPrivateNetworkParseError, +}; pub use rttp_protocol::access_control_expose_headers::{ AccessControlExposeHeaders, AccessControlExposeHeadersParseError, }; diff --git a/crates/rttp-client/src/response/response.rs b/crates/rttp-client/src/response/response.rs index 40b82cd2..17e97cec 100644 --- a/crates/rttp-client/src/response/response.rs +++ b/crates/rttp-client/src/response/response.rs @@ -45,6 +45,7 @@ use rttp_protocol::access_control_allow_credentials::AccessControlAllowCredentia use rttp_protocol::access_control_allow_headers::AccessControlAllowHeaders; use rttp_protocol::access_control_allow_methods::AccessControlAllowMethods; use rttp_protocol::access_control_allow_origin::AccessControlAllowOrigin; +use rttp_protocol::access_control_allow_private_network::AccessControlAllowPrivateNetwork; use rttp_protocol::access_control_expose_headers::AccessControlExposeHeaders; use rttp_protocol::access_control_max_age::AccessControlMaxAge; use rttp_protocol::age::Age; @@ -687,6 +688,20 @@ impl Response { .map_err(|parse_error| error::bad_response(parse_error.to_string())) } + /// Parses bounded `Access-Control-Allow-Private-Network` response metadata + /// without applying Private Network Access or CORS policy. + pub fn access_control_allow_private_network( + &self, + ) -> error::Result> { + let values = self.header_values("access-control-allow-private-network"); + if values.is_empty() { + return Ok(None); + } + AccessControlAllowPrivateNetwork::parse_values(values.into_iter().map(String::as_str)) + .map(Some) + .map_err(|parse_error| error::bad_response(parse_error.to_string())) + } + /// Parses bounded `Access-Control-Allow-Methods` response metadata without /// applying CORS method policy. pub fn access_control_allow_methods(&self) -> error::Result> { diff --git a/crates/rttp-client/tests/metadata_facade.rs b/crates/rttp-client/tests/metadata_facade.rs index 7f0482d7..28af1d1e 100644 --- a/crates/rttp-client/tests/metadata_facade.rs +++ b/crates/rttp-client/tests/metadata_facade.rs @@ -2,7 +2,8 @@ use rttp_client::response::{ AcceptCh, AcceptCharset, AcceptEncoding, AccessControlAllowCredentials, AccessControlAllowCredentialsParseError, AccessControlAllowHeaders, AccessControlAllowHeadersParseError, AccessControlAllowMethods, - AccessControlAllowMethodsParseError, AccessControlExposeHeaders, AccessControlMaxAge, + AccessControlAllowMethodsParseError, AccessControlAllowPrivateNetwork, + AccessControlAllowPrivateNetworkParseError, AccessControlExposeHeaders, AccessControlMaxAge, AccessControlMaxAgeParseError, Age, AgeParseError, AltSvc, AltUsed, AltUsedParseError, AuthenticationInfo, AuthenticationInfoParameter, AuthenticationInfoParseError, CacheStatus, CacheStatusParseError, Connection, ConnectionParseError, ContentDpr, ContentDprParseError, @@ -82,6 +83,12 @@ fn response_facade_exports_representative_bounded_metadata_types() { .expect("Access-Control-Allow-Credentials should parse"); let _: AccessControlAllowCredentialsParseError = AccessControlAllowCredentials::parse("false") .expect_err("false Access-Control-Allow-Credentials should be rejected"); + let allow_private_network = AccessControlAllowPrivateNetwork::parse("true") + .expect("Access-Control-Allow-Private-Network should parse"); + assert_eq!("true", allow_private_network.header_value()); + let _: AccessControlAllowPrivateNetworkParseError = + AccessControlAllowPrivateNetwork::parse("false") + .expect_err("false Access-Control-Allow-Private-Network should be rejected"); let allow_methods = AccessControlAllowMethods::parse("GET, POST") .expect("Access-Control-Allow-Methods should parse"); let _: AccessControlAllowMethodsParseError = AccessControlAllowMethods::parse("") diff --git a/crates/rttp-client/tests/test_response.rs b/crates/rttp-client/tests/test_response.rs index 124b1a2f..43cb1aaa 100644 --- a/crates/rttp-client/tests/test_response.rs +++ b/crates/rttp-client/tests/test_response.rs @@ -7743,6 +7743,80 @@ fn test_access_control_allow_credentials_response_helper_parses_valid_metadata_a ); } +#[test] +fn access_control_allow_private_network_response_helper_preserves_raw_parse_failures() { + let valid = Response::new( + RoUrl::with("https://example.test"), + b"HTTP/1.1 200 OK\r\nAccess-Control-Allow-Private-Network: \ttrue \r\nContent-Length: 0\r\n\r\n" + .to_vec(), + ) + .expect("valid response should remain usable"); + assert_eq!( + "true", + valid + .access_control_allow_private_network() + .expect("valid metadata should parse") + .expect("valid metadata should be present") + .header_value() + ); + assert_eq!( + Some("true"), + valid + .header_value("Access-Control-Allow-Private-Network") + .map(String::as_str) + ); + + let absent = Response::new( + RoUrl::with("https://example.test"), + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".to_vec(), + ) + .expect("response without metadata should remain usable"); + assert_eq!( + None, + absent + .access_control_allow_private_network() + .expect("absence should parse") + ); + + for value in [ + "TRUE".to_string(), + "True".to_string(), + "false".to_string(), + "true, true".to_string(), + "true\0".to_string(), + "x".repeat(64 * 1024 + 1), + ] { + let raw = format!( + "HTTP/1.1 200 OK\r\nAccess-Control-Allow-Private-Network: {value}\r\nContent-Length: 0\r\n\r\n" + ); + let response = Response::new(RoUrl::with("https://example.test"), raw.into_bytes()) + .expect("malformed response metadata should remain usable"); + assert!(response.access_control_allow_private_network().is_err()); + assert_eq!( + response.header_value("Access-Control-Allow-Private-Network"), + Some(&value) + ); + } + + let duplicate = Response::new( + RoUrl::with("https://example.test"), + concat!( + "HTTP/1.1 200 OK\r\n", + "Access-Control-Allow-Private-Network: true\r\n", + "access-control-allow-private-network: true\r\n", + "Content-Length: 0\r\n\r\n" + ) + .as_bytes() + .to_vec(), + ) + .expect("duplicate response metadata should remain usable"); + assert!(duplicate.access_control_allow_private_network().is_err()); + assert_eq!( + duplicate.header_values("Access-Control-Allow-Private-Network"), + [&"true".to_string(), &"true".to_string()] + ); +} + #[test] fn test_access_control_allow_headers_response_helper_parses_valid_lists_wildcard_and_multiple_fields( ) { diff --git a/crates/rttp-protocol/README.md b/crates/rttp-protocol/README.md index 5a64d5fd..fa20c4fa 100644 --- a/crates/rttp-protocol/README.md +++ b/crates/rttp-protocol/README.md @@ -1267,6 +1267,18 @@ whitespace. Unknown tokens, lists, quoted values, empty values, control bytes, and other unparsable input are errors. This parser does not evaluate CORS requests or grant credentials automatically. +## Access-Control-Allow-Private-Network + +`access_control_allow_private_network` parses a singleton +`Access-Control-Allow-Private-Network` response field. Each field value is +bounded to 64 KiB, and a second field is rejected after every supplied field +is bound-checked. The value must be exactly the case-sensitive Fetch +`true` token and is serialized canonically as lowercase `true`; surrounding +SP and HTAB are trimmed. Unknown tokens, lists, empty values, control bytes, +oversized values, and other malformed input are errors. This parser reports +response metadata only; it does not grant private-network access or apply +CORS or Private Network Access policy. + ## Access-Control-Request-Method `access_control_request_method` parses a singleton diff --git a/crates/rttp-protocol/src/access_control_allow_private_network.rs b/crates/rttp-protocol/src/access_control_allow_private_network.rs new file mode 100644 index 00000000..a9d074e2 --- /dev/null +++ b/crates/rttp-protocol/src/access_control_allow_private_network.rs @@ -0,0 +1,109 @@ +//! Bounded, policy-free `Access-Control-Allow-Private-Network` response metadata parsing. +//! +//! This module validates the response field value only. Callers decide whether +//! and how to apply Private Network Access or CORS policy. + +use std::error::Error; +use std::fmt; + +/// Maximum bytes accepted in an `Access-Control-Allow-Private-Network` field value. +pub const MAX_ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK_VALUE_BYTES: usize = 64 * 1024; + +/// The private-network access signal declared by `Access-Control-Allow-Private-Network`. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum AccessControlAllowPrivateNetwork { + True, +} + +impl AccessControlAllowPrivateNetwork { + pub fn parse(value: impl AsRef) -> Result { + Self::parse_values([value.as_ref()]) + } + + pub fn parse_values<'a, I>(values: I) -> Result + where + I: IntoIterator, + { + let value = parse_singleton(values)?; + // The Fetch CORS grammar requires the case-sensitive token %s"true". + if value == "true" { + Ok(Self::True) + } else { + Err(invalid_value()) + } + } + + pub const fn header_value(self) -> &'static str { + match self { + Self::True => "true", + } + } +} + +/// An error returned when `Access-Control-Allow-Private-Network` metadata is +/// malformed or exceeds bounds. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccessControlAllowPrivateNetworkParseError { + message: String, +} + +impl AccessControlAllowPrivateNetworkParseError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for AccessControlAllowPrivateNetworkParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for AccessControlAllowPrivateNetworkParseError {} + +fn parse_singleton<'a, I>(values: I) -> Result<&'a str, AccessControlAllowPrivateNetworkParseError> +where + I: IntoIterator, +{ + let mut values = values.into_iter(); + let value = values.next().ok_or_else(invalid_value)?; + validate_bounded_value(value)?; + let mut has_duplicate = false; + for value in values { + has_duplicate = true; + validate_bounded_value(value)?; + } + if has_duplicate { + return Err(AccessControlAllowPrivateNetworkParseError::new( + "duplicate Access-Control-Allow-Private-Network header fields", + )); + } + let value = value.trim_matches([' ', '\t']); + if value.is_empty() { + return Err(invalid_value()); + } + Ok(value) +} + +fn validate_bounded_value(value: &str) -> Result<(), AccessControlAllowPrivateNetworkParseError> { + if value.len() > MAX_ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK_VALUE_BYTES { + return Err(AccessControlAllowPrivateNetworkParseError::new( + "Access-Control-Allow-Private-Network header value is too large", + )); + } + if value + .bytes() + .any(|byte| byte.is_ascii_control() && byte != b'\t') + { + return Err(invalid_value()); + } + Ok(()) +} + +fn invalid_value() -> AccessControlAllowPrivateNetworkParseError { + AccessControlAllowPrivateNetworkParseError::new( + "invalid Access-Control-Allow-Private-Network header value", + ) +} diff --git a/crates/rttp-protocol/src/lib.rs b/crates/rttp-protocol/src/lib.rs index 67c28b8a..b576a458 100644 --- a/crates/rttp-protocol/src/lib.rs +++ b/crates/rttp-protocol/src/lib.rs @@ -20,6 +20,7 @@ pub mod access_control_allow_credentials; pub mod access_control_allow_headers; pub mod access_control_allow_methods; pub mod access_control_allow_origin; +pub mod access_control_allow_private_network; pub mod access_control_expose_headers; pub mod access_control_max_age; pub mod access_control_request_headers; diff --git a/crates/rttp-protocol/tests/access_control_allow_private_network.rs b/crates/rttp-protocol/tests/access_control_allow_private_network.rs new file mode 100644 index 00000000..8230a3fe --- /dev/null +++ b/crates/rttp-protocol/tests/access_control_allow_private_network.rs @@ -0,0 +1,50 @@ +use rttp_protocol::access_control_allow_private_network::{ + AccessControlAllowPrivateNetwork, MAX_ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK_VALUE_BYTES, +}; + +#[test] +fn access_control_allow_private_network_parses_the_true_token() { + for value in ["true", " true ", "\ttrue\t"] { + let metadata = + AccessControlAllowPrivateNetwork::parse(value).expect("the true token should parse"); + assert_eq!("true", metadata.header_value()); + } +} + +#[test] +fn access_control_allow_private_network_rejects_duplicate_and_malformed_values() { + for values in [ + vec!["true", "true"], + vec![""], + vec![" "], + vec!["false"], + vec!["TRUE"], + vec!["True"], + vec!["true, true"], + vec!["true\r\n"], + vec!["true\n"], + ] { + assert!( + AccessControlAllowPrivateNetwork::parse_values(values.iter().copied()).is_err(), + "{values:?} must be rejected" + ); + } +} + +#[test] +fn access_control_allow_private_network_rejects_control_bytes() { + for value in ["true\0", "true\r", "true\n", "true\u{7f}"] { + assert!( + AccessControlAllowPrivateNetwork::parse(value).is_err(), + "{value:?} must be rejected" + ); + } +} + +#[test] +fn access_control_allow_private_network_enforces_the_value_bound() { + assert!(AccessControlAllowPrivateNetwork::parse( + "x".repeat(MAX_ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK_VALUE_BYTES + 1) + ) + .is_err()); +} diff --git a/crates/rttp-protocol/tests/package_list.rs b/crates/rttp-protocol/tests/package_list.rs index bd9f8a4c..bcbf0fc3 100644 --- a/crates/rttp-protocol/tests/package_list.rs +++ b/crates/rttp-protocol/tests/package_list.rs @@ -20,6 +20,7 @@ fn package_includes_protocol_metadata_facade_test() { for expected in [ "README.md", "tests/authorization.rs", + "tests/access_control_allow_private_network.rs", "tests/link.rs", "tests/location.rs", "tests/lock_token.rs", diff --git a/crates/rttp-server/README.md b/crates/rttp-server/README.md index 8f91165e..3bd6e16d 100644 --- a/crates/rttp-server/README.md +++ b/crates/rttp-server/README.md @@ -118,6 +118,22 @@ error remain distinct. These helpers only declare and parse metadata; RTTP does not evaluate directives, enforce CSP, send reports, or create browser policy state. +## Access-Control-Allow-Private-Network response metadata + +`HttpResponse::with_access_control_allow_private_network(value)` validates and +replaces attached `Access-Control-Allow-Private-Network` response fields with +one canonical `true` value. `HttpResponse::access_control_allow_private_network()` +parses attached fields on demand through the shared +`HttpAccessControlAllowPrivateNetwork` type and returns `Ok(None)` when absent. +The value is matched case-sensitively after surrounding SP and HTAB are +trimmed; malformed, duplicate, control-byte, and over-64-KiB values return a +typed parse error while raw fields remain available when the accessor rejects +them. Invalid typed declarations leave existing fields unchanged. + +These helpers declare and inspect bounded response metadata only. They do not +grant private-network access, decide whether a preflight is needed, apply CORS +policy, or apply Private Network Access policy. + ## Authentication metadata `Request::authorization()` / `HttpRequest::authorization()` and diff --git a/crates/rttp-server/src/server/response.rs b/crates/rttp-server/src/server/response.rs index a3af96b9..8d6b28e3 100644 --- a/crates/rttp-server/src/server/response.rs +++ b/crates/rttp-server/src/server/response.rs @@ -26,6 +26,10 @@ pub use rttp_protocol::access_control_allow_origin::{ AccessControlAllowOrigin as HttpAccessControlAllowOrigin, AccessControlAllowOriginParseError as HttpAccessControlAllowOriginParseError, }; +pub use rttp_protocol::access_control_allow_private_network::{ + AccessControlAllowPrivateNetwork as HttpAccessControlAllowPrivateNetwork, + AccessControlAllowPrivateNetworkParseError as HttpAccessControlAllowPrivateNetworkParseError, +}; pub use rttp_protocol::allow::{ Allow as HttpAllowedMethods, AllowParseError as HttpAllowParseError, }; @@ -1393,6 +1397,25 @@ impl HttpResponse { Ok(self) } + /// Validates and replaces `Access-Control-Allow-Private-Network` response + /// metadata without applying Private Network Access or CORS policy. + pub fn with_access_control_allow_private_network( + mut self, + value: impl AsRef, + ) -> Result { + let allow_private_network = HttpAccessControlAllowPrivateNetwork::parse(value)?; + self.headers.retain(|header| { + !header + .name + .eq_ignore_ascii_case("Access-Control-Allow-Private-Network") + }); + self.headers.push(HttpHeader::new( + "Access-Control-Allow-Private-Network", + allow_private_network.header_value(), + )); + Ok(self) + } + /// Validates and replaces `Access-Control-Allow-Headers` response metadata /// without applying CORS policy. pub fn with_access_control_allow_headers( @@ -2788,6 +2811,30 @@ impl HttpResponse { HttpAccessControlAllowCredentials::parse_values(values).map(Some) } + /// Parses attached `Access-Control-Allow-Private-Network` response metadata + /// without applying Private Network Access or CORS policy. + pub fn access_control_allow_private_network( + &self, + ) -> Result< + Option, + HttpAccessControlAllowPrivateNetworkParseError, + > { + let values: Vec<&str> = self + .headers + .iter() + .filter(|header| { + header + .name + .eq_ignore_ascii_case("Access-Control-Allow-Private-Network") + }) + .map(|header| header.value.as_str()) + .collect(); + if values.is_empty() { + return Ok(None); + } + HttpAccessControlAllowPrivateNetwork::parse_values(values).map(Some) + } + /// Parses attached `Access-Control-Allow-Headers` response metadata without /// applying CORS policy. pub fn access_control_allow_headers( diff --git a/crates/rttp-server/tests/metadata_facade.rs b/crates/rttp-server/tests/metadata_facade.rs index c5170bbf..4c7ccacd 100644 --- a/crates/rttp-server/tests/metadata_facade.rs +++ b/crates/rttp-server/tests/metadata_facade.rs @@ -3,19 +3,21 @@ use rttp_server::server::{ HttpAcceptCharset, HttpAcceptCharsetParseError, HttpAcceptDatetime, HttpAcceptDatetimeParseError, HttpAcceptLanguageParseError, HttpAcceptLanguages, HttpAcceptParseError, HttpAccessControlAllowCredentials, HttpAccessControlAllowCredentialsParseError, - HttpAccessControlAllowHeaders, HttpAccessControlAllowMethods, HttpAccessControlRequestHeaders, - HttpAccessControlRequestHeadersParseError, HttpAccessControlRequestMethod, - HttpAccessControlRequestMethodParseError, HttpAccessControlRequestPrivateNetwork, - HttpAccessControlRequestPrivateNetworkParseError, HttpAltUsed, HttpAltUsedParseError, - HttpAuthenticationInfo, HttpAuthenticationInfoParameter, HttpAuthenticationInfoParseError, - HttpAuthorization, HttpAuthorizationParseError, HttpBaggage, HttpBaggageMember, - HttpBaggageParseError, HttpBaggageProperty, HttpCacheStatus, HttpCacheStatusParseError, - HttpCdnCacheControl, HttpCdnLoop, HttpCdnLoopMember, HttpCdnLoopParseError, - HttpConditionalMetadata, HttpConnection, HttpConnectionParseError, HttpContentDisposition, - HttpContentDispositionParseError, HttpContentDpr, HttpContentDprParseError, HttpContentLength, - HttpContentLocation, HttpContentLocationParseError, HttpContentRange, HttpContentRangeParseError, - HttpContentSecurityPolicyReportOnly, HttpContentSecurityPolicyReportOnlyParseError, - HttpCookieParseError, HttpCrossOriginEmbedderPolicyReportOnly, HttpCrossOriginOpenerPolicy, + HttpAccessControlAllowHeaders, HttpAccessControlAllowMethods, + HttpAccessControlAllowPrivateNetwork, HttpAccessControlAllowPrivateNetworkParseError, + HttpAccessControlRequestHeaders, HttpAccessControlRequestHeadersParseError, + HttpAccessControlRequestMethod, HttpAccessControlRequestMethodParseError, + HttpAccessControlRequestPrivateNetwork, HttpAccessControlRequestPrivateNetworkParseError, + HttpAltUsed, HttpAltUsedParseError, HttpAuthenticationInfo, HttpAuthenticationInfoParameter, + HttpAuthenticationInfoParseError, HttpAuthorization, HttpAuthorizationParseError, HttpBaggage, + HttpBaggageMember, HttpBaggageParseError, HttpBaggageProperty, HttpCacheStatus, + HttpCacheStatusParseError, HttpCdnCacheControl, HttpCdnLoop, HttpCdnLoopMember, + HttpCdnLoopParseError, HttpConditionalMetadata, HttpConnection, HttpConnectionParseError, + HttpContentDisposition, HttpContentDispositionParseError, HttpContentDpr, + HttpContentDprParseError, HttpContentLength, HttpContentLocation, HttpContentLocationParseError, + HttpContentRange, HttpContentRangeParseError, HttpContentSecurityPolicyReportOnly, + HttpContentSecurityPolicyReportOnlyParseError, HttpCookieParseError, + HttpCrossOriginEmbedderPolicyReportOnly, HttpCrossOriginOpenerPolicy, HttpCrossOriginOpenerPolicyReportOnly, HttpCrossOriginResourcePolicy, HttpDeltaBase, HttpDeltaBaseParseError, HttpDeprecation, HttpDeprecationParseError, HttpDepth, HttpDepthParseError, HttpDnt, HttpDntParseError, HttpDocumentPolicy, HttpDocumentPolicyDirective, @@ -100,6 +102,43 @@ fn server_dav_response_metadata_uses_protocol_representation() { assert!(invalid.dav().is_err()); } +#[test] +fn server_access_control_allow_private_network_is_bounded_metadata_only() { + let response = HttpResponse::ok("body") + .header("Access-Control-Allow-Private-Network", "legacy") + .header("access-control-allow-private-network", "legacy-case") + .with_access_control_allow_private_network("\ttrue ") + .expect("valid Access-Control-Allow-Private-Network should replace raw fields"); + let metadata = response + .access_control_allow_private_network() + .expect("typed metadata should parse") + .expect("typed metadata should be present"); + assert_eq!("true", metadata.header_value()); + let serialized = String::from_utf8(response.to_bytes()).expect("response should serialize"); + assert!(serialized.contains("Access-Control-Allow-Private-Network: true\r\n")); + assert!(!serialized.contains("legacy")); + assert!(serialized.contains("body")); + + let absent = HttpResponse::ok(""); + assert!(absent + .access_control_allow_private_network() + .expect("absence should parse") + .is_none()); + + let malformed = HttpResponse::ok("").header("Access-Control-Allow-Private-Network", "false"); + assert!(malformed.access_control_allow_private_network().is_err()); + let serialized = String::from_utf8(malformed.to_bytes()).expect("response should serialize"); + assert!(serialized.contains("Access-Control-Allow-Private-Network: false\r\n")); + + let unchanged = HttpResponse::ok("").header("Access-Control-Allow-Private-Network", "legacy"); + let _: Result = unchanged + .clone() + .with_access_control_allow_private_network("TRUE"); + assert!(unchanged.access_control_allow_private_network().is_err()); + let _: HttpAccessControlAllowPrivateNetwork = + HttpAccessControlAllowPrivateNetwork::parse("true").expect("true should parse"); +} + #[test] fn server_facade_exports_representative_bounded_metadata_types() { let accept_ch: HttpAcceptCh = HttpAcceptCh::parse("Sec-CH-UA").expect("Accept-CH should parse"); @@ -160,6 +199,14 @@ fn server_facade_exports_representative_bounded_metadata_types() { .expect("Access-Control-Allow-Credentials should parse"); let _: Result = HttpAccessControlAllowCredentials::parse("false"); + let allow_private_network: HttpAccessControlAllowPrivateNetwork = + HttpAccessControlAllowPrivateNetwork::parse("true") + .expect("Access-Control-Allow-Private-Network should parse"); + assert_eq!("true", allow_private_network.header_value()); + let _: Result< + HttpAccessControlAllowPrivateNetwork, + HttpAccessControlAllowPrivateNetworkParseError, + > = HttpAccessControlAllowPrivateNetwork::parse("false"); let allow_methods: HttpAccessControlAllowMethods = HttpAccessControlAllowMethods::parse("GET").expect("Access-Control-Allow-Methods should parse"); let allow_headers: HttpAccessControlAllowHeaders = diff --git a/crates/rttp/README.md b/crates/rttp/README.md index 5b2b9e07..273e77a5 100644 --- a/crates/rttp/README.md +++ b/crates/rttp/README.md @@ -14,6 +14,23 @@ Applications that want one dependency for both client and server entry points can use this compatibility facade. The compatibility facade keeps client and server entry points accessible through one crate. +## Bounded Access-Control-Allow-Private-Network response metadata + +With the `client` feature, the facade re-exports +`AccessControlAllowPrivateNetwork` and its parse error, and client +`Response::access_control_allow_private_network()` exposes bounded singleton +`Access-Control-Allow-Private-Network: true` response metadata. The server +facade exposes `HttpAccessControlAllowPrivateNetwork` plus +`HttpResponse::with_access_control_allow_private_network()` and +`HttpResponse::access_control_allow_private_network()`. Values are limited to +64 KiB, trim surrounding SP/HTAB, require case-sensitive `true`, reject +malformed, duplicate, control-byte, or oversized input, and preserve raw +headers when typed access fails. + +These are metadata-only helpers. The facade does not grant private-network +access, decide preflight behavior, or apply CORS or Private Network Access +policy. + ## Server Create a listener with `rttp::Http::server` or call `HttpServer::bind` directly. diff --git a/crates/rttp/src/lib.rs b/crates/rttp/src/lib.rs index c0738c47..cf36e825 100644 --- a/crates/rttp/src/lib.rs +++ b/crates/rttp/src/lib.rs @@ -8,7 +8,8 @@ pub use rttp_server::server; pub use rttp_client::response::{ AcceptCh, AcceptCharset, AcceptEncoding, AcceptPatch, AcceptPatchParseError, AcceptPost, AcceptPostParseError, AcceptRanges, AccessControlAllowCredentials, - AccessControlAllowCredentialsParseError, Age, AgeParseError, AltSvc, AltSvcAlternative, + AccessControlAllowCredentialsParseError, AccessControlAllowPrivateNetwork, + AccessControlAllowPrivateNetworkParseError, Age, AgeParseError, AltSvc, AltSvcAlternative, AltSvcParameter, AltSvcParseError, AltUsed, AltUsedParseError, AlternateAttribute, AlternateVariant, Alternates, AlternatesParseError, AuthenticationInfo, AuthenticationInfoParameter, AuthenticationInfoParseError, CacheControl, CacheControlExtension, diff --git a/crates/rttp/tests/metadata_facade.rs b/crates/rttp/tests/metadata_facade.rs index b1b7b250..0c35ad3a 100644 --- a/crates/rttp/tests/metadata_facade.rs +++ b/crates/rttp/tests/metadata_facade.rs @@ -184,6 +184,13 @@ fn compatibility_facade_exports_client_metadata_types() { let _: rttp::AccessControlAllowCredentialsParseError = rttp_client::response::AccessControlAllowCredentials::parse("false") .expect_err("invalid Access-Control-Allow-Credentials should fail"); + let allow_private_network: rttp::AccessControlAllowPrivateNetwork = + rttp_client::response::AccessControlAllowPrivateNetwork::parse(" true ") + .expect("Access-Control-Allow-Private-Network should parse"); + assert_eq!("true", allow_private_network.header_value()); + let _: rttp::AccessControlAllowPrivateNetworkParseError = + rttp_client::response::AccessControlAllowPrivateNetwork::parse("false") + .expect_err("invalid Access-Control-Allow-Private-Network should fail"); let client_sec_websocket_key = HttpSecWebSocketKey::parse("dGhlIHNhbXBsZSBub25jZQ==").expect("Sec-WebSocket-Key should parse"); let client_sec_websocket_accept: rttp::SecWebSocketAccept = diff --git a/tests/http11_client_server_matrix.rs b/tests/http11_client_server_matrix.rs index bacb5133..ac89d03c 100644 --- a/tests/http11_client_server_matrix.rs +++ b/tests/http11_client_server_matrix.rs @@ -1310,6 +1310,54 @@ fn sync_client_and_server_exchange_access_control_allow_credentials_metadata_wit .expect("Access-Control-Allow-Credentials server thread"); } +#[test] +fn sync_client_and_server_exchange_access_control_allow_private_network_metadata_without_policy() { + let server = rttp_server::server::HttpServer::bind("127.0.0.1:0") + .expect("bind Access-Control-Allow-Private-Network server"); + let addr = server + .local_addr() + .expect("Access-Control-Allow-Private-Network server addr"); + let handle = thread::spawn(move || { + server + .accept_one(|_| { + HttpResponse::ok("OK") + .with_access_control_allow_private_network("\ttrue ") + .expect("Access-Control-Allow-Private-Network should be accepted") + }) + .expect("serve Access-Control-Allow-Private-Network response"); + }); + + let response = client() + .get() + .url(format!( + "http://{addr}/matrix/access-control-allow-private-network" + )) + .emit() + .expect("Access-Control-Allow-Private-Network response should parse"); + assert_eq!( + "true", + response + .access_control_allow_private_network() + .expect("Access-Control-Allow-Private-Network should parse") + .expect("Access-Control-Allow-Private-Network should be present") + .header_value() + ); + assert_eq!( + Some(&"true".to_string()), + response.header_value("Access-Control-Allow-Private-Network") + ); + assert_eq!( + "OK", + response + .body() + .string() + .expect("response body should parse") + ); + handle + .join() + .expect("Access-Control-Allow-Private-Network server thread"); +} + #[test] fn sync_client_and_server_exchange_cross_origin_resource_policy_metadata_without_policy() { let server = rttp_server::server::HttpServer::bind("127.0.0.1:0")