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:
Minkyu Kim 2026-04-28 14:48:02 +00:00 committed by Alex
parent 80f9335950
commit a2c797000f
7 changed files with 267 additions and 23 deletions

View File

@ -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",]
);
}
}

View File

@ -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")?;
}

View File

@ -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")?;
}

View File

@ -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
View 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
);
}

View File

@ -1,3 +1,4 @@
mod cors;
mod list;
mod multipart;
mod objects;

View File

@ -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)