feat: add CORS support for gRPC-Web on the frontend gRPC server (#9374)

* feat: add CORS support for gRPC-Web on the frontend gRPC server

A browser-based gRPC-Web client cannot read a cross-origin response. The
request carries a non-simple content type, so the browser sends an
`OPTIONS` preflight first, and it drops the call when the response has no
`Access-Control-Allow-Origin`.

Add `enable_cors` and `cors_allowed_origins` to `[grpc]`, threaded from
`GrpcOptions` to `GrpcServerConfig` the same way `tls` is.

The CORS layer sits outside `tonic_web::GrpcWebLayer`, so it answers the
preflight before the request reaches the gRPC routes. It exposes
`grpc-status`, `grpc-message` and `grpc-status-details-bin`, which a
browser cannot read otherwise. An empty `cors_allowed_origins` allows any
origin.

`enable_cors` defaults to false, unlike `[http]`. `GrpcOptions` is shared
by the public `[grpc]` section and the internal `[internal_grpc]` one, and
serde cannot tell them apart, so a true default would also turn CORS on
for the internal gRPC listeners: the frontend internal gRPC server, and
the datanode and flownode gRPC servers. None of them authenticate callers,
and a browser on the host or in the cluster network can reach all of them.
Set `enable_cors = true` to turn it on.

Tests start a real server on an ephemeral port and cover the preflight, a
custom origin list, and the disabled case.

Update the example TOMLs and regenerate config/config.md.

Signed-off-by: lczllx <2181719471@qq.com>

* fix: drop the redundant OPTIONS from the gRPC CORS allow_methods

Signed-off-by: lczllx <2181719471@qq.com>

* test: assert the allow-headers and allow-methods headers in the gRPC CORS preflight

The preflight answers with `access-control-allow-headers: *` from
`AllowHeaders::any()`, and with `access-control-allow-methods: post` from the
POST-only `allow_methods` list. Pin both, the way the HTTP CORS test pins its
own headers: a missing allow-headers header would let the preflight pass the
origin check and still have the browser block every gRPC-Web call, which a
non-browser client would never notice.

Signed-off-by: lczllx <2181719471@qq.com>

* docs(config): stop the example configs from setting the new gRPC CORS keys

Signed-off-by: lczllx <2181719471@qq.com>

* test(servers): cover the exposed gRPC CORS headers and pin the option plumbing

Signed-off-by: lczllx <2181719471@qq.com>

* docs(config): document the gRPC CORS origin example with `#+`

Signed-off-by: lczllx <2181719471@qq.com>

* fix(frontend): keep CORS off the internal gRPC server

Signed-off-by: lczllx <2181719471@qq.com>

* docs(grpc): document that the internal gRPC server never serves CORS

Signed-off-by: lczllx <2181719471@qq.com>

* Update src/servers/src/grpc.rs

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* chore: trim redundant comments

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* refactor: share CORS origin parsing and simplify gRPC-Web CORS tests

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* refactor: enable gRPC CORS only on the frontend public server

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* docs(config): clarify the gRPC CORS options

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

---------

Signed-off-by: lczllx <2181719471@qq.com>
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
Co-authored-by: Dennis Zhuang <killme2008@gmail.com>
This commit is contained in:
Dragon Roar
2026-09-30 10:40:17 +00:00
committed by GitHub
co-authored by Dennis Zhuang
parent a314ac4281
commit d7f5331876
10 changed files with 280 additions and 20 deletions
+4
View File
@@ -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.<br/>The value can be a human-readable time string. For example: `10m` for ten minutes or `1h` for one hour.<br/>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.<br/>If left empty or unset, the server will automatically use the IP address of the first network interface<br/>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:<br/>- `none`: disable all compression<br/>- `transport`: only enable gRPC transport compression (zstd)<br/>- `arrow_ipc`: only enable Arrow IPC compression (lz4)<br/>- `all`: enable all compression.<br/>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.<br/>The value can be a human-readable time string. For example: `10m` for ten minutes or `1h` for one hour.<br/>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. |
+5
View File
@@ -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.
+5
View File
@@ -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.
+1
View File
@@ -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()
+2 -1
View File
@@ -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<GrpcOptions>,
pub mysql: MysqlOptions,
pub postgres: PostgresOptions,
+70 -1
View File
@@ -215,11 +215,15 @@ where
external: bool,
request_memory_limiter: ServerMemoryLimiter,
) -> Result<GrpcServer> {
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::<UserProviderRef>()
@@ -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()
}
}
+169 -4
View File
@@ -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<String>,
}
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<SocketAddr>,
name: Option<String>,
config: GrpcServerConfig,
/// `None` disables CORS.
cors_allowed_origins: Option<Vec<String>>,
}
/// Grpc Server configuration
@@ -265,6 +279,24 @@ impl Default for GrpcServerConfig {
}
impl GrpcServer {
fn cors_layer(&self) -> Result<Option<CorsLayer>> {
let Some(allowed_origins) = &self.cors_allowed_origins else {
return Ok(None);
};
let layer = CorsLayer::new()
.allow_methods([Method::POST])
.allow_origin(cors_allow_origin(allowed_origins)?)
.allow_headers(AllowHeaders::any())
// Trailers-only responses (most errors) carry `grpc-status` in HTTP
// headers, which browsers hide unless exposed.
.expose_headers([
HeaderName::from_static("grpc-status"),
HeaderName::from_static("grpc-message"),
HeaderName::from_static("grpc-status-details-bin"),
]);
Ok(Some(layer))
}
pub fn create_healthcheck_service(&self) -> HealthCheckServer<impl HealthCheck> {
HealthCheckServer::new(HealthCheckHandler)
}
@@ -359,13 +391,18 @@ impl Server for GrpcServer {
(incoming, addr)
};
let metrics_layer = tower::ServiceBuilder::new()
let cors_layer = self.cors_layer()?;
// `Server::layer` prepends, so the first layer added is outermost. CORS must
// sit outside `GrpcWebLayer`, which rejects the `OPTIONS` preflight with a 400.
let middleware_layer = tower::ServiceBuilder::new()
.layer(MetricsMiddlewareLayer)
.option_layer(cors_layer)
.into_inner();
let mut builder = tonic::transport::Server::builder()
.accept_http1(true)
.layer(metrics_layer)
.layer(middleware_layer)
.layer(tonic_web::GrpcWebLayer::new());
if let Some(tls_config) = self.tls_config.clone() {
@@ -425,9 +462,23 @@ impl Server for GrpcServer {
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use super::{DEFAULT_GRPC_ADDR_PORT, format_server_addr, port_from_bind_addr};
use common_runtime::Runtime;
use common_runtime::runtime::{BuilderBuild, RuntimeTrait};
use http::header::{
ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN,
ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_REQUEST_HEADERS,
ACCESS_CONTROL_REQUEST_METHOD, CONTENT_TYPE, ORIGIN,
};
use http::{HeaderName, Method, StatusCode};
use super::{
DEFAULT_GRPC_ADDR_PORT, GrpcServer, GrpcServerConfig, format_server_addr,
port_from_bind_addr,
};
use crate::grpc::builder::GrpcServerBuilder;
use crate::server::Server;
#[test]
fn test_port_from_bind_addr() {
@@ -454,4 +505,118 @@ mod tests {
format_server_addr(IpAddr::V6(Ipv6Addr::LOCALHOST), 3002)
);
}
/// The returned server owns the shutdown sender and must outlive the requests.
async fn start_test_grpc_server(
cors_allowed_origins: Option<Vec<String>>,
) -> (GrpcServer, SocketAddr) {
let runtime = Runtime::builder().build().unwrap();
let mut builder = GrpcServerBuilder::new(GrpcServerConfig::default(), runtime);
if let Some(origins) = cors_allowed_origins {
builder = builder.with_cors(origins);
}
let mut server = builder.build();
server
.start(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = server.bind_addr().unwrap();
(server, addr)
}
const TEST_PATH: &str = "greptime.v1.HealthCheck/Check";
async fn send_preflight(addr: SocketAddr, origin: &str) -> reqwest::Response {
reqwest::Client::new()
.request(Method::OPTIONS, format!("http://{addr}/{TEST_PATH}"))
.header(ORIGIN, origin)
.header(ACCESS_CONTROL_REQUEST_METHOD, "POST")
.header(ACCESS_CONTROL_REQUEST_HEADERS, "content-type,x-grpc-web")
.send()
.await
.unwrap()
}
/// The path is not served, so the response is a trailers-only `Unimplemented`.
async fn send_grpc_web_call(addr: SocketAddr, origin: &str) -> reqwest::Response {
reqwest::Client::new()
.post(format!("http://{addr}/{TEST_PATH}"))
.header(ORIGIN, origin)
.header(CONTENT_TYPE, "application/grpc-web+proto")
.header("x-grpc-web", "1")
// Empty gRPC-Web frame: 1-byte compression flag + 4-byte length.
.body(vec![0u8; 5])
.send()
.await
.unwrap()
}
fn header(response: &reqwest::Response, name: HeaderName) -> Option<&str> {
response.headers().get(name).map(|v| v.to_str().unwrap())
}
#[tokio::test]
async fn test_grpc_web_cors_preflight() {
let (_server, addr) = start_test_grpc_server(Some(Vec::new())).await;
let response = send_preflight(addr, "https://example.com").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(header(&response, ACCESS_CONTROL_ALLOW_ORIGIN), Some("*"));
assert_eq!(header(&response, ACCESS_CONTROL_ALLOW_HEADERS), Some("*"));
// Lists the method of the actual call, not `OPTIONS`.
assert_eq!(
header(&response, ACCESS_CONTROL_ALLOW_METHODS),
Some("POST")
);
}
#[tokio::test]
async fn test_grpc_web_cors_expose_headers() {
let (_server, addr) = start_test_grpc_server(Some(Vec::new())).await;
let response = send_grpc_web_call(addr, "https://example.com").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(header(&response, ACCESS_CONTROL_ALLOW_ORIGIN), Some("*"));
// Premise: the response is trailers-only.
assert_eq!(
header(&response, HeaderName::from_static("grpc-status")),
Some("12")
);
let expose_headers = header(&response, ACCESS_CONTROL_EXPOSE_HEADERS).unwrap();
for name in ["grpc-status", "grpc-message", "grpc-status-details-bin"] {
assert!(expose_headers.contains(name), "{expose_headers}");
}
}
#[tokio::test]
async fn test_grpc_web_cors_custom_origins() {
let (_server, addr) =
start_test_grpc_server(Some(vec!["https://example.com".to_string()])).await;
let response = send_preflight(addr, "https://example.com").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
header(&response, ACCESS_CONTROL_ALLOW_ORIGIN),
Some("https://example.com")
);
// A disallowed origin still gets a 200, just without allow-origin; the
// browser does the blocking.
let response = send_preflight(addr, "https://notallowed.com").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(header(&response, ACCESS_CONTROL_ALLOW_ORIGIN), None);
}
#[tokio::test]
async fn test_grpc_web_cors_disabled() {
let (_server, addr) = start_test_grpc_server(None).await;
let response = send_preflight(addr, "https://example.com").await;
// Without CORS, `GrpcWebLayer` rejects the preflight.
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(header(&response, ACCESS_CONTROL_ALLOW_ORIGIN), None);
}
}
+9
View File
@@ -97,6 +97,7 @@ pub struct GrpcServerBuilder {
>,
>,
memory_limiter: ServerMemoryLimiter,
cors_allowed_origins: Option<Vec<String>>,
}
impl GrpcServerBuilder {
@@ -112,9 +113,16 @@ impl GrpcServerBuilder {
tls_config: None,
otel_arrow_service: None,
memory_limiter,
cors_allowed_origins: None,
}
}
/// Enables CORS for gRPC-Web clients in browsers. An empty list allows any origin.
pub fn with_cors(mut self, allowed_origins: Vec<String>) -> Self {
self.cors_allowed_origins = Some(allowed_origins);
self
}
/// Set a global memory limiter for all server protocols.
pub fn with_memory_limiter(mut self, limiter: ServerMemoryLimiter) -> Self {
self.memory_limiter = limiter;
@@ -257,6 +265,7 @@ impl GrpcServerBuilder {
bind_addr: None,
name: self.name,
config: self.config,
cors_allowed_origins: self.cors_allowed_origins,
}
}
}
+13 -14
View File
@@ -1061,20 +1061,7 @@ impl HttpServer {
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::<Result<Vec<HeaderValue>>>()?,
)
})
.allow_origin(cors_allow_origin(&self.options.cors_allowed_origins)?)
.allow_headers(Any),
)
} else {
@@ -1612,6 +1599,18 @@ impl HttpServer {
pub const HTTP_SERVER: &str = "HTTP_SERVER";
pub const HTTP_API_SERVER: &str = "HTTP_API_SERVER";
/// An empty list allows any origin.
pub(crate) fn cors_allow_origin(origins: &[String]) -> Result<AllowOrigin> {
if origins.is_empty() {
return Ok(AllowOrigin::any());
}
let origins = origins
.iter()
.map(|origin| HeaderValue::from_str(origin).context(InvalidHeaderValueSnafu))
.collect::<Result<Vec<_>>>()?;
Ok(AllowOrigin::list(origins))
}
#[async_trait]
impl Server for HttpServer {
async fn shutdown(&self) -> Result<()> {
+2
View File
@@ -2835,6 +2835,8 @@ flight_compression = "arrow_ipc"
runtime_size = 8
http2_keep_alive_interval = "10s"
http2_keep_alive_timeout = "3s"
enable_cors = false
cors_allowed_origins = []
[grpc.tls]
mode = "disable"