diff --git a/config/config.md b/config/config.md
index 0639e06a21..42cbe6c116 100644
--- a/config/config.md
+++ b/config/config.md
@@ -26,6 +26,8 @@
| `http.addr` | String | `127.0.0.1:4000` | The address to bind the HTTP server. |
| `http.timeout` | String | `30s` | HTTP request timeout. Set to 0 to disable timeout. |
| `http.body_limit` | String | `64MB` | HTTP request body limit.
The following units are supported: `B`, `KB`, `KiB`, `MB`, `MiB`, `GB`, `GiB`, `TB`, `TiB`, `PB`, `PiB`.
Set to 0 to disable limit. |
+| `http.enable_cors` | Bool | `true` | HTTP CORS support, it's turned on by default
This allows browser to access http APIs without CORS restrictions |
+| `http.cors_allowed_origins` | Array | Unset | Customize allowed origins for HTTP CORS. |
| `grpc` | -- | -- | The gRPC server options. |
| `grpc.addr` | String | `127.0.0.1:4001` | The address to bind the gRPC server. |
| `grpc.runtime_size` | Integer | `8` | The number of server worker threads. |
@@ -216,6 +218,8 @@
| `http.addr` | String | `127.0.0.1:4000` | The address to bind the HTTP server. |
| `http.timeout` | String | `30s` | HTTP request timeout. Set to 0 to disable timeout. |
| `http.body_limit` | String | `64MB` | HTTP request body limit.
The following units are supported: `B`, `KB`, `KiB`, `MB`, `MiB`, `GB`, `GiB`, `TB`, `TiB`, `PB`, `PiB`.
Set to 0 to disable limit. |
+| `http.enable_cors` | Bool | `true` | HTTP CORS support, it's turned on by default
This allows browser to access http APIs without CORS restrictions |
+| `http.cors_allowed_origins` | Array | Unset | Customize allowed origins for HTTP CORS. |
| `grpc` | -- | -- | The gRPC server options. |
| `grpc.addr` | String | `127.0.0.1:4001` | The address to bind the gRPC server. |
| `grpc.hostname` | String | `127.0.0.1:4001` | The hostname advertised to the metasrv,
and used for connections from outside the host |
diff --git a/config/frontend.example.toml b/config/frontend.example.toml
index ade1b9169a..2ecd7a19e8 100644
--- a/config/frontend.example.toml
+++ b/config/frontend.example.toml
@@ -31,6 +31,12 @@ timeout = "30s"
## The following units are supported: `B`, `KB`, `KiB`, `MB`, `MiB`, `GB`, `GiB`, `TB`, `TiB`, `PB`, `PiB`.
## Set to 0 to disable limit.
body_limit = "64MB"
+## HTTP CORS support, it's turned on by default
+## This allows browser to access http APIs without CORS restrictions
+enable_cors = true
+## Customize allowed origins for HTTP CORS.
+## @toml2docs:none-default
+cors_allowed_origins = ["https://example.com"]
## The gRPC server options.
[grpc]
diff --git a/config/standalone.example.toml b/config/standalone.example.toml
index c86e48ae51..da1afc29fc 100644
--- a/config/standalone.example.toml
+++ b/config/standalone.example.toml
@@ -39,6 +39,12 @@ timeout = "30s"
## The following units are supported: `B`, `KB`, `KiB`, `MB`, `MiB`, `GB`, `GiB`, `TB`, `TiB`, `PB`, `PiB`.
## Set to 0 to disable limit.
body_limit = "64MB"
+## HTTP CORS support, it's turned on by default
+## This allows browser to access http APIs without CORS restrictions
+enable_cors = true
+## Customize allowed origins for HTTP CORS.
+## @toml2docs:none-default
+cors_allowed_origins = ["https://example.com"]
## The gRPC server options.
[grpc]
diff --git a/src/cmd/tests/load_config_test.rs b/src/cmd/tests/load_config_test.rs
index 78ef6848f4..f913964694 100644
--- a/src/cmd/tests/load_config_test.rs
+++ b/src/cmd/tests/load_config_test.rs
@@ -34,6 +34,7 @@ use metric_engine::config::EngineConfig as MetricEngineConfig;
use mito2::config::MitoConfig;
use servers::export_metrics::ExportMetricsOption;
use servers::grpc::GrpcOptions;
+use servers::http::HttpOptions;
#[allow(deprecated)]
#[test]
@@ -144,6 +145,10 @@ fn test_load_frontend_example_config() {
..Default::default()
},
grpc: GrpcOptions::default().with_hostname("127.0.0.1:4001"),
+ http: HttpOptions {
+ cors_allowed_origins: vec!["https://example.com".to_string()],
+ ..Default::default()
+ },
..Default::default()
},
..Default::default()
@@ -234,6 +239,10 @@ fn test_load_standalone_example_config() {
remote_write: Some(Default::default()),
..Default::default()
},
+ http: HttpOptions {
+ cors_allowed_origins: vec!["https://example.com".to_string()],
+ ..Default::default()
+ },
..Default::default()
},
..Default::default()
diff --git a/src/servers/src/error.rs b/src/servers/src/error.rs
index 235195fa02..096c3fd75f 100644
--- a/src/servers/src/error.rs
+++ b/src/servers/src/error.rs
@@ -27,6 +27,7 @@ use common_macro::stack_trace_debug;
use common_telemetry::{error, warn};
use datatypes::prelude::ConcreteDataType;
use headers::ContentType;
+use http::header::InvalidHeaderValue;
use query::parser::PromQuery;
use serde_json::json;
use snafu::{Location, Snafu};
@@ -345,6 +346,14 @@ pub enum Error {
location: Location,
},
+ #[snafu(display("Invalid http header value"))]
+ InvalidHeaderValue {
+ #[snafu(source)]
+ error: InvalidHeaderValue,
+ #[snafu(implicit)]
+ location: Location,
+ },
+
#[snafu(display("Error accessing catalog"))]
Catalog {
source: catalog::error::Error,
@@ -678,7 +687,7 @@ impl ErrorExt for Error {
#[cfg(feature = "mem-prof")]
DumpProfileData { source, .. } => source.status_code(),
- InvalidUtf8Value { .. } => StatusCode::InvalidArguments,
+ InvalidUtf8Value { .. } | InvalidHeaderValue { .. } => StatusCode::InvalidArguments,
ParsePromQL { source, .. } => source.status_code(),
Other { source, .. } => source.status_code(),
diff --git a/src/servers/src/http.rs b/src/servers/src/http.rs
index e446e1a455..ea522efdc2 100644
--- a/src/servers/src/http.rs
+++ b/src/servers/src/http.rs
@@ -36,14 +36,14 @@ use datatypes::schema::SchemaRef;
use datatypes::value::transform_value_ref_to_json_value;
use event::{LogState, LogValidatorRef};
use futures::FutureExt;
-use http::Method;
+use http::{HeaderValue, Method};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use snafu::{ensure, ResultExt};
use tokio::sync::oneshot::{self, Sender};
use tokio::sync::Mutex;
use tower::ServiceBuilder;
-use tower_http::cors::{Any, CorsLayer};
+use tower_http::cors::{AllowOrigin, Any, CorsLayer};
use tower_http::decompression::RequestDecompressionLayer;
use tower_http::trace::TraceLayer;
@@ -52,7 +52,8 @@ use self::result::table_result::TableResponse;
use crate::configurator::ConfiguratorRef;
use crate::elasticsearch;
use crate::error::{
- AddressBindSnafu, AlreadyStartedSnafu, Error, InternalIoSnafu, Result, ToJsonSnafu,
+ AddressBindSnafu, AlreadyStartedSnafu, Error, InternalIoSnafu, InvalidHeaderValueSnafu, Result,
+ ToJsonSnafu,
};
use crate::http::influxdb::{influxdb_health, influxdb_ping, influxdb_write_v1, influxdb_write_v2};
use crate::http::prometheus::{
@@ -140,6 +141,10 @@ pub struct HttpOptions {
pub body_limit: ReadableSize,
pub is_strict_mode: bool,
+
+ pub cors_allowed_origins: Vec,
+
+ pub enable_cors: bool,
}
impl Default for HttpOptions {
@@ -150,6 +155,8 @@ impl Default for HttpOptions {
disable_dashboard: false,
body_limit: DEFAULT_BODY_LIMIT,
is_strict_mode: false,
+ cors_allowed_origins: Vec::new(),
+ enable_cors: true,
}
}
}
@@ -715,7 +722,7 @@ impl HttpServer {
/// Attaches middlewares and debug routes to the router.
/// Callers should call this method after [HttpServer::make_app()].
- pub fn build(&self, router: Router) -> Router {
+ pub fn build(&self, router: Router) -> Result {
let timeout_layer = if self.options.timeout != Duration::default() {
Some(ServiceBuilder::new().layer(DynamicTimeoutLayer::new(self.options.timeout)))
} else {
@@ -731,26 +738,45 @@ impl HttpServer {
info!("HTTP server body limit is disabled");
None
};
+ let cors_layer = if self.options.enable_cors {
+ Some(
+ CorsLayer::new()
+ .allow_methods([
+ Method::GET,
+ Method::POST,
+ Method::PUT,
+ Method::DELETE,
+ Method::HEAD,
+ ])
+ .allow_origin(if self.options.cors_allowed_origins.is_empty() {
+ AllowOrigin::from(Any)
+ } else {
+ AllowOrigin::from(
+ self.options
+ .cors_allowed_origins
+ .iter()
+ .map(|s| {
+ HeaderValue::from_str(s.as_str())
+ .context(InvalidHeaderValueSnafu)
+ })
+ .collect::>>()?,
+ )
+ })
+ .allow_headers(Any),
+ )
+ } else {
+ info!("HTTP server cross-origin is disabled");
+ None
+ };
- router
+ Ok(router
// middlewares
.layer(
ServiceBuilder::new()
// disable on failure tracing. because printing out isn't very helpful,
// and we have impl IntoResponse for Error. It will print out more detailed error messages
.layer(TraceLayer::new_for_http().on_failure(()))
- .layer(
- CorsLayer::new()
- .allow_methods([
- Method::GET,
- Method::POST,
- Method::PUT,
- Method::DELETE,
- Method::HEAD,
- ])
- .allow_origin(Any)
- .allow_headers(Any),
- )
+ .option_layer(cors_layer)
.option_layer(timeout_layer)
.option_layer(body_limit_layer)
// auth layer
@@ -772,7 +798,7 @@ impl HttpServer {
.route("/cpu", routing::post(pprof::pprof_handler))
.route("/mem", routing::post(mem_prof::mem_prof_handler)),
),
- )
+ ))
}
fn route_metrics(metrics_handler: MetricsHandler) -> Router {
@@ -1032,7 +1058,7 @@ impl Server for HttpServer {
if let Some(configurator) = self.plugins.get::() {
app = configurator.config_http(app);
}
- let app = self.build(app);
+ let app = self.build(app)?;
let listener = tokio::net::TcpListener::bind(listening)
.await
.context(AddressBindSnafu { addr: listening })?
@@ -1177,17 +1203,123 @@ mod test {
}
fn make_test_app(tx: mpsc::Sender<(String, Vec)>) -> Router {
+ make_test_app_custom(tx, HttpOptions::default())
+ }
+
+ fn make_test_app_custom(tx: mpsc::Sender<(String, Vec)>, options: HttpOptions) -> Router {
let instance = Arc::new(DummyInstance { _tx: tx });
let sql_instance = ServerSqlQueryHandlerAdapter::arc(instance.clone());
- let server = HttpServerBuilder::new(HttpOptions::default())
+ let server = HttpServerBuilder::new(options)
.with_sql_handler(sql_instance)
.build();
- server.build(server.make_app()).route(
+ server.build(server.make_app()).unwrap().route(
"/test/timeout",
get(forever.layer(ServiceBuilder::new().layer(timeout()))),
)
}
+ #[tokio::test]
+ pub async fn test_cors() {
+ // cors is on by default
+ let (tx, _rx) = mpsc::channel(100);
+ let app = make_test_app(tx);
+ let client = TestClient::new(app).await;
+
+ let res = client.get("/health").send().await;
+
+ assert_eq!(res.status(), StatusCode::OK);
+ assert_eq!(
+ res.headers()
+ .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN)
+ .expect("expect cors header origin"),
+ "*"
+ );
+
+ let res = client
+ .options("/health")
+ .header("Access-Control-Request-Headers", "x-greptime-auth")
+ .header("Access-Control-Request-Method", "DELETE")
+ .header("Origin", "https://example.com")
+ .send()
+ .await;
+ assert_eq!(res.status(), StatusCode::OK);
+ assert_eq!(
+ res.headers()
+ .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN)
+ .expect("expect cors header origin"),
+ "*"
+ );
+ assert_eq!(
+ res.headers()
+ .get(http::header::ACCESS_CONTROL_ALLOW_HEADERS)
+ .expect("expect cors header headers"),
+ "*"
+ );
+ assert_eq!(
+ res.headers()
+ .get(http::header::ACCESS_CONTROL_ALLOW_METHODS)
+ .expect("expect cors header methods"),
+ "GET,POST,PUT,DELETE,HEAD"
+ );
+ }
+
+ #[tokio::test]
+ pub async fn test_cors_custom_origins() {
+ // cors is on by default
+ let (tx, _rx) = mpsc::channel(100);
+ let origin = "https://example.com";
+
+ let options = HttpOptions {
+ cors_allowed_origins: vec![origin.to_string()],
+ ..Default::default()
+ };
+
+ let app = make_test_app_custom(tx, options);
+ let client = TestClient::new(app).await;
+
+ let res = client.get("/health").header("Origin", origin).send().await;
+
+ assert_eq!(res.status(), StatusCode::OK);
+ assert_eq!(
+ res.headers()
+ .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN)
+ .expect("expect cors header origin"),
+ origin
+ );
+
+ let res = client
+ .get("/health")
+ .header("Origin", "https://notallowed.com")
+ .send()
+ .await;
+
+ assert_eq!(res.status(), StatusCode::OK);
+ assert!(!res
+ .headers()
+ .contains_key(http::header::ACCESS_CONTROL_ALLOW_ORIGIN));
+ }
+
+ #[tokio::test]
+ pub async fn test_cors_disabled() {
+ // cors is on by default
+ let (tx, _rx) = mpsc::channel(100);
+
+ let options = HttpOptions {
+ enable_cors: false,
+ ..Default::default()
+ };
+
+ let app = make_test_app_custom(tx, options);
+ let client = TestClient::new(app).await;
+
+ let res = client.get("/health").send().await;
+
+ assert_eq!(res.status(), StatusCode::OK);
+ assert!(!res
+ .headers()
+ .contains_key(http::header::ACCESS_CONTROL_ALLOW_ORIGIN));
+ }
+
#[test]
fn test_http_options_default() {
let default = HttpOptions::default();
diff --git a/src/servers/tests/http/influxdb_test.rs b/src/servers/tests/http/influxdb_test.rs
index b4f823da86..1a251763ae 100644
--- a/src/servers/tests/http/influxdb_test.rs
+++ b/src/servers/tests/http/influxdb_test.rs
@@ -121,7 +121,7 @@ fn make_test_app(tx: Arc>, db_name: Option<&str>)
.with_user_provider(Arc::new(user_provider))
.with_influxdb_handler(instance)
.build();
- server.build(server.make_app())
+ server.build(server.make_app()).unwrap()
}
#[tokio::test]
diff --git a/src/servers/tests/http/opentsdb_test.rs b/src/servers/tests/http/opentsdb_test.rs
index efbfc2a310..358af19dc8 100644
--- a/src/servers/tests/http/opentsdb_test.rs
+++ b/src/servers/tests/http/opentsdb_test.rs
@@ -112,7 +112,7 @@ fn make_test_app(tx: mpsc::Sender) -> Router {
.with_sql_handler(instance.clone())
.with_opentsdb_handler(instance)
.build();
- server.build(server.make_app())
+ server.build(server.make_app()).unwrap()
}
#[tokio::test]
diff --git a/src/servers/tests/http/prom_store_test.rs b/src/servers/tests/http/prom_store_test.rs
index f5ca54d22d..77a06db079 100644
--- a/src/servers/tests/http/prom_store_test.rs
+++ b/src/servers/tests/http/prom_store_test.rs
@@ -141,7 +141,7 @@ fn make_test_app(tx: mpsc::Sender<(String, Vec)>) -> Router {
.with_sql_handler(instance.clone())
.with_prom_handler(instance, true, is_strict_mode)
.build();
- server.build(server.make_app())
+ server.build(server.make_app()).unwrap()
}
#[tokio::test]
diff --git a/tests-integration/src/test_util.rs b/tests-integration/src/test_util.rs
index a326681e0f..626e2d96ac 100644
--- a/tests-integration/src/test_util.rs
+++ b/tests-integration/src/test_util.rs
@@ -397,7 +397,10 @@ pub async fn setup_test_http_app(store_type: StorageType, name: &str) -> (Router
.with_metrics_handler(MetricsHandler)
.with_greptime_config_options(instance.opts.datanode_options().to_toml().unwrap())
.build();
- (http_server.build(http_server.make_app()), instance.guard)
+ (
+ http_server.build(http_server.make_app()).unwrap(),
+ instance.guard,
+ )
}
pub async fn setup_test_http_app_with_frontend(
@@ -436,7 +439,7 @@ pub async fn setup_test_http_app_with_frontend_and_user_provider(
let http_server = http_server.build();
- let app = http_server.build(http_server.make_app());
+ let app = http_server.build(http_server.make_app()).unwrap();
(app, instance.guard)
}
@@ -481,7 +484,7 @@ pub async fn setup_test_prom_app_with_frontend(
.with_prometheus_handler(frontend_ref)
.with_greptime_config_options(instance.opts.datanode_options().to_toml().unwrap())
.build();
- let app = http_server.build(http_server.make_app());
+ let app = http_server.build(http_server.make_app()).unwrap();
(app, instance.guard)
}
diff --git a/tests-integration/tests/http.rs b/tests-integration/tests/http.rs
index 3078edea51..e4a75aa341 100644
--- a/tests-integration/tests/http.rs
+++ b/tests-integration/tests/http.rs
@@ -902,6 +902,8 @@ addr = "127.0.0.1:4000"
timeout = "30s"
body_limit = "64MiB"
is_strict_mode = false
+cors_allowed_origins = []
+enable_cors = true
[grpc]
addr = "127.0.0.1:4001"