Skip to content

Commit 4e8ef5a

Browse files
committed
feat: CORS for the REST and S3 interfaces
1 parent 6bc23f0 commit 4e8ef5a

16 files changed

Lines changed: 472 additions & 7 deletions

File tree

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,14 @@ METADATA_REPLICATION_FACTOR=3
1616
S3_HOST=127.0.0.1:1337
1717
S3_ADDRESS=0.0.0.0:1337
1818

19+
# Comma-separated list of origins allowed to call the REST and S3 interfaces
20+
# from a browser (e.g. http://localhost:5173 for the portal dev server).
21+
# Empty = deny all cross-origin requests. A literal * allows every origin.
22+
CORS_ALLOWED_ORIGINS=
23+
24+
# Maximum REST request body size in bytes (default 1 MiB). Oversized bodies
25+
# are rejected with 413.
26+
MAX_HTTP_BODY_SIZE=
27+
1928
RUST_LOG=
2029
OTEL_EXPORTER_OTLP_ENDPOINT=

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ tempfile = "3.27.0"
122122
thiserror = "2.0.18"
123123
tokio = { version = "1.52.3", features = ["full", "tracing"] }
124124
tokio-util = "0.7.18"
125+
tower-http = { version = "0.6.7", features = ["cors"] }
125126
tracing = "0.1.44"
126127
tracing-opentelemetry = { version = "0.33.0", default-features = false }
127128
tracing-subscriber = { version = "0.3.23", features = [

api/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ async-trait = { workspace = true }
4646
futures-core = { workspace = true }
4747
futures-util = { workspace = true }
4848
http = { workspace = true }
49+
tower-http = { workspace = true }
4950
hyper = { workspace = true }
5051
hyper-util = { workspace = true }
5152
s3s = { workspace = true }

api/src/cors.rs

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
use axum::http::Method;
2+
use http::HeaderMap;
3+
use http::HeaderValue;
4+
use http::header;
5+
use std::time::Duration;
6+
use tower_http::cors::{AllowOrigin, CorsLayer};
7+
8+
const CORS_MAX_AGE: Duration = Duration::from_secs(3600);
9+
const S3_ALLOWED_METHODS: &str = "GET,HEAD,PUT,POST,DELETE,OPTIONS";
10+
const S3_DEFAULT_ALLOWED_HEADERS: &str = "authorization,content-type,content-md5,range,\
11+
x-amz-content-sha256,x-amz-date,x-amz-security-token,x-amz-user-agent";
12+
const S3_EXPOSED_HEADERS: &str = "etag,content-range,accept-ranges,content-length,last-modified,\
13+
x-amz-request-id,x-amz-version-id,x-amz-delete-marker,aruna-source-content-type,\
14+
aruna-source-etag,aruna-source-last-modified,aruna-last-refresh";
15+
16+
/// Allowed cross-origin request origins, shared by the REST and S3 interfaces.
17+
/// An empty configuration denies all cross-origin access (no CORS headers are
18+
/// emitted); a literal `*` entry allows every origin.
19+
#[derive(Clone, Debug, Default)]
20+
pub struct CorsConfig {
21+
allowed_origins: Vec<String>,
22+
allow_any: bool,
23+
}
24+
25+
impl CorsConfig {
26+
pub fn new(origins: impl IntoIterator<Item = String>) -> Self {
27+
let mut allowed_origins = Vec::new();
28+
let mut allow_any = false;
29+
for origin in origins {
30+
let origin = origin.trim().trim_end_matches('/');
31+
if origin.is_empty() {
32+
continue;
33+
}
34+
if origin == "*" {
35+
allow_any = true;
36+
} else {
37+
allowed_origins.push(origin.to_string());
38+
}
39+
}
40+
Self {
41+
allowed_origins,
42+
allow_any,
43+
}
44+
}
45+
46+
pub fn is_enabled(&self) -> bool {
47+
self.allow_any || !self.allowed_origins.is_empty()
48+
}
49+
50+
pub fn allows(&self, origin: &HeaderValue) -> bool {
51+
if self.allow_any {
52+
return true;
53+
}
54+
origin
55+
.to_str()
56+
.map(|origin| {
57+
self.allowed_origins
58+
.iter()
59+
.any(|allowed| allowed == origin.trim_end_matches('/'))
60+
})
61+
.unwrap_or(false)
62+
}
63+
64+
pub fn rest_layer(&self) -> Option<CorsLayer> {
65+
if !self.is_enabled() {
66+
return None;
67+
}
68+
let allow_origin = if self.allow_any {
69+
AllowOrigin::any()
70+
} else {
71+
AllowOrigin::list(
72+
self.allowed_origins
73+
.iter()
74+
.filter_map(|origin| origin.parse().ok()),
75+
)
76+
};
77+
Some(
78+
CorsLayer::new()
79+
.allow_origin(allow_origin)
80+
.allow_methods([
81+
Method::GET,
82+
Method::POST,
83+
Method::PUT,
84+
Method::PATCH,
85+
Method::DELETE,
86+
Method::HEAD,
87+
Method::OPTIONS,
88+
])
89+
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
90+
.max_age(CORS_MAX_AGE),
91+
)
92+
}
93+
94+
fn allow_origin_value(&self, origin: &HeaderValue) -> HeaderValue {
95+
if self.allow_any {
96+
HeaderValue::from_static("*")
97+
} else {
98+
origin.clone()
99+
}
100+
}
101+
102+
/// Headers for an S3 preflight response. Returns `None` when the origin is
103+
/// not allowed; the preflight is then answered without CORS headers.
104+
pub fn s3_preflight_headers(
105+
&self,
106+
origin: &HeaderValue,
107+
requested_headers: Option<&HeaderValue>,
108+
) -> Option<HeaderMap> {
109+
if !self.allows(origin) {
110+
return None;
111+
}
112+
let mut headers = HeaderMap::new();
113+
headers.insert(
114+
header::ACCESS_CONTROL_ALLOW_ORIGIN,
115+
self.allow_origin_value(origin),
116+
);
117+
headers.insert(
118+
header::ACCESS_CONTROL_ALLOW_METHODS,
119+
HeaderValue::from_static(S3_ALLOWED_METHODS),
120+
);
121+
headers.insert(
122+
header::ACCESS_CONTROL_ALLOW_HEADERS,
123+
requested_headers
124+
.cloned()
125+
.unwrap_or_else(|| HeaderValue::from_static(S3_DEFAULT_ALLOWED_HEADERS)),
126+
);
127+
headers.insert(
128+
header::ACCESS_CONTROL_MAX_AGE,
129+
HeaderValue::from(CORS_MAX_AGE.as_secs()),
130+
);
131+
headers.insert(header::VARY, HeaderValue::from_static("origin"));
132+
Some(headers)
133+
}
134+
135+
/// Adds CORS headers to a normal (non-preflight) S3 response when the
136+
/// request origin is allowed.
137+
pub fn apply_s3_response_headers(&self, origin: Option<&HeaderValue>, headers: &mut HeaderMap) {
138+
let Some(origin) = origin else {
139+
return;
140+
};
141+
if !self.allows(origin) {
142+
return;
143+
}
144+
headers.insert(
145+
header::ACCESS_CONTROL_ALLOW_ORIGIN,
146+
self.allow_origin_value(origin),
147+
);
148+
headers.insert(
149+
header::ACCESS_CONTROL_EXPOSE_HEADERS,
150+
HeaderValue::from_static(S3_EXPOSED_HEADERS),
151+
);
152+
headers.append(header::VARY, HeaderValue::from_static("origin"));
153+
}
154+
}
155+
156+
#[cfg(test)]
157+
mod tests {
158+
use super::*;
159+
160+
#[test]
161+
fn empty_config_denies_everything() {
162+
let config = CorsConfig::default();
163+
assert!(!config.is_enabled());
164+
assert!(!config.allows(&HeaderValue::from_static("http://portal.test")));
165+
assert!(config.rest_layer().is_none());
166+
assert!(
167+
config
168+
.s3_preflight_headers(&HeaderValue::from_static("http://portal.test"), None)
169+
.is_none()
170+
);
171+
let mut headers = HeaderMap::new();
172+
config.apply_s3_response_headers(
173+
Some(&HeaderValue::from_static("http://portal.test")),
174+
&mut headers,
175+
);
176+
assert!(headers.is_empty());
177+
}
178+
179+
#[test]
180+
fn listed_origin_is_allowed_and_reflected() {
181+
let config = CorsConfig::new(vec!["http://portal.test".to_string()]);
182+
let origin = HeaderValue::from_static("http://portal.test");
183+
assert!(config.allows(&origin));
184+
assert!(!config.allows(&HeaderValue::from_static("http://evil.test")));
185+
186+
let headers = config.s3_preflight_headers(&origin, None).unwrap();
187+
assert_eq!(
188+
headers.get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
189+
&origin
190+
);
191+
192+
let mut response_headers = HeaderMap::new();
193+
config.apply_s3_response_headers(Some(&origin), &mut response_headers);
194+
assert_eq!(
195+
response_headers
196+
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
197+
.unwrap(),
198+
&origin
199+
);
200+
assert!(
201+
response_headers
202+
.get(header::ACCESS_CONTROL_EXPOSE_HEADERS)
203+
.unwrap()
204+
.to_str()
205+
.unwrap()
206+
.contains("etag")
207+
);
208+
}
209+
210+
#[test]
211+
fn wildcard_allows_any_origin() {
212+
let config = CorsConfig::new(vec!["*".to_string()]);
213+
let origin = HeaderValue::from_static("http://anywhere.test");
214+
assert!(config.allows(&origin));
215+
let headers = config.s3_preflight_headers(&origin, None).unwrap();
216+
assert_eq!(
217+
headers.get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
218+
"*"
219+
);
220+
}
221+
222+
#[test]
223+
fn preflight_echoes_requested_headers() {
224+
let config = CorsConfig::new(vec!["http://portal.test".to_string()]);
225+
let requested = HeaderValue::from_static("authorization,x-amz-meta-custom");
226+
let headers = config
227+
.s3_preflight_headers(
228+
&HeaderValue::from_static("http://portal.test"),
229+
Some(&requested),
230+
)
231+
.unwrap();
232+
assert_eq!(
233+
headers.get(header::ACCESS_CONTROL_ALLOW_HEADERS).unwrap(),
234+
&requested
235+
);
236+
}
237+
}

api/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#![allow(clippy::result_large_err)]
22

33
pub mod auth;
4+
pub mod cors;
45
pub mod error;
56
pub mod openapi;
67
pub mod routes;

api/src/routes/users.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,7 @@ mod tests {
11621162
ServerConfig {
11631163
http_addr: addr,
11641164
max_http_body_size: crate::server::DEFAULT_MAX_HTTP_BODY_SIZE,
1165+
cors: crate::cors::CorsConfig::default(),
11651166
},
11661167
)
11671168
.build_router();

api/src/s3/s3_server.rs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
use super::auth::AuthProvider;
22
use super::s3_service::ArunaS3Service;
3+
use crate::cors::CorsConfig;
34
use crate::error::S3ServerError;
45
use crate::telemetry::{emit_request_completed, make_request_span};
56
use aruna_core::NodeId;
67
use aruna_core::structs::RealmId;
78
use aruna_operations::driver::DriverContext;
89
use futures_core::future::BoxFuture;
9-
use http::Request;
10+
use http::{Method, Request, StatusCode, header};
1011
use hyper::body::Incoming;
1112
use hyper::service::Service;
1213
use hyper_util::rt::TokioExecutor;
@@ -28,11 +29,13 @@ use tracing::{Instrument, error, info, trace};
2829
pub struct S3Server {
2930
address: String,
3031
s3service: S3Service,
32+
cors: CorsConfig,
3133
}
3234

3335
#[derive(Clone)]
3436
pub struct WrappingService {
3537
shared: S3Service, // Aruna specific implementation of S3 trait
38+
cors: CorsConfig,
3639
}
3740

3841
impl S3Server {
@@ -43,6 +46,7 @@ impl S3Server {
4346
driver_ctx: Arc<DriverContext>,
4447
realm_id: RealmId,
4548
node_id: NodeId,
49+
cors: CorsConfig,
4650
) -> Result<Self, S3ServerError> {
4751
let s3service = ArunaS3Service::new(driver_ctx.clone(), realm_id, node_id).await;
4852

@@ -64,6 +68,7 @@ impl S3Server {
6468
Ok(Self {
6569
address: address.into(),
6670
s3service: service,
71+
cors,
6772
})
6873
}
6974

@@ -74,6 +79,7 @@ impl S3Server {
7479
let local_addr = listener.local_addr()?;
7580
let service = WrappingService {
7681
shared: self.s3service,
82+
cors: self.cors,
7783
};
7884
let connection = ConnBuilder::new(TokioExecutor::new());
7985

@@ -116,7 +122,29 @@ impl Service<Request<Incoming>> for WrappingService {
116122
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
117123

118124
fn call(&self, req: Request<Incoming>) -> Self::Future {
119-
//TODO: CORS
125+
let origin = req.headers().get(header::ORIGIN).cloned();
126+
127+
// Answer CORS preflight before s3s signature validation: an unsigned
128+
// OPTIONS request must not fail with 403.
129+
if req.method() == Method::OPTIONS
130+
&& let Some(origin) = origin.as_ref()
131+
{
132+
let requested_headers = req
133+
.headers()
134+
.get(header::ACCESS_CONTROL_REQUEST_HEADERS)
135+
.cloned();
136+
let mut response = http::Response::builder()
137+
.status(StatusCode::NO_CONTENT)
138+
.body(s3s::Body::empty())
139+
.expect("static response must build");
140+
if let Some(cors_headers) = self
141+
.cors
142+
.s3_preflight_headers(origin, requested_headers.as_ref())
143+
{
144+
response.headers_mut().extend(cors_headers);
145+
}
146+
return Box::pin(async move { Ok(response) });
147+
}
120148

121149
// Default S3 operation call
122150
let (parts, body) = req.into_parts();
@@ -136,8 +164,12 @@ impl Service<Request<Incoming>> for WrappingService {
136164
}
137165
let s3s_request = s3s::HttpRequest::from_parts(parts, body.into());
138166
let shared = self.shared.clone();
167+
let cors = self.cors.clone();
139168
Box::pin(async move {
140-
let result = shared.call(s3s_request).instrument(span.clone()).await;
169+
let mut result = shared.call(s3s_request).instrument(span.clone()).await;
170+
if let Ok(response) = &mut result {
171+
cors.apply_s3_response_headers(origin.as_ref(), response.headers_mut());
172+
}
141173
match &result {
142174
Ok(response) => {
143175
emit_request_completed(&span, "s3", response.status().as_u16(), started)

0 commit comments

Comments
 (0)