mirror of
https://git.deuxfleurs.fr/Deuxfleurs/garage.git
synced 2026-05-05 16:56:10 +02:00
fix(cors): return single matching origin instead of multiple values in Access-Control-Allow-Origin (#1419)
## Title fix(cors): return single matching origin instead of multiple values in `Access-Control-Allow-Origin` ## Summary This PR fixes bucket CORS responses when a single CORS rule contains multiple `AllowedOrigins`. Previously, Garage returned the configured origins as a comma-separated list in `Access-Control-Allow-Origin`, for example: ```http Access-Control-Allow-Origin: https://app.example.test, https://admin.example.test ``` This is not the expected browser-facing behavior. When a request origin matches a configured rule, the response should reflect **only the matching request origin**, unless the rule contains `*`. ## What changed - `Access-Control-Allow-Origin` now behaves as follows: - returns `*` when the matched rule contains a wildcard origin - otherwise returns the request `Origin` as a **single value** - added `Vary: Origin` when ACAO reflects the request origin - added preflight-specific `Vary` handling in the preflight path for: - `Origin` - `Access-Control-Request-Method` - `Access-Control-Request-Headers` ## Scope This change applies to shared bucket CORS handling paths, including: - S3 API responses - K2V API responses - S3 POST object responses - web bucket responses - preflight (`OPTIONS`) bucket CORS responses This does **not** change admin API fixed CORS behavior. ## Reproduction A direct repro script is included: ```bash ./script/test-cors-multi-origin.sh ``` It exercises two cases against a direct single-node Garage instance: 1. **single-origin control** 2. **multi-origin repro** Before this fix, the multi-origin case returned a comma-separated ACAO value. After this fix, both cases reflect only the request origin. ## Example behavior ### Before ```http Access-Control-Allow-Origin: https://app.example.test, https://admin.example.test ``` ### After ```http Access-Control-Allow-Origin: https://app.example.test ``` ## Tests Added/updated tests in `src/api/common/cors.rs` for: - single-origin control - multiple allowed origins reflecting the request origin - wildcard origin preserving `*` - preserving existing `Vary` values while appending `Origin` ## Validation Used for validation: ```bash cargo test -p garage_api_common cors::tests -- --nocapture cargo build -p garage --bin garage ./script/test-cors-multi-origin.sh ``` ## Reproducibility For reviewers who want to validate behavior by commit: - Before fix: `aa368e4b` - includes the direct repro script and the regression test setup - multi-origin ACAO is reproduced as a comma-separated value - After fix: `f630eb92` - reflects only the matching request origin - preserves wildcard behavior - adds `Vary: Origin` and preflight-specific `Vary` handling Branch: - `fix/cors-multiple-allow-origin` Base used during validation: - `74ad3bf8` (`main-v2`) Closes Deuxfleurs/garage#1149 Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1419
This commit is contained in:
parent
80f9335950
commit
a2c797000f
@ -1,8 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use http::header::{
|
||||
ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD,
|
||||
HeaderValue, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
|
||||
ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_REQUEST_HEADERS,
|
||||
ACCESS_CONTROL_REQUEST_METHOD, VARY,
|
||||
};
|
||||
use hyper::{body::Body, body::Incoming as IncomingBody, Request, Response, StatusCode};
|
||||
|
||||
@ -12,10 +13,12 @@ use garage_model::garage::Garage;
|
||||
use crate::common_error::{CommonError, OkOrBadRequest, OkOrInternalError};
|
||||
use crate::helpers::*;
|
||||
|
||||
// Return both the matching rule and the parsed Origin header so callers that
|
||||
// apply CORS headers don't have to repeat Origin lookup and validation.
|
||||
pub fn find_matching_cors_rule<'a, B>(
|
||||
bucket_params: &'a BucketParams,
|
||||
req: &Request<B>,
|
||||
) -> Result<Option<&'a GarageCorsRule>, CommonError> {
|
||||
req: &'a Request<B>,
|
||||
) -> Result<Option<(&'a GarageCorsRule, &'a str)>, CommonError> {
|
||||
if let Some(cors_config) = bucket_params.cors_config.get() {
|
||||
if let Some(origin) = req.headers().get("Origin") {
|
||||
let origin = origin.to_str()?;
|
||||
@ -23,9 +26,12 @@ pub fn find_matching_cors_rule<'a, B>(
|
||||
Some(h) => h.to_str()?.split(',').map(|h| h.trim()).collect::<Vec<_>>(),
|
||||
None => vec![],
|
||||
};
|
||||
return Ok(cors_config.iter().find(|rule| {
|
||||
cors_rule_matches(rule, origin, req.method().as_ref(), request_headers.iter())
|
||||
}));
|
||||
return Ok(cors_config
|
||||
.iter()
|
||||
.find(|rule| {
|
||||
cors_rule_matches(rule, origin, req.method().as_ref(), request_headers.iter())
|
||||
})
|
||||
.map(|rule| (rule, origin)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
@ -53,12 +59,16 @@ where
|
||||
pub fn add_cors_headers(
|
||||
resp: &mut Response<impl Body>,
|
||||
rule: &GarageCorsRule,
|
||||
request_origin: &str,
|
||||
) -> Result<(), http::header::InvalidHeaderValue> {
|
||||
let h = resp.headers_mut();
|
||||
h.insert(
|
||||
ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
rule.allow_origins.join(", ").parse()?,
|
||||
);
|
||||
let is_wildcard_origin = rule.allow_origins.iter().any(|origin| origin == "*");
|
||||
let allow_origin = if is_wildcard_origin {
|
||||
"*"
|
||||
} else {
|
||||
request_origin
|
||||
};
|
||||
h.insert(ACCESS_CONTROL_ALLOW_ORIGIN, allow_origin.parse()?);
|
||||
h.insert(
|
||||
ACCESS_CONTROL_ALLOW_METHODS,
|
||||
rule.allow_methods.join(", ").parse()?,
|
||||
@ -71,6 +81,12 @@ pub fn add_cors_headers(
|
||||
ACCESS_CONTROL_EXPOSE_HEADERS,
|
||||
rule.expose_headers.join(", ").parse()?,
|
||||
);
|
||||
// When ACAO reflects the request origin instead of returning "*",
|
||||
// caches must vary on the Origin request header to avoid reusing
|
||||
// a response generated for one origin when serving another origin.
|
||||
if !is_wildcard_origin {
|
||||
h.insert(VARY, HeaderValue::from_static("Origin"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -149,7 +165,17 @@ pub fn handle_options_for_bucket<B>(
|
||||
let mut resp = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body(EmptyBody::new())?;
|
||||
add_cors_headers(&mut resp, rule).ok_or_internal_error("Invalid CORS configuration")?;
|
||||
add_cors_headers(&mut resp, rule, origin)
|
||||
.ok_or_internal_error("Invalid CORS configuration")?;
|
||||
// Preflight responses vary not only on Origin but also on the
|
||||
// requested method and requested headers, so caches must not
|
||||
// reuse one preflight decision for a different preflight input.
|
||||
resp.headers_mut().insert(
|
||||
VARY,
|
||||
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
|
||||
.parse()
|
||||
.expect("static vary header"),
|
||||
);
|
||||
return Ok(resp);
|
||||
}
|
||||
}
|
||||
@ -158,3 +184,98 @@ pub fn handle_options_for_bucket<B>(
|
||||
"This CORS request is not allowed.".into(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bucket_params_with_rule(allow_origins: Vec<&str>) -> BucketParams {
|
||||
let mut bucket_params = BucketParams::default();
|
||||
bucket_params.cors_config.update(Some(vec![GarageCorsRule {
|
||||
id: Some("cors-test".into()),
|
||||
max_age_seconds: None,
|
||||
allow_origins: allow_origins.into_iter().map(str::to_string).collect(),
|
||||
allow_methods: vec!["GET".into(), "PUT".into()],
|
||||
allow_headers: vec!["*".into()],
|
||||
expose_headers: vec![],
|
||||
}]));
|
||||
bucket_params
|
||||
}
|
||||
|
||||
fn preflight_request(origin: &str) -> Request<()> {
|
||||
Request::builder()
|
||||
.method("OPTIONS")
|
||||
.uri("http://example.test/bucket")
|
||||
.header("Origin", origin)
|
||||
.header(ACCESS_CONTROL_REQUEST_METHOD, "PUT")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_with_single_allowed_origin_returns_request_origin() {
|
||||
let bucket_params = bucket_params_with_rule(vec!["https://app.example.test"]);
|
||||
let req = preflight_request("https://app.example.test");
|
||||
|
||||
let resp = handle_options_for_bucket(&req, &bucket_params).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
|
||||
"https://app.example.test"
|
||||
);
|
||||
let vary_values: Vec<_> = resp
|
||||
.headers()
|
||||
.get_all(VARY)
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
vary_values,
|
||||
vec!["Origin, Access-Control-Request-Method, Access-Control-Request-Headers",]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_with_multiple_allowed_origins_reflects_request_origin() {
|
||||
let bucket_params = bucket_params_with_rule(vec![
|
||||
"https://app.example.test",
|
||||
"https://admin.example.test",
|
||||
]);
|
||||
let req = preflight_request("https://app.example.test");
|
||||
|
||||
let resp = handle_options_for_bucket(&req, &bucket_params).unwrap();
|
||||
|
||||
// This assertion documents the behavior browsers expect:
|
||||
// even if multiple origins are allowed by configuration, the
|
||||
// response should reflect the request origin rather than emit
|
||||
// a comma-separated list. It currently fails and is meant to
|
||||
// turn green once header generation is corrected.
|
||||
assert_eq!(
|
||||
resp.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
|
||||
"https://app.example.test"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_with_wildcard_allowed_origin_returns_wildcard() {
|
||||
let bucket_params = bucket_params_with_rule(vec!["*"]);
|
||||
let req = preflight_request("https://app.example.test");
|
||||
|
||||
let resp = handle_options_for_bucket(&req, &bucket_params).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
|
||||
"*"
|
||||
);
|
||||
let vary_values: Vec<_> = resp
|
||||
.headers()
|
||||
.get_all(VARY)
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
vary_values,
|
||||
vec!["Origin, Access-Control-Request-Method, Access-Control-Request-Headers",]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,7 +111,7 @@ impl ApiHandler for K2VApiServer {
|
||||
Method::GET | Method::HEAD | Method::POST => {
|
||||
find_matching_cors_rule(&bucket_params, &req)
|
||||
.ok_or_internal_error("Error looking up CORS rule")?
|
||||
.cloned()
|
||||
.map(|(rule, origin)| (rule.clone(), origin.to_string()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
@ -164,8 +164,8 @@ impl ApiHandler for K2VApiServer {
|
||||
// If request was a success and we have a CORS rule that applies to it,
|
||||
// add the corresponding CORS headers to the response
|
||||
let mut resp_ok = resp?;
|
||||
if let Some(rule) = matching_cors_rule {
|
||||
add_cors_headers(&mut resp_ok, &rule)
|
||||
if let Some((rule, origin)) = matching_cors_rule {
|
||||
add_cors_headers(&mut resp_ok, &rule, &origin)
|
||||
.ok_or_internal_error("Invalid bucket CORS configuration")?;
|
||||
}
|
||||
|
||||
|
||||
@ -159,7 +159,8 @@ impl ApiHandler for S3ApiServer {
|
||||
return Err(Error::forbidden("Operation is not allowed for this key."));
|
||||
}
|
||||
|
||||
let matching_cors_rule = find_matching_cors_rule(&bucket_params, &req)?.cloned();
|
||||
let matching_cors = find_matching_cors_rule(&bucket_params, &req)?
|
||||
.map(|(rule, origin)| (rule.clone(), origin.to_string()));
|
||||
|
||||
let ctx = ReqCtx {
|
||||
garage,
|
||||
@ -334,8 +335,8 @@ impl ApiHandler for S3ApiServer {
|
||||
// If request was a success and we have a CORS rule that applies to it,
|
||||
// add the corresponding CORS headers to the response
|
||||
let mut resp_ok = resp?;
|
||||
if let Some(rule) = matching_cors_rule {
|
||||
add_cors_headers(&mut resp_ok, &rule)
|
||||
if let Some((rule, origin)) = matching_cors {
|
||||
add_cors_headers(&mut resp_ok, &rule, &origin)
|
||||
.ok_or_internal_error("Invalid bucket CORS configuration")?;
|
||||
}
|
||||
|
||||
|
||||
@ -121,7 +121,7 @@ pub async fn handle_post_object(
|
||||
&bucket_params,
|
||||
&Request::from_parts(head.clone(), empty_body::<Infallible>()),
|
||||
)?
|
||||
.cloned();
|
||||
.map(|(rule, origin)| (rule.clone(), origin.to_string()));
|
||||
|
||||
let decoded_policy = BASE64_STANDARD
|
||||
.decode(policy)
|
||||
@ -351,8 +351,8 @@ pub async fn handle_post_object(
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(rule) = matching_cors_rule {
|
||||
add_cors_headers(&mut resp, &rule)
|
||||
if let Some((rule, origin)) = matching_cors_rule {
|
||||
add_cors_headers(&mut resp, &rule, &origin)
|
||||
.ok_or_internal_error("Invalid bucket CORS configuration")?;
|
||||
}
|
||||
|
||||
|
||||
121
src/garage/tests/s3/cors.rs
Normal file
121
src/garage/tests/s3/cors.rs
Normal file
@ -0,0 +1,121 @@
|
||||
use aws_sdk_s3::types::{CorsConfiguration, CorsRule};
|
||||
use hyper::{Method, StatusCode};
|
||||
|
||||
use crate::common;
|
||||
|
||||
const REQUEST_ORIGIN: &str = "https://app.example.test";
|
||||
const SECOND_ALLOWED_ORIGIN: &str = "https://admin.example.test";
|
||||
const OBJECT_KEY: &str = "probe.txt";
|
||||
const BODY: &[u8] = b"hello from integration repro\n";
|
||||
|
||||
async fn send_preflight(
|
||||
ctx: &common::Context,
|
||||
bucket: &str,
|
||||
origin: &str,
|
||||
) -> hyper::Response<common::custom_requester::Body> {
|
||||
ctx.custom_request
|
||||
.builder(bucket.to_string())
|
||||
.method(Method::OPTIONS)
|
||||
.path(OBJECT_KEY)
|
||||
.unsigned_header("origin", origin)
|
||||
.unsigned_header("access-control-request-method", "PUT")
|
||||
.unsigned_header(
|
||||
"access-control-request-headers",
|
||||
"content-type,x-amz-meta-demo",
|
||||
)
|
||||
.body(vec![])
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn send_put(
|
||||
ctx: &common::Context,
|
||||
bucket: &str,
|
||||
origin: &str,
|
||||
) -> hyper::Response<common::custom_requester::Body> {
|
||||
ctx.custom_request
|
||||
.builder(bucket.to_string())
|
||||
.method(Method::PUT)
|
||||
.path(OBJECT_KEY)
|
||||
.signed_header("content-type", "text/plain")
|
||||
.signed_header("x-amz-meta-demo", "1")
|
||||
.unsigned_header("origin", origin)
|
||||
.body(BODY.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn apply_bucket_cors(ctx: &common::Context, bucket: &str, allowed_origins: &[&str]) {
|
||||
let rule = allowed_origins.iter().fold(
|
||||
CorsRule::builder()
|
||||
.allowed_headers("*")
|
||||
.allowed_methods("PUT")
|
||||
.expose_headers("ETag"),
|
||||
|rule, origin| rule.allowed_origins(*origin),
|
||||
);
|
||||
|
||||
let cors = CorsConfiguration::builder()
|
||||
.cors_rules(rule.build().unwrap())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
ctx.client
|
||||
.put_bucket_cors()
|
||||
.bucket(bucket)
|
||||
.cors_configuration(cors)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_s3_api_cors_reflects_request_origin() {
|
||||
let ctx = common::context();
|
||||
let bucket = ctx.create_bucket("s3-cors-direct");
|
||||
|
||||
apply_bucket_cors(&ctx, &bucket, &[REQUEST_ORIGIN]).await;
|
||||
|
||||
let control_preflight = send_preflight(&ctx, &bucket, REQUEST_ORIGIN).await;
|
||||
assert_eq!(control_preflight.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
control_preflight
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.unwrap(),
|
||||
REQUEST_ORIGIN
|
||||
);
|
||||
|
||||
let control_put = send_put(&ctx, &bucket, REQUEST_ORIGIN).await;
|
||||
assert_eq!(control_put.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
control_put
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.unwrap(),
|
||||
REQUEST_ORIGIN
|
||||
);
|
||||
|
||||
apply_bucket_cors(&ctx, &bucket, &[REQUEST_ORIGIN, SECOND_ALLOWED_ORIGIN]).await;
|
||||
|
||||
let repro_preflight = send_preflight(&ctx, &bucket, REQUEST_ORIGIN).await;
|
||||
assert_eq!(repro_preflight.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
repro_preflight
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.unwrap(),
|
||||
REQUEST_ORIGIN
|
||||
);
|
||||
|
||||
let repro_put = send_put(&ctx, &bucket, REQUEST_ORIGIN).await;
|
||||
assert_eq!(repro_put.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
repro_put
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.unwrap(),
|
||||
REQUEST_ORIGIN
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
mod cors;
|
||||
mod list;
|
||||
mod multipart;
|
||||
mod objects;
|
||||
|
||||
@ -405,8 +405,8 @@ impl WebServer {
|
||||
}
|
||||
Ok(mut resp) => {
|
||||
// Maybe add CORS headers
|
||||
if let Some(rule) = find_matching_cors_rule(&bucket_params, req)? {
|
||||
add_cors_headers(&mut resp, rule)
|
||||
if let Some((rule, origin)) = find_matching_cors_rule(&bucket_params, req)? {
|
||||
add_cors_headers(&mut resp, rule, origin)
|
||||
.ok_or_internal_error("Invalid bucket CORS configuration")?;
|
||||
}
|
||||
Ok(resp)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user