Skip to content

Commit 1b72a61

Browse files
committed
Allow API cors behavior to be configured
1 parent 1e30008 commit 1b72a61

6 files changed

Lines changed: 86 additions & 13 deletions

File tree

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.

crates/arroyo-api/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ utoipa-swagger-ui = { version = "7", features = ["axum"] }
4141

4242
serde = { workspace = true }
4343
serde_json = { workspace = true }
44+
url = { workspace = true }
4445

4546
# logging
4647
tracing = { workspace = true }
@@ -68,4 +69,4 @@ postgres = "0.19.5"
6869
arroyo-types = { path = "../arroyo-types" }
6970
utoipa = { workspace = true }
7071
rusqlite = {workspace = true}
71-
refinery = { version = "0.8.14", features = ["rusqlite"] }
72+
refinery = { version = "0.8.14", features = ["rusqlite"] }

crates/arroyo-api/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ pub async fn start_server(database: DatabaseSource, guard: ShutdownGuard) -> any
138138
let config = config();
139139
let addr = SocketAddr::new(config.api.bind_address, config.api.http_port);
140140

141-
let mut app = rest::create_rest_app(database).layer(
141+
let mut app = rest::create_rest_app(database)?.layer(
142142
CompressionLayer::new().zstd(true).compress_when(
143143
DefaultPredicate::new()
144144
// compression doesn't work for server-sent events

crates/arroyo-api/src/rest.rs

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ use axum::{
44
routing::{delete, get, patch, post, put},
55
};
66

7-
use http::{HeaderMap, HeaderName, StatusCode, Uri, header};
7+
use anyhow::{Context, bail};
8+
use http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Uri, header};
89
use rust_embed::RustEmbed;
9-
use tower_http::cors;
10-
use tower_http::cors::CorsLayer;
10+
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, Any, CorsLayer};
1111
use utoipa::OpenApi;
1212
use utoipa_swagger_ui::SwaggerUi;
1313

@@ -31,7 +31,7 @@ use crate::pipelines::{
3131
};
3232
use crate::rest_utils::not_found;
3333
use crate::udfs::{create_udf, delete_udf, get_udfs, validate_udf};
34-
use arroyo_rpc::config::config;
34+
use arroyo_rpc::config::{CorsConfig, CorsOriginPolicy, config};
3535
use cornucopia_async::DatabaseSource;
3636

3737
static BASENAME_HEADER: HeaderName = HeaderName::from_static("x-arroyo-basename");
@@ -156,12 +156,54 @@ async fn index_html(headers: HeaderMap) -> Response {
156156
}
157157
}
158158

159-
pub fn create_rest_app(database: DatabaseSource) -> Router {
160-
// TODO: enable in development only!!!
159+
fn cors_layer(config: &CorsConfig) -> anyhow::Result<CorsLayer> {
161160
let cors = CorsLayer::new()
162-
.allow_methods(cors::Any)
163-
.allow_headers(cors::Any)
164-
.allow_origin(cors::Any);
161+
.allow_methods(AllowMethods::mirror_request())
162+
.allow_headers(AllowHeaders::mirror_request());
163+
164+
match config.origin_policy {
165+
CorsOriginPolicy::Any => {
166+
if config.allow_credentials {
167+
bail!("CORS credentials cannot be enabled when allowing any origin");
168+
}
169+
if !config.allowed_origins.is_empty() {
170+
bail!("CORS allowed origins must be empty when allowing any origin");
171+
}
172+
173+
Ok(cors.allow_credentials(false).allow_origin(Any))
174+
}
175+
CorsOriginPolicy::AllowList => {
176+
if config.allowed_origins.iter().any(|origin| origin == "*") {
177+
bail!("CORS allow-list cannot contain the wildcard origin '*'");
178+
}
179+
180+
let origins = config
181+
.allowed_origins
182+
.iter()
183+
.map(|origin| {
184+
let url = url::Url::parse(origin)
185+
.with_context(|| format!("invalid CORS allowed origin {origin:?}"))?;
186+
if url.origin().ascii_serialization() != *origin {
187+
bail!(
188+
"CORS allowed origin {origin:?} must contain only a canonical scheme, host, and optional port"
189+
);
190+
}
191+
192+
origin
193+
.parse::<HeaderValue>()
194+
.with_context(|| format!("invalid CORS allowed origin {origin:?}"))
195+
})
196+
.collect::<anyhow::Result<Vec<_>>>()?;
197+
198+
Ok(cors
199+
.allow_credentials(config.allow_credentials)
200+
.allow_origin(AllowOrigin::list(origins)))
201+
}
202+
}
203+
}
204+
205+
pub fn create_rest_app(database: DatabaseSource) -> anyhow::Result<Router> {
206+
let cors = cors_layer(&config().api.cors)?;
165207

166208
let api_routes = Router::new()
167209
.route("/ping", get(ping))
@@ -189,7 +231,7 @@ pub fn create_rest_app(database: DatabaseSource) -> Router {
189231
.merge(pipeline_and_job_routes())
190232
.fallback(api_fallback);
191233

192-
Router::new()
234+
Ok(Router::new()
193235
.merge(
194236
SwaggerUi::new("/api/v1/swagger-ui")
195237
.url("/api/v1/api-docs/openapi.json", ApiDoc::openapi()),
@@ -201,5 +243,5 @@ pub fn create_rest_app(database: DatabaseSource) -> Router {
201243
)
202244
.fallback(static_handler)
203245
.with_state(AppState { database })
204-
.layer(cors)
246+
.layer(cors))
205247
}

crates/arroyo-rpc/default.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ checkpoints-to-compact = 4
2828
bind-address = "0.0.0.0"
2929
http-port = 5115
3030

31+
[api.cors]
32+
origin-policy = "any"
33+
allowed-origins = []
34+
allow-credentials = false
35+
3136
[controller]
3237
bind-address = "0.0.0.0"
3338
rpc-port = 5116

crates/arroyo-rpc/src/config.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,30 @@ pub struct ApiConfig {
356356

357357
#[serde(default)]
358358
pub auth_mode: ApiAuthMode,
359+
360+
#[serde(default)]
361+
pub cors: CorsConfig,
362+
}
363+
364+
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
365+
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
366+
pub struct CorsConfig {
367+
#[serde(default)]
368+
pub origin_policy: CorsOriginPolicy,
369+
370+
#[serde(default)]
371+
pub allowed_origins: Vec<String>,
372+
373+
#[serde(default)]
374+
pub allow_credentials: bool,
375+
}
376+
377+
#[derive(Debug, Deserialize, Serialize, Copy, Clone, Default, PartialEq, Eq)]
378+
#[serde(rename_all = "kebab-case")]
379+
pub enum CorsOriginPolicy {
380+
#[default]
381+
Any,
382+
AllowList,
359383
}
360384

361385
#[derive(Debug, Deserialize, Serialize, Clone)]

0 commit comments

Comments
 (0)