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"