From c63bdafffdce3871a55a15da606020ba1de6f963 Mon Sep 17 00:00:00 2001 From: fewensa Date: Mon, 20 Jul 2026 07:24:22 +0000 Subject: [PATCH] feat(cors): add access-control-allow-origin metadata --- crates/rttp-client/src/response/mod.rs | 3 + crates/rttp-client/src/response/response.rs | 13 ++ crates/rttp-client/tests/test_response.rs | 70 ++++++++++ .../src/access_control_allow_origin.rs | 123 ++++++++++++++++++ crates/rttp-protocol/src/lib.rs | 1 + .../tests/access_control_allow_origin.rs | 51 ++++++++ crates/rttp-server/src/server/response.rs | 44 +++++++ crates/rttp-server/src/server/server_tests.rs | 58 +++++++++ crates/rttp/tests/test_server_models.rs | 49 +++++-- tests/http11_client_server_matrix.rs | 39 ++++++ 10 files changed, 443 insertions(+), 8 deletions(-) create mode 100644 crates/rttp-protocol/src/access_control_allow_origin.rs create mode 100644 crates/rttp-protocol/tests/access_control_allow_origin.rs diff --git a/crates/rttp-client/src/response/mod.rs b/crates/rttp-client/src/response/mod.rs index 4c6c51df..bd0162a7 100644 --- a/crates/rttp-client/src/response/mod.rs +++ b/crates/rttp-client/src/response/mod.rs @@ -8,6 +8,9 @@ pub use rttp_protocol::access_control_allow_headers::{ pub use rttp_protocol::access_control_allow_methods::{ AccessControlAllowMethods, AccessControlAllowMethodsParseError, }; +pub use rttp_protocol::access_control_allow_origin::{ + AccessControlAllowOrigin, AccessControlAllowOriginParseError, +}; 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 04bf84cd..c393eda0 100644 --- a/crates/rttp-client/src/response/response.rs +++ b/crates/rttp-client/src/response/response.rs @@ -17,6 +17,7 @@ use crate::response::WwwAuthenticate; use crate::types::{Cookie, Header, RoUrl}; 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_expose_headers::AccessControlExposeHeaders; use rttp_protocol::access_control_max_age::AccessControlMaxAge; use rttp_protocol::clear_site_data::ClearSiteData; @@ -353,6 +354,18 @@ impl Response { .map_err(|parse_error| error::bad_response(parse_error.to_string())) } + /// Parses bounded `Access-Control-Allow-Origin` response metadata without + /// applying CORS origin policy. + pub fn access_control_allow_origin(&self) -> error::Result> { + let values = self.header_values("access-control-allow-origin"); + if values.is_empty() { + return Ok(None); + } + AccessControlAllowOrigin::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-Max-Age` response metadata without applying CORS caching. pub fn access_control_max_age(&self) -> error::Result> { let values = self.header_values("access-control-max-age"); diff --git a/crates/rttp-client/tests/test_response.rs b/crates/rttp-client/tests/test_response.rs index f3fa7c6b..3e33b8d5 100644 --- a/crates/rttp-client/tests/test_response.rs +++ b/crates/rttp-client/tests/test_response.rs @@ -2480,6 +2480,76 @@ fn test_access_control_allow_methods_response_helper_preserves_invalid_or_absent ); } +#[test] +fn test_access_control_allow_origin_response_helper_parses_valid_metadata_and_preserves_invalid_raw_headers( +) { + for value in ["*", "null", "https://example.test:8443"] { + let raw = format!( + "HTTP/1.1 200 OK\r\nAccess-Control-Allow-Origin: {value}\r\nContent-Length: 0\r\n\r\n" + ); + let response = Response::new(RoUrl::with("https://example.test"), raw.into_bytes()) + .expect("raw response should remain usable"); + assert_eq!( + value, + response + .access_control_allow_origin() + .expect("Access-Control-Allow-Origin should parse") + .expect("Access-Control-Allow-Origin should be present") + .header_value() + ); + } + + 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("raw response without metadata should remain usable"); + assert_eq!( + None, + absent + .access_control_allow_origin() + .expect("absence should parse") + ); + + for value in [ + "https://example.test, https://other.test".to_string(), + "https://example.test/path".to_string(), + "x".repeat(64 * 1024 + 1), + ] { + let raw = format!( + "HTTP/1.1 200 OK\r\nAccess-Control-Allow-Origin: {value}\r\nContent-Length: 0\r\n\r\n" + ); + let response = Response::new(RoUrl::with("https://example.test"), raw.into_bytes()) + .expect("raw response should remain usable"); + assert!(response.access_control_allow_origin().is_err()); + assert_eq!( + response.header_value("Access-Control-Allow-Origin"), + Some(&value) + ); + } + + let duplicate = Response::new( + RoUrl::with("https://example.test"), + concat!( + "HTTP/1.1 200 OK\r\n", + "Access-Control-Allow-Origin: https://example.test\r\n", + "access-control-allow-origin: https://other.test\r\n", + "Content-Length: 0\r\n\r\n" + ) + .as_bytes() + .to_vec(), + ) + .expect("response with duplicate metadata should remain usable"); + assert!(duplicate.access_control_allow_origin().is_err()); + assert_eq!( + duplicate.header_values("Access-Control-Allow-Origin"), + [ + &"https://example.test".to_string(), + &"https://other.test".to_string() + ] + ); +} + #[test] fn test_access_control_allow_headers_response_helper_parses_valid_lists_wildcard_and_multiple_fields( ) { diff --git a/crates/rttp-protocol/src/access_control_allow_origin.rs b/crates/rttp-protocol/src/access_control_allow_origin.rs new file mode 100644 index 00000000..c7d773d6 --- /dev/null +++ b/crates/rttp-protocol/src/access_control_allow_origin.rs @@ -0,0 +1,123 @@ +//! Bounded, policy-free `Access-Control-Allow-Origin` response metadata parsing. +//! +//! This module validates the response field value only. Callers decide whether +//! and how to apply CORS policy. + +use std::error::Error; +use std::fmt; + +use crate::origin::Origin; + +/// Maximum bytes accepted in an `Access-Control-Allow-Origin` field value. +pub const MAX_ACCESS_CONTROL_ALLOW_ORIGIN_VALUE_BYTES: usize = 64 * 1024; + +/// Parsed, bounded `Access-Control-Allow-Origin` response metadata. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum AccessControlAllowOrigin { + Wildcard, + Origin(Origin), +} + +impl AccessControlAllowOrigin { + 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)?; + if value == "*" { + return Ok(Self::Wildcard); + } + Origin::parse(value) + .map(Self::Origin) + .map_err(|error| AccessControlAllowOriginParseError::new(error.to_string())) + } + + pub const fn is_wildcard(&self) -> bool { + matches!(self, Self::Wildcard) + } + + pub fn origin(&self) -> Option<&Origin> { + match self { + Self::Wildcard => None, + Self::Origin(origin) => Some(origin), + } + } + + pub fn header_value(&self) -> String { + match self { + Self::Wildcard => "*".to_string(), + Self::Origin(origin) => origin.header_value(), + } + } +} + +/// An error returned when `Access-Control-Allow-Origin` metadata is malformed or exceeds bounds. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccessControlAllowOriginParseError { + message: String, +} + +impl AccessControlAllowOriginParseError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for AccessControlAllowOriginParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for AccessControlAllowOriginParseError {} + +fn parse_singleton<'a, I>(values: I) -> Result<&'a str, AccessControlAllowOriginParseError> +where + I: IntoIterator, +{ + let mut values = values.into_iter(); + let value = values.next().ok_or_else(invalid_value)?; + validate_value(value)?; + let mut has_duplicate = false; + for value in values { + has_duplicate = true; + validate_value(value)?; + } + if has_duplicate { + return Err(AccessControlAllowOriginParseError::new( + "duplicate Access-Control-Allow-Origin header fields", + )); + } + let value = value.trim_matches([' ', '\t']); + if value.is_empty() || value.contains(',') { + return Err(invalid_value()); + } + Ok(value) +} + +fn validate_value(value: &str) -> Result<(), AccessControlAllowOriginParseError> { + if value.len() > MAX_ACCESS_CONTROL_ALLOW_ORIGIN_VALUE_BYTES { + return Err(AccessControlAllowOriginParseError::new( + "Access-Control-Allow-Origin header value is too large", + )); + } + if value + .bytes() + .any(|byte| byte.is_ascii_control() && byte != b'\t') + { + return Err(AccessControlAllowOriginParseError::new( + "invalid Access-Control-Allow-Origin header control byte", + )); + } + Ok(()) +} + +fn invalid_value() -> AccessControlAllowOriginParseError { + AccessControlAllowOriginParseError::new("invalid Access-Control-Allow-Origin header value") +} diff --git a/crates/rttp-protocol/src/lib.rs b/crates/rttp-protocol/src/lib.rs index 00149276..9a34a77b 100644 --- a/crates/rttp-protocol/src/lib.rs +++ b/crates/rttp-protocol/src/lib.rs @@ -7,6 +7,7 @@ pub mod accept_patch; pub mod accept_post; pub mod access_control_allow_headers; pub mod access_control_allow_methods; +pub mod access_control_allow_origin; pub mod access_control_expose_headers; pub mod access_control_max_age; pub mod alt_svc; diff --git a/crates/rttp-protocol/tests/access_control_allow_origin.rs b/crates/rttp-protocol/tests/access_control_allow_origin.rs new file mode 100644 index 00000000..aa3af49b --- /dev/null +++ b/crates/rttp-protocol/tests/access_control_allow_origin.rs @@ -0,0 +1,51 @@ +use rttp_protocol::access_control_allow_origin::{ + AccessControlAllowOrigin, MAX_ACCESS_CONTROL_ALLOW_ORIGIN_VALUE_BYTES, +}; +use rttp_protocol::origin::{Origin, OriginScheme}; + +#[test] +fn access_control_allow_origin_parses_wildcard_null_and_tuple_origins() { + let wildcard = + AccessControlAllowOrigin::parse("*").expect("wildcard Access-Control-Allow-Origin"); + assert!(wildcard.is_wildcard()); + assert_eq!(None, wildcard.origin()); + assert_eq!("*", wildcard.header_value()); + + let null = AccessControlAllowOrigin::parse("null").expect("null Access-Control-Allow-Origin"); + assert_eq!(Some(&Origin::Null), null.origin()); + assert_eq!("null", null.header_value()); + + let tuple = AccessControlAllowOrigin::parse("https://example.test:8443") + .expect("tuple Access-Control-Allow-Origin"); + let origin = tuple.origin().expect("tuple origin should be present"); + let origin = origin.tuple().expect("tuple origin should parse"); + assert_eq!(OriginScheme::Https, origin.scheme()); + assert_eq!("example.test", origin.host()); + assert_eq!(Some(8443), origin.port()); + assert_eq!("https://example.test:8443", tuple.header_value()); +} + +#[test] +fn access_control_allow_origin_rejects_duplicate_and_malformed_values() { + for values in [ + vec!["*", "null"], + vec!["https://example.test", "https://other.test"], + vec!["https://example.test, https://other.test"], + vec!["https://example.test\r\n"], + vec!["https://example.test/path"], + vec!["ftp://example.test"], + ] { + assert!( + AccessControlAllowOrigin::parse_values(values.iter().copied()).is_err(), + "{values:?} must be rejected" + ); + } +} + +#[test] +fn access_control_allow_origin_enforces_the_value_bound() { + assert!(AccessControlAllowOrigin::parse( + "x".repeat(MAX_ACCESS_CONTROL_ALLOW_ORIGIN_VALUE_BYTES + 1) + ) + .is_err()); +} diff --git a/crates/rttp-server/src/server/response.rs b/crates/rttp-server/src/server/response.rs index 4a10e69a..9a41e0ac 100644 --- a/crates/rttp-server/src/server/response.rs +++ b/crates/rttp-server/src/server/response.rs @@ -8,6 +8,10 @@ pub use rttp_protocol::access_control_allow_methods::{ AccessControlAllowMethods as HttpAccessControlAllowMethods, AccessControlAllowMethodsParseError as HttpAccessControlAllowMethodsParseError, }; +pub use rttp_protocol::access_control_allow_origin::{ + AccessControlAllowOrigin as HttpAccessControlAllowOrigin, + AccessControlAllowOriginParseError as HttpAccessControlAllowOriginParseError, +}; pub use rttp_protocol::alt_svc::{ AltSvc as HttpAltSvc, AltSvcAlternative as HttpAltSvcAlternative, AltSvcParameter as HttpAltSvcParameter, AltSvcParseError as HttpAltSvcParseError, @@ -701,6 +705,25 @@ impl HttpResponse { Ok(self) } + /// Validates and replaces `Access-Control-Allow-Origin` response metadata + /// without applying CORS policy. + pub fn with_access_control_allow_origin( + mut self, + value: impl AsRef, + ) -> Result { + let allow_origin = HttpAccessControlAllowOrigin::parse(value)?; + self.headers.retain(|header| { + !header + .name + .eq_ignore_ascii_case("Access-Control-Allow-Origin") + }); + self.headers.push(HttpHeader::new( + "Access-Control-Allow-Origin", + allow_origin.header_value(), + )); + Ok(self) + } + /// Validates and replaces `Access-Control-Allow-Headers` response metadata /// without applying CORS policy. pub fn with_access_control_allow_headers( @@ -1195,6 +1218,27 @@ impl HttpResponse { HttpAccessControlAllowMethods::parse_values(values).map(Some) } + /// Parses attached `Access-Control-Allow-Origin` response metadata without + /// applying CORS policy. + pub fn access_control_allow_origin( + &self, + ) -> Result, HttpAccessControlAllowOriginParseError> { + let values: Vec<&str> = self + .headers + .iter() + .filter(|header| { + header + .name + .eq_ignore_ascii_case("Access-Control-Allow-Origin") + }) + .map(|header| header.value.as_str()) + .collect(); + if values.is_empty() { + return Ok(None); + } + HttpAccessControlAllowOrigin::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/src/server/server_tests.rs b/crates/rttp-server/src/server/server_tests.rs index 81a51ee7..4d8e8f1f 100644 --- a/crates/rttp-server/src/server/server_tests.rs +++ b/crates/rttp-server/src/server/server_tests.rs @@ -149,6 +149,64 @@ fn access_control_allow_methods_helpers_validate_replace_and_parse_response_meta ); } +#[test] +fn access_control_allow_origin_helpers_validate_replace_and_preserve_raw_metadata() { + let response = HttpResponse::ok([]) + .header("Access-Control-Allow-Origin", "https://legacy.test") + .header("access-control-allow-origin", "https://deprecated.test") + .with_access_control_allow_origin("https://example.test:8443") + .expect("Access-Control-Allow-Origin should be accepted"); + + assert_eq!( + "https://example.test:8443", + response + .access_control_allow_origin() + .expect("Access-Control-Allow-Origin should parse") + .expect("Access-Control-Allow-Origin should be present") + .header_value() + ); + assert_eq!( + vec![("Access-Control-Allow-Origin", "https://example.test:8443")], + response + .headers + .iter() + .map(|header| (header.name.as_str(), header.value.as_str())) + .collect::>() + ); + + let malformed = HttpResponse::ok([]).header("Access-Control-Allow-Origin", "https://example.test/path"); + assert!(malformed.access_control_allow_origin().is_err()); + assert!(HttpResponse::ok([]) + .with_access_control_allow_origin("https://example.test/path") + .is_err()); + assert_eq!( + None, + HttpResponse::ok([]) + .access_control_allow_origin() + .expect("absent Access-Control-Allow-Origin should parse") + ); + for value in ["*", "null"] { + assert_eq!( + value, + HttpResponse::ok([]) + .with_access_control_allow_origin(value) + .expect("valid Access-Control-Allow-Origin should be accepted") + .access_control_allow_origin() + .expect("Access-Control-Allow-Origin should parse") + .expect("Access-Control-Allow-Origin should be present") + .header_value() + ); + } + + let duplicate = HttpResponse::ok([]) + .header("Access-Control-Allow-Origin", "https://example.test") + .header("access-control-allow-origin", "https://other.test"); + assert!(duplicate.access_control_allow_origin().is_err()); + assert!(HttpResponse::ok([]) + .with_access_control_allow_origin("x".repeat(64 * 1024 + 1)) + .is_err()); +} + #[test] fn access_control_allow_headers_helpers_validate_replace_and_parse_response_metadata() { let response = HttpResponse::ok([]) diff --git a/crates/rttp/tests/test_server_models.rs b/crates/rttp/tests/test_server_models.rs index dba757cc..1b232f52 100644 --- a/crates/rttp/tests/test_server_models.rs +++ b/crates/rttp/tests/test_server_models.rs @@ -2,16 +2,49 @@ use std::time::{Duration, UNIX_EPOCH}; use rttp::server::{ HttpAccept, HttpAcceptCh, HttpAcceptRanges, HttpAccessControlAllowHeaders, - HttpAccessControlAllowMethods, HttpAllowedMethods, HttpAuthorization, HttpByteRange, - HttpByteRangeError, HttpClearSiteData, HttpConditionalMetadata, HttpContentDisposition, - HttpContentLanguages, HttpContentSecurityPolicy, HttpContentType, HttpCriticalCh, HttpEntityTag, - HttpExpectations, HttpIfNoneMatch, HttpIfRange, HttpIfRangeRequestOutcome, HttpLinkValues, - HttpPermissionsPolicy, HttpReferrerPolicy, HttpReportingEndpoints, HttpRequest, - HttpRequestAcceptEncodings, HttpRequestCacheControl, HttpRequestTe, HttpResponse, - HttpResponseCacheControl, HttpResponseContentEncodings, HttpRetryAfter, HttpServerTiming, - HttpVary, + HttpAccessControlAllowMethods, HttpAccessControlAllowOrigin, HttpAllowedMethods, + HttpAuthorization, HttpByteRange, HttpByteRangeError, HttpClearSiteData, HttpConditionalMetadata, + HttpContentDisposition, HttpContentLanguages, HttpContentSecurityPolicy, HttpContentType, + HttpCriticalCh, HttpEntityTag, HttpExpectations, HttpIfNoneMatch, HttpIfRange, + HttpIfRangeRequestOutcome, HttpLinkValues, HttpPermissionsPolicy, HttpReferrerPolicy, + HttpReportingEndpoints, HttpRequest, HttpRequestAcceptEncodings, HttpRequestCacheControl, + HttpRequestTe, HttpResponse, HttpResponseCacheControl, HttpResponseContentEncodings, + HttpRetryAfter, HttpServerTiming, HttpVary, }; +#[test] +fn response_access_control_allow_origin_helper_validates_and_preserves_raw_headers() { + let response = HttpResponse::ok("body") + .header("Access-Control-Allow-Origin", "https://legacy.test") + .header("access-control-allow-origin", "https://deprecated.test") + .with_access_control_allow_origin("https://example.test:8443") + .expect("valid Access-Control-Allow-Origin should be accepted"); + + let origin: HttpAccessControlAllowOrigin = response + .access_control_allow_origin() + .expect("attached Access-Control-Allow-Origin should parse") + .expect("Access-Control-Allow-Origin should be present"); + assert_eq!("https://example.test:8443", origin.header_value()); + let serialized = String::from_utf8(response.to_bytes()).expect("response should serialize"); + assert_eq!( + 1, + serialized + .matches("\r\nAccess-Control-Allow-Origin: ") + .count() + ); + assert!(serialized.contains("\r\nAccess-Control-Allow-Origin: https://example.test:8443\r\n")); + + assert!(HttpResponse::ok("body") + .with_access_control_allow_origin("https://example.test/path") + .is_err()); + let raw = + HttpResponse::ok("body").header("Access-Control-Allow-Origin", "https://example.test/path"); + assert!(raw.access_control_allow_origin().is_err()); + assert!(String::from_utf8(raw.to_bytes()) + .expect("response should serialize") + .contains("\r\nAccess-Control-Allow-Origin: https://example.test/path\r\n")); +} + #[test] fn response_access_control_allow_methods_helper_validates_and_preserves_raw_headers() { let response = HttpResponse::ok("body") diff --git a/tests/http11_client_server_matrix.rs b/tests/http11_client_server_matrix.rs index 81c2179e..833e3a7e 100644 --- a/tests/http11_client_server_matrix.rs +++ b/tests/http11_client_server_matrix.rs @@ -495,6 +495,45 @@ fn spawn_metadata_response_server( (addr, handle) } +#[test] +fn sync_client_and_server_exchange_access_control_allow_origin_metadata_without_policy() { + let server = rttp_server::server::HttpServer::bind("127.0.0.1:0") + .expect("bind Access-Control-Allow-Origin server"); + let addr = server + .local_addr() + .expect("Access-Control-Allow-Origin server addr"); + let handle = thread::spawn(move || { + server + .accept_one(|_| { + HttpResponse::ok("OK") + .with_access_control_allow_origin("https://example.test:8443") + .expect("Access-Control-Allow-Origin should be accepted") + }) + .expect("serve Access-Control-Allow-Origin response"); + }); + + let response = client() + .get() + .url(format!("http://{addr}/matrix/access-control-allow-origin")) + .emit() + .expect("Access-Control-Allow-Origin response should parse"); + assert_eq!( + "https://example.test:8443", + response + .access_control_allow_origin() + .expect("Access-Control-Allow-Origin should parse") + .expect("Access-Control-Allow-Origin should be present") + .header_value() + ); + assert_eq!( + Some(&"https://example.test:8443".to_string()), + response.header_value("Access-Control-Allow-Origin") + ); + handle + .join() + .expect("Access-Control-Allow-Origin server thread"); +} + #[test] fn sync_client_sec_fetch_metadata_is_observed_by_server_helpers() { let server =