diff options
Diffstat (limited to 'src/api')
-rw-r--r-- | src/api/api_server.rs | 42 | ||||
-rw-r--r-- | src/api/lib.rs | 1 | ||||
-rw-r--r-- | src/api/s3_cors.rs | 409 | ||||
-rw-r--r-- | src/api/s3_router.rs | 15 | ||||
-rw-r--r-- | src/api/s3_website.rs | 15 | ||||
-rw-r--r-- | src/api/s3_xml.rs | 6 |
6 files changed, 474 insertions, 14 deletions
diff --git a/src/api/api_server.rs b/src/api/api_server.rs index 11daae13..c28e8832 100644 --- a/src/api/api_server.rs +++ b/src/api/api_server.rs @@ -5,7 +5,7 @@ use futures::future::Future; use hyper::header; use hyper::server::conn::AddrStream; use hyper::service::{make_service_fn, service_fn}; -use hyper::{Body, Request, Response, Server}; +use hyper::{Body, Method, Request, Response, Server}; use garage_util::data::*; use garage_util::error::Error as GarageError; @@ -13,12 +13,15 @@ use garage_util::error::Error as GarageError; use garage_model::garage::Garage; use garage_model::key_table::Key; +use garage_table::util::*; + use crate::error::*; use crate::signature::payload::check_payload_signature; use crate::helpers::*; use crate::s3_bucket::*; use crate::s3_copy::*; +use crate::s3_cors::*; use crate::s3_delete::*; use crate::s3_get::*; use crate::s3_list::*; @@ -102,17 +105,17 @@ async fn handler_inner(garage: Arc<Garage>, req: Request<Body>) -> Result<Respon let host = authority_to_host(authority)?; - let bucket = garage + let bucket_name = garage .config .s3_api .root_domain .as_ref() .and_then(|root_domain| host_to_bucket(&host, root_domain)); - let (endpoint, bucket) = Endpoint::from_request(&req, bucket.map(ToOwned::to_owned))?; + let (endpoint, bucket_name) = Endpoint::from_request(&req, bucket_name.map(ToOwned::to_owned))?; debug!("Endpoint: {:?}", endpoint); - let bucket_name = match bucket { + let bucket_name = match bucket_name { None => return handle_request_without_bucket(garage, req, api_key, endpoint).await, Some(bucket) => bucket.to_string(), }; @@ -123,6 +126,12 @@ async fn handler_inner(garage: Arc<Garage>, req: Request<Body>) -> Result<Respon } let bucket_id = resolve_bucket(&garage, &bucket_name, &api_key).await?; + let bucket = garage + .bucket_table + .get(&EmptyKey, &bucket_id) + .await? + .filter(|b| !b.state.is_deleted()) + .ok_or(Error::NoSuchBucket)?; let allowed = match endpoint.authorization_type() { Authorization::Read => api_key.allow_read(&bucket_id), @@ -137,7 +146,17 @@ async fn handler_inner(garage: Arc<Garage>, req: Request<Body>) -> Result<Respon )); } - match endpoint { + // Look up what CORS rule might apply to response. + // Requests for methods different than GET, HEAD or POST + // are always preflighted, i.e. the browser should make + // an OPTIONS call before to check it is allowed + let matching_cors_rule = match *req.method() { + Method::GET | Method::HEAD | Method::POST => find_matching_cors_rule(&bucket, &req)?, + _ => None, + }; + + let resp = match endpoint { + Endpoint::Options => handle_options(garage, &req, bucket_id).await, Endpoint::HeadObject { key, .. } => handle_head(garage, &req, bucket_id, &key).await, Endpoint::GetObject { key, .. } => handle_get(garage, &req, bucket_id, &key).await, Endpoint::UploadPart { @@ -320,8 +339,21 @@ async fn handler_inner(garage: Arc<Garage>, req: Request<Body>) -> Result<Respon handle_put_website(garage, bucket_id, req, content_sha256).await } Endpoint::DeleteBucketWebsite {} => handle_delete_website(garage, bucket_id).await, + Endpoint::GetBucketCors {} => handle_get_cors(garage, bucket_id).await, + Endpoint::PutBucketCors {} => handle_put_cors(garage, bucket_id, req, content_sha256).await, + Endpoint::DeleteBucketCors {} => handle_delete_cors(garage, bucket_id).await, endpoint => Err(Error::NotImplemented(endpoint.name().to_owned())), + }; + + // 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) + .ok_or_internal_error("Invalid bucket CORS configuration")?; } + + Ok(resp_ok) } async fn handle_request_without_bucket( diff --git a/src/api/lib.rs b/src/api/lib.rs index 725cd9d1..bb5a8265 100644 --- a/src/api/lib.rs +++ b/src/api/lib.rs @@ -15,6 +15,7 @@ mod signature; pub mod helpers; mod s3_bucket; mod s3_copy; +pub mod s3_cors; mod s3_delete; pub mod s3_get; mod s3_list; diff --git a/src/api/s3_cors.rs b/src/api/s3_cors.rs new file mode 100644 index 00000000..d23bf48d --- /dev/null +++ b/src/api/s3_cors.rs @@ -0,0 +1,409 @@ +use quick_xml::de::from_reader; +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, +}; +use hyper::{header::HeaderName, Body, Method, Request, Response, StatusCode}; + +use serde::{Deserialize, Serialize}; + +use crate::error::*; +use crate::s3_xml::{to_xml_with_header, xmlns_tag, IntValue, Value}; +use crate::signature::verify_signed_content; + +use garage_model::bucket_table::{Bucket, CorsRule as GarageCorsRule}; +use garage_model::garage::Garage; +use garage_table::*; +use garage_util::data::*; + +pub async fn handle_get_cors( + garage: Arc<Garage>, + bucket_id: Uuid, +) -> Result<Response<Body>, Error> { + let bucket = garage + .bucket_table + .get(&EmptyKey, &bucket_id) + .await? + .ok_or(Error::NoSuchBucket)?; + + let param = bucket + .params() + .ok_or_internal_error("Bucket should not be deleted at this point")?; + + if let Some(cors) = param.cors_config.get() { + let wc = CorsConfiguration { + xmlns: (), + cors_rules: cors + .iter() + .map(CorsRule::from_garage_cors_rule) + .collect::<Vec<_>>(), + }; + let xml = to_xml_with_header(&wc)?; + Ok(Response::builder() + .status(StatusCode::OK) + .header(http::header::CONTENT_TYPE, "application/xml") + .body(Body::from(xml))?) + } else { + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty())?) + } +} + +pub async fn handle_delete_cors( + garage: Arc<Garage>, + bucket_id: Uuid, +) -> Result<Response<Body>, Error> { + let mut bucket = garage + .bucket_table + .get(&EmptyKey, &bucket_id) + .await? + .ok_or(Error::NoSuchBucket)?; + + let param = bucket + .params_mut() + .ok_or_internal_error("Bucket should not be deleted at this point")?; + + param.cors_config.update(None); + garage.bucket_table.insert(&bucket).await?; + + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty())?) +} + +pub async fn handle_put_cors( + garage: Arc<Garage>, + bucket_id: Uuid, + req: Request<Body>, + content_sha256: Option<Hash>, +) -> Result<Response<Body>, Error> { + let body = hyper::body::to_bytes(req.into_body()).await?; + + if let Some(content_sha256) = content_sha256 { + verify_signed_content(content_sha256, &body[..])?; + } + + let mut bucket = garage + .bucket_table + .get(&EmptyKey, &bucket_id) + .await? + .ok_or(Error::NoSuchBucket)?; + + let param = bucket + .params_mut() + .ok_or_internal_error("Bucket should not be deleted at this point")?; + + let conf: CorsConfiguration = from_reader(&body as &[u8])?; + conf.validate()?; + + param + .cors_config + .update(Some(conf.into_garage_cors_config()?)); + garage.bucket_table.insert(&bucket).await?; + + Ok(Response::builder() + .status(StatusCode::OK) + .body(Body::empty())?) +} + +pub async fn handle_options( + garage: Arc<Garage>, + req: &Request<Body>, + bucket_id: Uuid, +) -> Result<Response<Body>, Error> { + let bucket = garage + .bucket_table + .get(&EmptyKey, &bucket_id) + .await? + .ok_or(Error::NoSuchBucket)?; + let origin = req + .headers() + .get("Origin") + .ok_or_bad_request("Missing Origin header")? + .to_str()?; + let request_method = req + .headers() + .get(ACCESS_CONTROL_REQUEST_METHOD) + .ok_or_bad_request("Missing Access-Control-Request-Method header")? + .to_str()?; + let request_headers = match req.headers().get(ACCESS_CONTROL_REQUEST_HEADERS) { + Some(h) => h.to_str()?.split(',').map(|h| h.trim()).collect::<Vec<_>>(), + None => vec![], + }; + + if let Some(cors_config) = bucket.params().unwrap().cors_config.get() { + let matching_rule = cors_config + .iter() + .find(|rule| cors_rule_matches(rule, origin, request_method, request_headers.iter())); + if let Some(rule) = matching_rule { + let mut resp = Response::builder() + .status(StatusCode::OK) + .body(Body::empty())?; + add_cors_headers(&mut resp, rule).ok_or_internal_error("Invalid CORS configuration")?; + return Ok(resp); + } + } + + Err(Error::Forbidden("This CORS request is not allowed.".into())) +} + +pub fn find_matching_cors_rule<'a>( + bucket: &'a Bucket, + req: &Request<Body>, +) -> Result<Option<&'a GarageCorsRule>, Error> { + if let Some(cors_config) = bucket.params().unwrap().cors_config.get() { + if let Some(origin) = req.headers().get("Origin") { + let origin = origin.to_str()?; + let request_headers = match req.headers().get(ACCESS_CONTROL_REQUEST_HEADERS) { + 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().to_string(), + request_headers.iter(), + ) + })); + } + } + Ok(None) +} + +fn cors_rule_matches<'a, HI, S>( + rule: &GarageCorsRule, + origin: &'a str, + method: &'a str, + mut request_headers: HI, +) -> bool +where + HI: Iterator<Item = S>, + S: AsRef<str>, +{ + rule.allow_origins.iter().any(|x| x == "*" || x == origin) + && rule.allow_methods.iter().any(|x| x == "*" || x == method) + && request_headers.all(|h| { + rule.allow_headers + .iter() + .any(|x| x == "*" || x == h.as_ref()) + }) +} + +pub fn add_cors_headers( + resp: &mut Response<Body>, + rule: &GarageCorsRule, +) -> Result<(), http::header::InvalidHeaderValue> { + let h = resp.headers_mut(); + h.insert( + ACCESS_CONTROL_ALLOW_ORIGIN, + rule.allow_origins.join(", ").parse()?, + ); + h.insert( + ACCESS_CONTROL_ALLOW_METHODS, + rule.allow_methods.join(", ").parse()?, + ); + h.insert( + ACCESS_CONTROL_ALLOW_HEADERS, + rule.allow_headers.join(", ").parse()?, + ); + h.insert( + ACCESS_CONTROL_EXPOSE_HEADERS, + rule.expose_headers.join(", ").parse()?, + ); + Ok(()) +} + +// ---- SERIALIZATION AND DESERIALIZATION TO/FROM S3 XML ---- + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename = "CORSConfiguration")] +pub struct CorsConfiguration { + #[serde(serialize_with = "xmlns_tag", skip_deserializing)] + pub xmlns: (), + #[serde(rename = "CORSRule")] + pub cors_rules: Vec<CorsRule>, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct CorsRule { + #[serde(rename = "ID")] + pub id: Option<Value>, + #[serde(rename = "MaxAgeSeconds")] + pub max_age_seconds: Option<IntValue>, + #[serde(rename = "AllowedOrigin")] + pub allowed_origins: Vec<Value>, + #[serde(rename = "AllowedMethod")] + pub allowed_methods: Vec<Value>, + #[serde(rename = "AllowedHeader", default)] + pub allowed_headers: Vec<Value>, + #[serde(rename = "ExposeHeader", default)] + pub expose_headers: Vec<Value>, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct AllowedMethod { + #[serde(rename = "AllowedMethod")] + pub allowed_method: Value, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct AllowedHeader { + #[serde(rename = "AllowedHeader")] + pub allowed_header: Value, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct ExposeHeader { + #[serde(rename = "ExposeHeader")] + pub expose_header: Value, +} + +impl CorsConfiguration { + pub fn validate(&self) -> Result<(), Error> { + for r in self.cors_rules.iter() { + r.validate()?; + } + Ok(()) + } + + pub fn into_garage_cors_config(self) -> Result<Vec<GarageCorsRule>, Error> { + Ok(self + .cors_rules + .iter() + .map(CorsRule::to_garage_cors_rule) + .collect()) + } +} + +impl CorsRule { + pub fn validate(&self) -> Result<(), Error> { + for method in self.allowed_methods.iter() { + method + .0 + .parse::<Method>() + .ok_or_bad_request("Invalid CORSRule method")?; + } + for header in self + .allowed_headers + .iter() + .chain(self.expose_headers.iter()) + { + header + .0 + .parse::<HeaderName>() + .ok_or_bad_request("Invalid HTTP header name")?; + } + Ok(()) + } + + pub fn to_garage_cors_rule(&self) -> GarageCorsRule { + let convert_vec = + |vval: &[Value]| vval.iter().map(|x| x.0.to_owned()).collect::<Vec<String>>(); + GarageCorsRule { + id: self.id.as_ref().map(|x| x.0.to_owned()), + max_age_seconds: self.max_age_seconds.as_ref().map(|x| x.0 as u64), + allow_origins: convert_vec(&self.allowed_origins), + allow_methods: convert_vec(&self.allowed_methods), + allow_headers: convert_vec(&self.allowed_headers), + expose_headers: convert_vec(&self.expose_headers), + } + } + + pub fn from_garage_cors_rule(rule: &GarageCorsRule) -> Self { + let convert_vec = |vval: &[String]| { + vval.iter() + .map(|x| Value(x.clone())) + .collect::<Vec<Value>>() + }; + Self { + id: rule.id.as_ref().map(|x| Value(x.clone())), + max_age_seconds: rule.max_age_seconds.map(|x| IntValue(x as i64)), + allowed_origins: convert_vec(&rule.allow_origins), + allowed_methods: convert_vec(&rule.allow_methods), + allowed_headers: convert_vec(&rule.allow_headers), + expose_headers: convert_vec(&rule.expose_headers), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use quick_xml::de::from_str; + + #[test] + fn test_deserialize() -> Result<(), Error> { + let message = r#"<?xml version="1.0" encoding="UTF-8"?> +<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> + <CORSRule> + <AllowedOrigin>http://www.example.com</AllowedOrigin> + + <AllowedMethod>PUT</AllowedMethod> + <AllowedMethod>POST</AllowedMethod> + <AllowedMethod>DELETE</AllowedMethod> + + <AllowedHeader>*</AllowedHeader> + </CORSRule> + <CORSRule> + <AllowedOrigin>*</AllowedOrigin> + <AllowedMethod>GET</AllowedMethod> + </CORSRule> + <CORSRule> + <ID>qsdfjklm</ID> + <MaxAgeSeconds>12345</MaxAgeSeconds> + <AllowedOrigin>https://perdu.com</AllowedOrigin> + + <AllowedMethod>GET</AllowedMethod> + <AllowedMethod>DELETE</AllowedMethod> + <AllowedHeader>*</AllowedHeader> + <ExposeHeader>*</ExposeHeader> + </CORSRule> +</CORSConfiguration>"#; + let conf: CorsConfiguration = from_str(message).unwrap(); + let ref_value = CorsConfiguration { + xmlns: (), + cors_rules: vec![ + CorsRule { + id: None, + max_age_seconds: None, + allowed_origins: vec!["http://www.example.com".into()], + allowed_methods: vec!["PUT".into(), "POST".into(), "DELETE".into()], + allowed_headers: vec!["*".into()], + expose_headers: vec![], + }, + CorsRule { + id: None, + max_age_seconds: None, + allowed_origins: vec!["*".into()], + allowed_methods: vec!["GET".into()], + allowed_headers: vec![], + expose_headers: vec![], + }, + CorsRule { + id: Some("qsdfjklm".into()), + max_age_seconds: Some(IntValue(12345)), + allowed_origins: vec!["https://perdu.com".into()], + allowed_methods: vec!["GET".into(), "DELETE".into()], + allowed_headers: vec!["*".into()], + expose_headers: vec!["*".into()], + }, + ], + }; + assert_eq! { + ref_value, + conf + }; + + let message2 = to_xml_with_header(&ref_value)?; + + let cleanup = |c: &str| c.replace(char::is_whitespace, ""); + assert_eq!(cleanup(message), cleanup(&message2)); + + Ok(()) + } +} diff --git a/src/api/s3_router.rs b/src/api/s3_router.rs index 9add5e4a..51020a81 100644 --- a/src/api/s3_router.rs +++ b/src/api/s3_router.rs @@ -327,6 +327,7 @@ pub enum Endpoint { part_number_marker: Option<u64>, upload_id: String, }, + Options, PutBucketAccelerateConfiguration { }, PutBucketAcl { @@ -434,6 +435,10 @@ impl Endpoint { .unwrap_or((path.to_owned(), "")) }; + if *req.method() == Method::OPTIONS { + return Ok((Self::Options, Some(bucket))); + } + let key = percent_encoding::percent_decode_str(key) .decode_utf8()? .into_owned(); @@ -665,7 +670,6 @@ impl Endpoint { GetBucketAccelerateConfiguration, GetBucketAcl, GetBucketAnalyticsConfiguration, - GetBucketCors, GetBucketEncryption, GetBucketIntelligentTieringConfiguration, GetBucketInventoryConfiguration, @@ -711,6 +715,9 @@ impl Endpoint { GetBucketWebsite, PutBucketWebsite, DeleteBucketWebsite, + GetBucketCors, + PutBucketCors, + DeleteBucketCors, ] }; if readonly { @@ -1027,7 +1034,7 @@ mod tests { OWNER_DELETE "/" => DeleteBucket DELETE "/?analytics&id=list1" => DeleteBucketAnalyticsConfiguration DELETE "/?analytics&id=Id" => DeleteBucketAnalyticsConfiguration - DELETE "/?cors" => DeleteBucketCors + OWNER_DELETE "/?cors" => DeleteBucketCors DELETE "/?encryption" => DeleteBucketEncryption DELETE "/?intelligent-tiering&id=Id" => DeleteBucketIntelligentTieringConfiguration DELETE "/?inventory&id=list1" => DeleteBucketInventoryConfiguration @@ -1050,7 +1057,7 @@ mod tests { GET "/?accelerate" => GetBucketAccelerateConfiguration GET "/?acl" => GetBucketAcl GET "/?analytics&id=Id" => GetBucketAnalyticsConfiguration - GET "/?cors" => GetBucketCors + OWNER_GET "/?cors" => GetBucketCors GET "/?encryption" => GetBucketEncryption GET "/?intelligent-tiering&id=Id" => GetBucketIntelligentTieringConfiguration GET "/?inventory&id=list1" => GetBucketInventoryConfiguration @@ -1126,7 +1133,7 @@ mod tests { PUT "/?acl" => PutBucketAcl PUT "/?analytics&id=report1" => PutBucketAnalyticsConfiguration PUT "/?analytics&id=Id" => PutBucketAnalyticsConfiguration - PUT "/?cors" => PutBucketCors + OWNER_PUT "/?cors" => PutBucketCors PUT "/?encryption" => PutBucketEncryption PUT "/?intelligent-tiering&id=Id" => PutBucketIntelligentTieringConfiguration PUT "/?inventory&id=report1" => PutBucketInventoryConfiguration diff --git a/src/api/s3_website.rs b/src/api/s3_website.rs index c4a43e2c..d5864fc8 100644 --- a/src/api/s3_website.rs +++ b/src/api/s3_website.rs @@ -5,7 +5,7 @@ use hyper::{Body, Request, Response, StatusCode}; use serde::{Deserialize, Serialize}; use crate::error::*; -use crate::s3_xml::{xmlns_tag, IntValue, Value}; +use crate::s3_xml::{to_xml_with_header, xmlns_tag, IntValue, Value}; use crate::signature::verify_signed_content; use garage_model::bucket_table::*; @@ -39,7 +39,7 @@ pub async fn handle_get_website( redirect_all_requests_to: None, routing_rules: None, }; - let xml = quick_xml::se::to_string(&wc)?; + let xml = to_xml_with_header(&wc)?; Ok(Response::builder() .status(StatusCode::OK) .header(http::header::CONTENT_TYPE, "application/xml") @@ -306,7 +306,7 @@ mod tests { use quick_xml::de::from_str; #[test] - fn test_deserialize() { + fn test_deserialize() -> Result<(), Error> { let message = r#"<?xml version="1.0" encoding="UTF-8"?> <WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> <ErrorDocument> @@ -368,7 +368,12 @@ mod tests { ref_value, conf } - // TODO verify result is ok - // TODO cycle back and verify if ok + + let message2 = to_xml_with_header(&ref_value)?; + + let cleanup = |c: &str| c.replace(char::is_whitespace, ""); + assert_eq!(cleanup(message), cleanup(&message2)); + + Ok(()) } } diff --git a/src/api/s3_xml.rs b/src/api/s3_xml.rs index 7bbfa083..8a0dcee0 100644 --- a/src/api/s3_xml.rs +++ b/src/api/s3_xml.rs @@ -16,6 +16,12 @@ pub fn xmlns_tag<S: Serializer>(_v: &(), s: S) -> Result<S::Ok, S::Error> { #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct Value(#[serde(rename = "$value")] pub String); +impl From<&str> for Value { + fn from(s: &str) -> Value { + Value(s.to_string()) + } +} + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct IntValue(#[serde(rename = "$value")] pub i64); |