diff --git a/config/config.md b/config/config.md
index 06d3b89e075..24351b2b6fd 100644
--- a/config/config.md
+++ b/config/config.md
@@ -43,6 +43,8 @@
| `grpc` | -- | -- | The gRPC server options. |
| `grpc.bind_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. |
+| `grpc.enable_cors` | Bool | `false` | Enable CORS for gRPC-Web clients in browsers. Disabled by default. |
+| `grpc.cors_allowed_origins` | Array | Unset | Origins allowed by gRPC CORS. An empty list allows any origin. |
| `grpc.max_connection_age` | String | Unset | The maximum connection age for gRPC connection. The value can be a human-readable time string. For example: `10m` for ten minutes or `1h` for one hour. Refer to https://grpc.io/docs/guides/keepalive/ for more details. |
| `grpc.tls` | -- | -- | gRPC server TLS options, see `mysql.tls` section. |
| `grpc.tls.mode` | String | `disable` | TLS mode. |
@@ -303,6 +305,8 @@
| `grpc.server_addr` | String | `127.0.0.1:4001` | The address advertised to the metasrv, and used for connections from outside the host. If left empty or unset, the server will automatically use the IP address of the first network interface on the host, with the same port number as the one specified in `grpc.bind_addr`. |
| `grpc.runtime_size` | Integer | `8` | The number of server worker threads. |
| `grpc.flight_compression` | String | `arrow_ipc` | Compression mode for frontend side Arrow IPC service. Available options: - `none`: disable all compression - `transport`: only enable gRPC transport compression (zstd) - `arrow_ipc`: only enable Arrow IPC compression (lz4) - `all`: enable all compression. Default to `none` |
+| `grpc.enable_cors` | Bool | `false` | Enable CORS for gRPC-Web clients in browsers. Disabled by default. |
+| `grpc.cors_allowed_origins` | Array | Unset | Origins allowed by gRPC CORS. An empty list allows any origin. |
| `grpc.max_connection_age` | String | Unset | The maximum connection age for gRPC connection. The value can be a human-readable time string. For example: `10m` for ten minutes or `1h` for one hour. Refer to https://grpc.io/docs/guides/keepalive/ for more details. |
| `grpc.tls` | -- | -- | gRPC server TLS options, see `mysql.tls` section. |
| `grpc.tls.mode` | String | `disable` | TLS mode. |
diff --git a/config/frontend.example.toml b/config/frontend.example.toml
index b528da0304f..2f2c15cfc57 100644
--- a/config/frontend.example.toml
+++ b/config/frontend.example.toml
@@ -96,6 +96,11 @@ runtime_size = 8
## - `all`: enable all compression.
## Default to `none`
flight_compression = "arrow_ipc"
+## Enable CORS for gRPC-Web clients in browsers. Disabled by default.
+#+ enable_cors = false
+## Origins allowed by gRPC CORS. An empty list allows any origin.
+## @toml2docs:none-default
+#+ cors_allowed_origins = ["https://example.com"]
## The maximum connection age for gRPC connection.
## The value can be a human-readable time string. For example: `10m` for ten minutes or `1h` for one hour.
## Refer to https://grpc.io/docs/guides/keepalive/ for more details.
diff --git a/config/standalone.example.toml b/config/standalone.example.toml
index c09c815aed0..4760e038f49 100644
--- a/config/standalone.example.toml
+++ b/config/standalone.example.toml
@@ -111,6 +111,11 @@ api_server_addr = "127.0.0.1:4006"
bind_addr = "127.0.0.1:4001"
## The number of server worker threads.
runtime_size = 8
+## Enable CORS for gRPC-Web clients in browsers. Disabled by default.
+#+ enable_cors = false
+## Origins allowed by gRPC CORS. An empty list allows any origin.
+## @toml2docs:none-default
+#+ cors_allowed_origins = ["https://example.com"]
## The maximum connection age for gRPC connection.
## The value can be a human-readable time string. For example: `10m` for ten minutes or `1h` for one hour.
## Refer to https://grpc.io/docs/guides/keepalive/ for more details.
diff --git a/src/cmd/tests/load_config_test.rs b/src/cmd/tests/load_config_test.rs
index cc095c5c92e..b56299f9916 100644
--- a/src/cmd/tests/load_config_test.rs
+++ b/src/cmd/tests/load_config_test.rs
@@ -464,6 +464,7 @@ fn test_load_standalone_example_config() {
cors_allowed_origins: vec!["https://example.com".to_string()],
..Default::default()
},
+ grpc: GrpcOptions::default(),
query: QueryOptions {
memory_pool_size: MemoryLimit::Percentage(50),
..Default::default()
diff --git a/src/frontend/src/frontend.rs b/src/frontend/src/frontend.rs
index 71e5888b8fa..90e7abc4517 100644
--- a/src/frontend/src/frontend.rs
+++ b/src/frontend/src/frontend.rs
@@ -61,7 +61,8 @@ pub struct FrontendOptions {
pub http: HttpOptions,
pub grpc: GrpcOptions,
/// The internal gRPC options for the frontend service.
- /// it provide the same service as the public gRPC service, just only for internal use.
+ /// It serves the same services as the public one plus the internal handler.
+ /// CORS is always off on it.
pub internal_grpc: Option,
pub mysql: MysqlOptions,
pub postgres: PostgresOptions,
diff --git a/src/frontend/src/server.rs b/src/frontend/src/server.rs
index a5568b24be5..cf278f1f1d0 100644
--- a/src/frontend/src/server.rs
+++ b/src/frontend/src/server.rs
@@ -215,11 +215,15 @@ where
external: bool,
request_memory_limiter: ServerMemoryLimiter,
) -> Result {
- let builder = if let Some(builder) = self.grpc_server_builder.take() {
+ let mut builder = if let Some(builder) = self.grpc_server_builder.take() {
builder
} else {
self.grpc_server_builder(grpc, request_memory_limiter)?
};
+ // Browsers never talk to the internal server.
+ if external && grpc.enable_cors {
+ builder = builder.with_cors(grpc.cors_allowed_origins.clone());
+ }
let user_provider = if external {
self.plugins.get::()
@@ -522,6 +526,7 @@ mod tests {
use client::{Client, Database};
use common_grpc::channel_manager::ChannelManager;
use meta_client::client::MetaClientBuilder;
+ use reqwest::header::{ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ORIGIN};
use servers::grpc::GRPC_SERVER;
use servers::grpc::flight::{FlightCraft, FlightCraftRef, TonicStream};
use tonic::{Code, Request, Response, Status, Streaming};
@@ -1029,4 +1034,68 @@ mod tests {
// Assert
assert!(health_check.is_ok());
}
+
+ #[tokio::test]
+ async fn test_internal_grpc_server_never_serves_cors() {
+ let options = FrontendOptions {
+ http: HttpOptions {
+ addr: "127.0.0.1:0".to_string(),
+ ..Default::default()
+ },
+ grpc: GrpcOptions {
+ enable_cors: true,
+ ..GrpcOptions::default().with_bind_addr("127.0.0.1:0")
+ },
+ internal_grpc: Some(GrpcOptions {
+ enable_cors: true,
+ ..GrpcOptions::default().with_bind_addr("127.0.0.1:0")
+ }),
+ mysql: crate::service_config::MysqlOptions {
+ enable: false,
+ ..Default::default()
+ },
+ postgres: crate::service_config::PostgresOptions {
+ enable: false,
+ ..Default::default()
+ },
+ ..Default::default()
+ };
+ let meta_client = Arc::new(
+ MetaClientBuilder::new(0, Role::Frontend)
+ .enable_procedure()
+ .build(),
+ );
+ let instance = Arc::new(
+ FrontendBuilder::new_test(&options, meta_client)
+ .try_build()
+ .await
+ .unwrap(),
+ );
+ let mut services = Services::new(options, instance, Default::default())
+ .build()
+ .unwrap();
+
+ services.start_all().await.unwrap();
+ let public_addr = services.addr(GRPC_SERVER).unwrap();
+ let internal_addr = services.addr("INTERNAL_GRPC_SERVER").unwrap();
+ let public = send_cors_preflight(public_addr).await;
+ let internal = send_cors_preflight(internal_addr).await;
+ services.shutdown_all().await.unwrap();
+
+ assert!(public.headers().contains_key(ACCESS_CONTROL_ALLOW_ORIGIN));
+ assert!(!internal.headers().contains_key(ACCESS_CONTROL_ALLOW_ORIGIN));
+ }
+
+ async fn send_cors_preflight(addr: std::net::SocketAddr) -> reqwest::Response {
+ reqwest::Client::new()
+ .request(
+ reqwest::Method::OPTIONS,
+ format!("http://{addr}/greptime.v1.HealthCheck/Check"),
+ )
+ .header(ORIGIN, "https://example.com")
+ .header(ACCESS_CONTROL_REQUEST_METHOD, "POST")
+ .send()
+ .await
+ .unwrap()
+ }
}
diff --git a/src/servers/src/grpc.rs b/src/servers/src/grpc.rs
index 1be1f8215b5..9e9335b422d 100644
--- a/src/servers/src/grpc.rs
+++ b/src/servers/src/grpc.rs
@@ -36,6 +36,7 @@ use common_grpc::channel_manager::{
};
use common_telemetry::{error, info, warn};
use futures::FutureExt;
+use http::{HeaderName, Method};
use otel_arrow_rust::proto::opentelemetry::arrow::v1::arrow_metrics_service_server::ArrowMetricsServiceServer;
use serde::{Deserialize, Serialize};
use snafu::{OptionExt, ResultExt, ensure};
@@ -48,9 +49,11 @@ use tonic::transport::ServerTlsConfig;
use tonic::transport::server::TcpIncoming;
use tonic::{Request, Response, Status};
use tonic_reflection::server::v1::{ServerReflection, ServerReflectionServer};
+use tower_http::cors::{AllowHeaders, CorsLayer};
use crate::error::{AlreadyStartedSnafu, InternalSnafu, Result, StartGrpcSnafu, TcpBindSnafu};
use crate::grpc::memory_limit::MemoryLimiterExtensionService;
+use crate::http::cors_allow_origin;
use crate::install_default_crypto_provider;
use crate::metrics::MetricsMiddlewareLayer;
use crate::otel_arrow::{HeaderInterceptor, OtelArrowServiceHandler};
@@ -87,6 +90,11 @@ pub struct GrpcOptions {
/// The HTTP/2 keep-alive timeout.
#[serde(with = "humantime_serde")]
pub http2_keep_alive_timeout: Duration,
+ /// Whether to enable CORS, required by gRPC-Web clients in browsers.
+ /// Only the frontend's public gRPC server honors it.
+ pub enable_cors: bool,
+ /// Origins allowed by CORS. Empty allows any origin.
+ pub cors_allowed_origins: Vec,
}
impl GrpcOptions {
@@ -155,6 +163,8 @@ impl Default for GrpcOptions {
max_connection_age: None,
http2_keep_alive_interval: Duration::from_secs(10),
http2_keep_alive_timeout: Duration::from_secs(3),
+ enable_cors: false,
+ cors_allowed_origins: Vec::new(),
}
}
}
@@ -176,6 +186,8 @@ impl GrpcOptions {
max_connection_age: None,
http2_keep_alive_interval: Duration::from_secs(10),
http2_keep_alive_timeout: Duration::from_secs(3),
+ enable_cors: false,
+ cors_allowed_origins: Vec::new(),
}
}
@@ -237,6 +249,8 @@ pub struct GrpcServer {
bind_addr: Option,
name: Option,
config: GrpcServerConfig,
+ /// `None` disables CORS.
+ cors_allowed_origins: Option>,
}
/// Grpc Server configuration
@@ -265,6 +279,24 @@ impl Default for GrpcServerConfig {
}
impl GrpcServer {
+ fn cors_layer(&self) -> Result