Compare commits

...

4 Commits

Author SHA1 Message Date
Hauser Zhi 0f125bb4ec chore: merge main after container startup fixes
Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-11-07 01:42:56 +08:00
Hauser Zhi bda712df56 fix(cors): preserve readiness and complete cache selectors
Use a direct response future for originless requests and forward through
the polled-ready service. Nominate every retained CORS header input and
transfer evaluated headers without rebuilding the map.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-11-07 01:42:26 +08:00
Hauser Zhi 00102f5737 chore: merge main into the CORS cache fix
Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-11-07 00:28:13 +08:00
Hauser Zhi 55246fa932 fix(cors): isolate cache variants and streamline policy evaluation
Apply cache variation at the shared response boundary, preserve existing
Vary selectors, and reuse parsed global policy and compact CORS inputs.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-11-07 00:27:53 +08:00
4 changed files with 1178 additions and 236 deletions
+113 -1
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! CORS (Cross-Origin Resource Sharing) header name constants.
//! CORS (Cross-Origin Resource Sharing) headers and request inputs.
//!
//! This module provides centralized constants for CORS-related HTTP header names.
//! The http crate doesn't provide pre-defined constants for CORS headers,
@@ -38,3 +38,115 @@ pub mod request {
pub mod standard {
pub use http::header::{ORIGIN, VARY};
}
/// Only the inputs used by CORS policy evaluation. Credential payloads and
/// unrelated object metadata need not outlive the inner request.
pub(crate) struct CorsRequest {
pub(crate) origin: Option<http::HeaderValue>,
pub(crate) requested_method: Option<http::HeaderValue>,
pub(crate) requested_headers: Option<http::HeaderValue>,
pub(crate) credentialed: bool,
}
impl CorsRequest {
pub(crate) fn from_headers(headers: &http::HeaderMap) -> Self {
Self {
origin: headers.get(standard::ORIGIN).cloned(),
requested_method: headers.get(request::ACCESS_CONTROL_REQUEST_METHOD).cloned(),
requested_headers: headers.get(request::ACCESS_CONTROL_REQUEST_HEADERS).cloned(),
credentialed: headers.contains_key(http::header::AUTHORIZATION)
|| headers.contains_key(http::header::COOKIE)
|| headers.contains_key("x-amz-security-token")
|| headers.contains_key("x-amz-content-sha256"),
}
}
}
#[cfg(test)]
mod tests {
use super::CorsRequest;
use http::{HeaderMap, HeaderValue};
#[test]
fn cors_request_captures_first_value_without_mutating_duplicate_headers() {
let mut headers = HeaderMap::new();
for (name, first, second) in [
("origin", "https://first.example", "https://second.example"),
("access-control-request-method", "PUT", "DELETE"),
("access-control-request-headers", "x-first", "x-second"),
] {
headers.append(name, HeaderValue::from_static(first));
headers.append(name, HeaderValue::from_static(second));
}
let original = headers.clone();
let request = CorsRequest::from_headers(&headers);
assert_eq!(request.origin, Some(HeaderValue::from_static("https://first.example")));
assert_eq!(request.requested_method, Some(HeaderValue::from_static("PUT")));
assert_eq!(request.requested_headers, Some(HeaderValue::from_static("x-first")));
assert!(!request.credentialed);
assert_eq!(headers, original);
}
#[test]
fn cors_request_preserves_opaque_values_separately_from_absence() {
let mut headers = HeaderMap::new();
let opaque = HeaderValue::from_bytes(&[0x80]).expect("opaque HTTP header value");
assert!(opaque.to_str().is_err());
for name in ["origin", "access-control-request-method", "access-control-request-headers"] {
headers.insert(name, opaque.clone());
}
let original = headers.clone();
let request = CorsRequest::from_headers(&headers);
assert_eq!(request.origin, Some(opaque.clone()));
assert_eq!(request.requested_method, Some(opaque.clone()));
assert_eq!(request.requested_headers, Some(opaque));
assert!(!request.credentialed);
assert_eq!(headers, original);
}
#[test]
fn cors_request_detects_each_credential_header_by_presence() {
for name in ["authorization", "cookie", "x-amz-security-token", "x-amz-content-sha256"] {
for value in ["", "present"] {
let mut headers = HeaderMap::new();
headers.insert(name, HeaderValue::from_static(value));
let original = headers.clone();
let request = CorsRequest::from_headers(&headers);
assert!(request.credentialed, "{name} with value {value:?}");
assert!(request.origin.is_none());
assert!(request.requested_method.is_none());
assert!(request.requested_headers.is_none());
assert_eq!(headers, original);
}
}
}
#[test]
fn cors_request_ignores_unrelated_headers_and_preserves_the_request() {
let mut headers = HeaderMap::new();
headers.insert("x-amz-meta-large", HeaderValue::from_str(&"a".repeat(16 * 1024)).expect("large metadata"));
for name in [
"proxy-authorization",
"x-amz-date",
"x-amz-meta-authorization",
"x-amz-security-token-extra",
] {
headers.insert(name, HeaderValue::from_static("present"));
}
let original = headers.clone();
let request = CorsRequest::from_headers(&headers);
assert!(request.origin.is_none());
assert!(request.requested_method.is_none());
assert!(request.requested_headers.is_none());
assert!(!request.credentialed);
assert_eq!(headers, original);
}
}
+1037 -177
View File
File diff suppressed because it is too large Load Diff
+20 -48
View File
@@ -936,18 +936,12 @@ pub(crate) fn needs_cors_processing(headers: &HeaderMap) -> bool {
///
/// Note: This function should only be called if `needs_cors_processing()` returns true
/// to avoid unnecessary overhead for non-CORS requests.
pub(crate) async fn apply_cors_headers(bucket: &str, method: &http::Method, headers: &HeaderMap) -> Option<HeaderMap> {
pub(crate) async fn apply_cors_headers(bucket: &str, method: &http::Method, request: &cors::CorsRequest) -> Option<HeaderMap> {
use http::HeaderValue;
fn is_credentialed_request(headers: &HeaderMap) -> bool {
headers.contains_key(http::header::AUTHORIZATION)
|| headers.contains_key(http::header::COOKIE)
|| headers.contains_key("x-amz-security-token")
|| headers.contains_key("x-amz-content-sha256")
}
// Get Origin header from request
let origin = headers.get(cors::standard::ORIGIN)?.to_str().ok()?;
// Invalid and absent origins do not require a bucket metadata lookup.
let origin_header = request.origin.as_ref()?;
let origin = origin_header.to_str().ok()?;
// Get CORS configuration for the bucket
let cors_config = match get_bucket_cors_config(bucket).await {
@@ -970,15 +964,17 @@ pub(crate) async fn apply_cors_headers(bucket: &str, method: &http::Method, head
// Use Access-Control-Request-Method if present (for preflight and non-preflight requests),
// otherwise fall back to the actual HTTP method.
let is_preflight = method == http::Method::OPTIONS;
let requested_method = headers
.get(cors::request::ACCESS_CONTROL_REQUEST_METHOD)
let requested_method = request
.requested_method
.as_ref()
.and_then(|v| v.to_str().ok())
.unwrap_or(method_str);
// Get requested headers from preflight request
let requested_headers = if is_preflight {
headers
.get(cors::request::ACCESS_CONTROL_REQUEST_HEADERS)
request
.requested_headers
.as_ref()
.and_then(|v| v.to_str().ok())
.map(|h| h.split(',').map(|s| s.trim().to_lowercase()).collect::<Vec<_>>())
} else {
@@ -1041,27 +1037,16 @@ pub(crate) async fn apply_cors_headers(bucket: &str, method: &http::Method, head
// Credentials mode + wildcard allow list requires echoing the request origin.
// Browsers reject `Access-Control-Allow-Origin: *` with `credentials: include`.
let has_wildcard_origin = rule.allowed_origins.iter().any(|o| o == "*");
let credentialed_request = is_credentialed_request(headers);
let mut origin_reflected = false;
let credentialed_request = request.credentialed;
if has_wildcard_origin {
if credentialed_request {
if let Ok(origin_value) = HeaderValue::from_str(origin) {
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN, origin_value);
origin_reflected = true;
}
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN, origin_header.clone());
} else {
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
}
} else if let Ok(origin_value) = HeaderValue::from_str(origin) {
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN, origin_value);
origin_reflected = true;
}
// Vary: Origin whenever origin is reflected (non-"*" allow-origin).
// This prevents proxy/browser caches from reusing CORS headers across different origins.
if origin_reflected {
response_headers.insert(cors::standard::VARY, HeaderValue::from_static("Origin"));
} else {
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN, origin_header.clone());
}
// Credentials mode requires explicit allow-credentials.
@@ -1086,15 +1071,6 @@ pub(crate) async fn apply_cors_headers(bucket: &str, method: &http::Method, head
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_HEADERS, headers_value);
}
}
if is_preflight {
let vary = if origin_reflected {
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
} else {
"Access-Control-Request-Method, Access-Control-Request-Headers"
};
response_headers.insert(cors::standard::VARY, HeaderValue::from_static(vary));
}
// Access-Control-Expose-Headers (for actual requests)
if !is_preflight && let Some(ref expose_headers) = rule.expose_headers {
let expose_headers_str = expose_headers.iter().map(|h| h.as_str()).collect::<Vec<_>>().join(", ");
@@ -1131,7 +1107,8 @@ pub(crate) fn matches_origin_pattern(pattern: &str, origin: &str) -> bool {
}
}
/// Wrap S3Response with CORS headers if needed
/// Wrap S3Response with CORS grants if needed. Cache variation is applied by
/// `ConditionalCorsService` to all responses, including originless requests.
/// This function performs a lightweight check first to avoid unnecessary CORS processing
/// for non-CORS requests (requests without Origin header)
pub(crate) async fn wrap_response_with_cors<T>(
@@ -1140,18 +1117,13 @@ pub(crate) async fn wrap_response_with_cors<T>(
headers: &HeaderMap,
output: T,
) -> S3Response<T> {
let mut response = S3Response::new(output);
// Quick check: only process CORS if Origin header is present
if needs_cors_processing(headers)
&& let Some(cors_headers) = apply_cors_headers(bucket, method, headers).await
&& let Some(cors_headers) = apply_cors_headers(bucket, method, &cors::CorsRequest::from_headers(headers)).await
{
for (key, value) in cors_headers.iter() {
response.headers.insert(key, value.clone());
}
S3Response::with_headers(output, cors_headers)
} else {
S3Response::new(output)
}
response
}
/// Parse part number from Option<i32> to Option<usize> with validation
+8 -10
View File
@@ -1927,7 +1927,7 @@ mod tests {
let method = http::Method::GET;
// Should return None when no origin header
let result = apply_cors_headers("test-bucket", &method, &headers).await;
let result = apply_cors_headers("test-bucket", &method, &cors::CorsRequest::from_headers(&headers)).await;
assert!(result.is_none(), "Should return None when no Origin header");
}
@@ -1941,7 +1941,8 @@ mod tests {
// Should return None when no CORS config exists
// Note: This test may fail if test-bucket actually has CORS config
// In a real scenario, we'd use a mock or ensure the bucket doesn't exist
let _result = apply_cors_headers("non-existent-bucket-for-testing", &method, &headers).await;
let _result =
apply_cors_headers("non-existent-bucket-for-testing", &method, &cors::CorsRequest::from_headers(&headers)).await;
// Result depends on whether bucket exists and has CORS config
// This is expected behavior - we just verify it doesn't panic
}
@@ -1970,7 +1971,7 @@ mod tests {
let mut headers = HeaderMap::new();
headers.insert(cors::standard::ORIGIN, "https://disallowed.example.com".parse().unwrap());
let result = apply_cors_headers(bucket, &http::Method::GET, &headers).await;
let result = apply_cors_headers(bucket, &http::Method::GET, &cors::CorsRequest::from_headers(&headers)).await;
assert!(
result.is_some(),
"Expected Some empty headers when bucket has CORS config but origin does not match any rule"
@@ -2011,17 +2012,14 @@ mod tests {
headers.insert(cors::request::ACCESS_CONTROL_REQUEST_HEADERS, "x-amz-content-sha256".parse().unwrap());
headers.insert(http::header::AUTHORIZATION, "AWS4-HMAC-SHA256 Credential=test/20260302/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256, Signature=abc".parse().unwrap());
let result = apply_cors_headers(bucket, &http::Method::OPTIONS, &headers).await.unwrap();
let result = apply_cors_headers(bucket, &http::Method::OPTIONS, &cors::CorsRequest::from_headers(&headers))
.await
.unwrap();
assert_eq!(
result.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
"https://console.localhost",
);
assert_eq!(result.get(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), "true");
assert_eq!(
result.get(cors::standard::VARY).unwrap(),
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
);
set_bucket_metadata(bucket.to_string(), BucketMetadata::new(bucket))
.await
.unwrap();
@@ -2034,7 +2032,7 @@ mod tests {
headers.insert("origin", "https://example.com".parse().unwrap());
let method = http::Method::PATCH; // Unsupported method
let result = apply_cors_headers("test-bucket", &method, &headers).await;
let result = apply_cors_headers("test-bucket", &method, &cors::CorsRequest::from_headers(&headers)).await;
assert!(result.is_none(), "Should return None for unsupported methods");
}