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
3 changes: 3 additions & 0 deletions crates/rttp-client/src/response/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
13 changes: 13 additions & 0 deletions crates/rttp-client/src/response/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Option<AccessControlAllowOrigin>> {
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<Option<AccessControlMaxAge>> {
let values = self.header_values("access-control-max-age");
Expand Down
70 changes: 70 additions & 0 deletions crates/rttp-client/tests/test_response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
) {
Expand Down
123 changes: 123 additions & 0 deletions crates/rttp-protocol/src/access_control_allow_origin.rs
Original file line number Diff line number Diff line change
@@ -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<str>) -> Result<Self, AccessControlAllowOriginParseError> {
Self::parse_values([value.as_ref()])
}

pub fn parse_values<'a, I>(values: I) -> Result<Self, AccessControlAllowOriginParseError>
where
I: IntoIterator<Item = &'a str>,
{
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<String>) -> 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<Item = &'a str>,
{
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")
}
1 change: 1 addition & 0 deletions crates/rttp-protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
51 changes: 51 additions & 0 deletions crates/rttp-protocol/tests/access_control_allow_origin.rs
Original file line number Diff line number Diff line change
@@ -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());
}
44 changes: 44 additions & 0 deletions crates/rttp-server/src/server/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<str>,
) -> Result<Self, HttpAccessControlAllowOriginParseError> {
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(
Expand Down Expand Up @@ -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<Option<HttpAccessControlAllowOrigin>, 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(
Expand Down
Loading