feat(security): add demo mode lockdown for public deployments

DBX_DEMO_MODE=1 returns 403 for plugin/JDBC/agent installs,
connection probes and edits, AI provider config, cloud sync and
credential endpoints; /connection/connect only accepts pre-seeded
connections with matching endpoint identity. Ships a deploy/demo
compose recipe with seeded databases and scheduled reset.
This commit is contained in:
t8y2 committed 2026-09-23 23:53:00 +08:00
1 parent 8897e12714
commit b06e330a44
14 files changed
+793 -3

No files matched your search

+376
View File
@@ -0,0 +1,376 @@
//! `DBX_DEMO_MODE` 守卫:面向公网演示部署封锁会改变服务器状态、或让服务器向
//! 任意端点发起连接的入口。默认关闭,行为与常规部署完全一致。
//!
//! 封锁面分两类:
//! 1. `demo_mode_gate` 中间件按路径拦截:凭据改写(auth/setup、cloud-sync、AI
//! 供应商配置)、插件/JDBC/驱动安装(sidecar 原生进程 = 远程代码执行)、纯
//! 探测端点(connection/test*、mq/nacos test-connection、tunnel-profiles/test
//! —— body 携带完整连接配置,等于 SSRF)。
//! 2. `connection/connect` 不整体封锁(演示访客必须能连上预置连接),改为
//! `ensure_demo_connect_allowed`:请求体里的连接 id 必须已保存,且端点身份
//! 与保存值一致,防止伪造 body 把服务器拨向任意主机。
use std::sync::Arc;
use axum::extract::State;
use axum::http::{Method, Request, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Json;
use dbx_core::connection::{connection_configs_session_credentials_compatible, AppState};
use dbx_core::models::connection::ConnectionConfig;
use crate::auth::middleware_api_path_suffix;
use crate::state::WebState;
pub(crate) const DEMO_CONNECT_REJECTED: &str = "Demo mode only allows connecting to the pre-configured connections";
pub fn demo_mode_from_env() -> bool {
demo_mode_from_env_value(std::env::var("DBX_DEMO_MODE").ok().as_deref())
}
pub fn demo_mode_from_env_value(value: Option<&str>) -> bool {
value.map(|v| matches!(v.trim().to_lowercase().as_str(), "1" | "true" | "yes" | "on")).unwrap_or(false)
}
/// 无论方法一律拦截的路径(对应路由本身只注册了这一种方法)。
const BLOCKED_EXACT: &[&str] = &[
// 访客不得抢先设置或改写演示实例口令
"auth/setup",
"auth/change-password",
// 纯探测端点:body 携带完整连接配置,服务器会拨向任意主机
"connection/test",
"connection/test-info",
"connection/test-ssh-tunnel",
"mq/test-connection",
"nacos/test-connection",
"tunnel-profiles/test",
// 演示连接清单只读:整表覆盖会互删,新增等于 SSRF
"connection/save",
"connection/mcp/add",
"connection/mcp/duplicate",
"connection/mcp/remove",
"tunnel-profiles/save",
// 已存连接凭据解密出口
"app-settings/config/decrypt",
// 读取服务器本地 ~/.ssh/config
"ssh/config-hosts",
];
/// 非 GET 请求整族拦截的前缀:插件/JDBC/驱动安装与运行时控制、云同步外发通道。
/// GET 保持放行(商店浏览、已装列表、安装进度)。
const BLOCKED_MUTATING_PREFIXES: &[&str] = &["plugins/", "jdbc/", "agents/", "cloud-sync/"];
/// AI 供应商配置写入与按配置外呼的端点(无配置时本就不可用,一并封锁防止访客
/// 自带 key 配置后把服务器当外呼跳板)。会话、取消与只读 GET 不受影响。
const BLOCKED_AI_EXACT: &[&str] = &[
"ai/config",
"ai/provider-config",
"ai/configs",
"ai/default-config",
"ai/config-item",
"ai/chat-selection",
"ai/complete",
"ai/stream",
"ai/agent-stream",
"ai/test-connection",
"ai/models",
"ai/model-effort",
];
pub(crate) fn is_demo_blocked(method: &Method, suffix: &str) -> bool {
if BLOCKED_EXACT.contains(&suffix) {
return true;
}
if method != Method::GET {
if BLOCKED_MUTATING_PREFIXES.iter().any(|prefix| suffix.starts_with(prefix)) {
return true;
}
// DELETE /ai/config/{config_id}
if suffix.starts_with("ai/config/") {
return true;
}
}
method != Method::GET && BLOCKED_AI_EXACT.contains(&suffix)
}
pub async fn demo_mode_gate(
State(state): State<Arc<WebState>>,
request: Request<axum::body::Body>,
next: Next,
) -> Response {
if state.demo_mode {
let suffix = middleware_api_path_suffix(request.uri().path(), &state.public_base_path);
if suffix.is_some_and(|suffix| is_demo_blocked(request.method(), suffix)) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"code": "DEMO_MODE_DISABLED",
"message": "This operation is disabled in demo mode"
})),
)
.into_response();
}
}
next.run(request).await
}
/// 演示模式下 `/connection/connect` 的准入:连接必须已保存,且请求体的端点身份
/// 与保存值一致(忽略密码与当前选库)。凭据解析仍走原路径,存储里的预置连接
/// 密码照常生效。
pub(crate) async fn ensure_demo_connect_allowed(app: &AppState, config: &ConnectionConfig) -> Result<(), String> {
let stored = app
.storage
.load_connections()
.await
.map_err(|error| format!("Failed to load connections: {error}"))?
.into_iter()
.find(|stored| stored.id == config.id);
match stored {
Some(stored) if demo_connect_endpoint_matches(&stored, config) => Ok(()),
_ => Err(DEMO_CONNECT_REJECTED.to_string()),
}
}
fn demo_connect_endpoint_matches(stored: &ConnectionConfig, requested: &ConnectionConfig) -> bool {
let mut stored = stored.clone();
let mut requested = requested.clone();
// 密码以存储为准、选库是会话级选择,两者不参与端点身份比较。
stored.save_password = false;
requested.save_password = false;
stored.password.clear();
requested.password.clear();
stored.database = None;
requested.database = None;
connection_configs_session_credentials_compatible(&stored, &requested)
}
#[cfg(test)]
mod tests {
use super::{
demo_connect_endpoint_matches, demo_mode_from_env_value, ensure_demo_connect_allowed, is_demo_blocked,
};
use axum::http::Method;
use dbx_core::models::connection::ConnectionConfig;
fn config(json: serde_json::Value) -> ConnectionConfig {
serde_json::from_value(json).unwrap()
}
fn stored_config() -> ConnectionConfig {
config(serde_json::json!({
"id": "demo-pg",
"name": "Demo PostgreSQL",
"db_type": "postgres",
"host": "demo-postgres",
"port": 5432,
"username": "demo",
"password": "stored-secret",
"database": "dvdrental",
"save_password": true
}))
}
#[test]
fn demo_env_flag_accepts_common_truthy_spellings_only() {
for value in ["1", "true", "TRUE", " Yes ", "on"] {
assert!(demo_mode_from_env_value(Some(value)), "expected truthy: {value}");
}
for value in [None, Some(""), Some("0"), Some("false"), Some("off"), Some("demo")] {
assert!(!demo_mode_from_env_value(value), "expected falsy: {value:?}");
}
}
#[test]
fn demo_gate_blocks_credential_plugin_and_probe_routes() {
let blocked = [
(Method::POST, "auth/setup"),
(Method::POST, "auth/change-password"),
(Method::POST, "connection/test"),
(Method::POST, "connection/test-info"),
(Method::POST, "connection/test-ssh-tunnel"),
(Method::POST, "connection/save"),
(Method::POST, "connection/mcp/add"),
(Method::POST, "mq/test-connection"),
(Method::POST, "nacos/test-connection"),
(Method::POST, "tunnel-profiles/test"),
(Method::POST, "tunnel-profiles/save"),
(Method::POST, "app-settings/config/decrypt"),
(Method::GET, "ssh/config-hosts"),
(Method::POST, "plugins/install"),
(Method::POST, "plugins/marketplace/install"),
(Method::POST, "plugins/uninstall"),
(Method::POST, "plugins/trusted-keys/save"),
(Method::POST, "plugins/filesystem/write"),
(Method::POST, "jdbc/plugin/install"),
(Method::POST, "jdbc/plugin/install-local"),
(Method::DELETE, "jdbc/drivers/maven/bundle-1"),
(Method::POST, "agents/install"),
(Method::POST, "agents/import-offline"),
(Method::POST, "agents/runtime/stop"),
(Method::POST, "cloud-sync/webdav/upload"),
(Method::POST, "cloud-sync/snippet/save-token"),
(Method::POST, "ai/config"),
(Method::POST, "ai/provider-config"),
(Method::POST, "ai/stream"),
(Method::POST, "ai/test-connection"),
(Method::DELETE, "ai/config/cfg-1"),
];
for (method, suffix) in blocked {
assert!(is_demo_blocked(&method, suffix), "expected blocked: {method} {suffix}");
}
}
#[test]
fn demo_gate_keeps_readonly_and_demo_core_routes_open() {
let allowed = [
(Method::GET, "connection/list"),
(Method::POST, "connection/connect"),
(Method::POST, "connection/disconnect"),
(Method::POST, "query/execute"),
(Method::POST, "query/cancel"),
(Method::GET, "plugins"),
(Method::GET, "plugins/marketplace/catalogs"),
(Method::GET, "agents/installed"),
(Method::GET, "agents/progress/op-1"),
(Method::POST, "history/save"),
(Method::DELETE, "history/entry-1"),
(Method::POST, "saved-sql"),
(Method::POST, "ai/conversation"),
(Method::POST, "ai/cancel-stream"),
(Method::GET, "ai/config"),
(Method::POST, "app-settings/mcp-policy"),
(Method::POST, "plugin/table-metadata"),
(Method::POST, "migration/start"),
(Method::POST, "database-backups"),
(Method::POST, "schema/cache"),
(Method::DELETE, "schema/cache-prefix"),
(Method::POST, "redis/set-string"),
(Method::POST, "mongo/find-documents"),
(Method::POST, "consul/txn"),
(Method::POST, "export/table"),
(Method::POST, "import/preview"),
];
for (method, suffix) in allowed {
assert!(!is_demo_blocked(&method, suffix), "expected allowed: {method} {suffix}");
}
}
#[test]
fn demo_connect_endpoint_match_ignores_password_and_selected_database() {
let stored = stored_config();
let requested = config(serde_json::json!({
"id": "demo-pg",
"name": "Demo PostgreSQL",
"db_type": "postgres",
"host": "demo-postgres",
"port": 5432,
"username": "demo",
"password": "",
"database": "postgres",
"save_password": true
}));
assert!(demo_connect_endpoint_matches(&stored, &requested));
}
#[test]
fn demo_connect_endpoint_match_rejects_endpoint_and_transport_changes() {
let stored = stored_config();
let hijacked_host = config(serde_json::json!({
"id": "demo-pg",
"name": "Demo PostgreSQL",
"db_type": "postgres",
"host": "internal-bastion",
"port": 5432,
"username": "demo",
"password": ""
}));
assert!(!demo_connect_endpoint_matches(&stored, &hijacked_host));
let mut smuggled_tunnel = stored_config();
smuggled_tunnel.password.clear();
smuggled_tunnel.transport_layers = vec![serde_json::from_value(serde_json::json!({
"type": "ssh",
"id": "smuggled",
"enabled": true,
"host": "internal-bastion",
"port": 22,
"user": "root"
}))
.unwrap()];
assert!(!demo_connect_endpoint_matches(&stored, &smuggled_tunnel));
}
#[tokio::test]
async fn demo_connect_guard_requires_a_pool_equivalent_stored_connection() {
let directory = tempfile::tempdir().unwrap();
let storage = dbx_core::storage::Storage::open_unmigrated(&directory.path().join("dbx.db")).await.unwrap();
let app = std::sync::Arc::new(dbx_core::connection::AppState::new(storage));
let unknown = stored_config();
let error = ensure_demo_connect_allowed(&app, &unknown).await.unwrap_err();
assert_eq!(error, super::DEMO_CONNECT_REJECTED);
app.storage.save_connections(&[stored_config()]).await.unwrap();
let mut tampered = stored_config();
tampered.host = "evil.example.com".to_string();
assert_eq!(ensure_demo_connect_allowed(&app, &tampered).await.unwrap_err(), super::DEMO_CONNECT_REJECTED);
let mut legitimate = stored_config();
legitimate.password.clear();
legitimate.database = Some("postgres".to_string());
ensure_demo_connect_allowed(&app, &legitimate).await.unwrap();
}
#[tokio::test]
async fn demo_gate_returns_forbidden_only_when_enabled() {
use axum::routing::{get, post};
let directory = tempfile::tempdir().unwrap();
let storage = dbx_core::storage::Storage::open_unmigrated(&directory.path().join("dbx.db")).await.unwrap();
let app = std::sync::Arc::new(dbx_core::connection::AppState::new(storage));
let state = {
let mut web_state = crate::state::WebState::for_tests(app, directory.path().to_path_buf());
web_state.demo_mode = true;
std::sync::Arc::new(web_state)
};
let router = axum::Router::new()
.route("/api/plugins/install", post(|| async { "install" }))
.route("/api/connection/list", get(|| async { "connections" }))
.layer(axum::middleware::from_fn_with_state(state.clone(), super::demo_mode_gate));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
let client = reqwest::Client::new();
let blocked = client.post(format!("http://{address}/api/plugins/install")).send().await.unwrap();
assert_eq!(blocked.status(), reqwest::StatusCode::FORBIDDEN);
let payload = blocked.json::<serde_json::Value>().await.unwrap();
assert_eq!(payload["code"], "DEMO_MODE_DISABLED");
assert_eq!(payload["message"], "This operation is disabled in demo mode");
let allowed = client.get(format!("http://{address}/api/connection/list")).send().await.unwrap();
assert_eq!(allowed.status(), reqwest::StatusCode::OK);
assert_eq!(allowed.text().await.unwrap(), "connections");
server.abort();
// 关闭开关后同一中间件直接放行。
let app = std::sync::Arc::new(dbx_core::connection::AppState::new(
dbx_core::storage::Storage::open_unmigrated(&directory.path().join("dbx.db")).await.unwrap(),
));
let state = std::sync::Arc::new(crate::state::WebState::for_tests(app, directory.path().to_path_buf()));
let router = axum::Router::new()
.route("/api/plugins/install", post(|| async { "install" }))
.layer(axum::middleware::from_fn_with_state(state, super::demo_mode_gate));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
let response = client.post(format!("http://{address}/api/plugins/install")).send().await.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
assert_eq!(response.text().await.unwrap(), "install");
server.abort();
}
}
+4
View File
@@ -26,6 +26,10 @@ impl AppError {
Self::with_status(msg.into(), StatusCode::BAD_REQUEST)
}
pub fn forbidden(msg: impl Into<String>) -> Self {
Self::with_status(msg.into(), StatusCode::FORBIDDEN)
}
pub fn not_found(msg: impl Into<String>) -> Self {
Self::with_status(msg.into(), StatusCode::NOT_FOUND)
}
+8
View File
@@ -1,4 +1,5 @@
mod auth;
mod demo;
mod error;
mod routes;
mod sse;
@@ -386,11 +387,14 @@ async fn serve() {
let public_base_path = normalize_public_base_path(std::env::var("DBX_PUBLIC_BASE_PATH").ok());
let demo_mode = demo::demo_mode_from_env();
let migration_ready = storage_migration_ready(&app_state).await;
let web_state = Arc::new(WebState {
app: app_state,
data_dir,
public_base_path: public_base_path.clone(),
demo_mode,
password_disabled,
password_hash: RwLock::new(password_hash),
sessions: RwLock::new(HashSet::new()),
@@ -1247,6 +1251,7 @@ async fn serve() {
let api = add_mq_routes(api)
.layer(middleware::from_fn_with_state(web_state.clone(), migration_gate))
.layer(middleware::from_fn_with_state(web_state.clone(), demo::demo_mode_gate))
.layer(middleware::from_fn_with_state(web_state.clone(), auth::auth_middleware))
.with_state(web_state.clone());
@@ -1278,6 +1283,9 @@ async fn serve() {
} else if std::env::var("DBX_PASSWORD").is_ok() {
tracing::info!("Password protection is enabled");
}
if demo_mode {
tracing::info!("Demo mode is enabled: connection/plugin/AI mutations are blocked");
}
let listener = tokio::net::TcpListener::bind(addr).await.expect("Failed to bind address");
let shutdown_state = web_state.app.clone();
+5
View File
@@ -401,6 +401,11 @@ pub async fn connect_db(
Json(body): Json<ConnectRequest>,
) -> Result<Json<String>, AppError> {
let config = body.config;
// 演示模式:只允许连接已保存的连接,端点身份以存储为准,防止伪造 body
// 配置把服务器拨向任意主机(见 demo 模块)。
if state.demo_mode {
crate::demo::ensure_demo_connect_allowed(&state.app, &config).await.map_err(AppError::forbidden)?;
}
if config.db_type == dbx_core::models::connection::DatabaseType::Sqlite {
dbx_core::db::sqlite::validate_persistent_attachments(
&config.host,
+6 -3
View File
@@ -1,10 +1,13 @@
use axum::{extract::Query, Json};
use std::sync::Arc;
use axum::{extract::Query, extract::State, Json};
use dbx_core::{changelog, update};
use crate::error::AppError;
use crate::state::WebState;
pub async fn get_version() -> Json<serde_json::Value> {
Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION") }))
pub async fn get_version(State(state): State<Arc<WebState>>) -> Json<serde_json::Value> {
Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "demoMode": state.demo_mode }))
}
#[derive(serde::Deserialize)]
+3
View File
@@ -33,6 +33,8 @@ pub struct WebState {
pub data_dir: PathBuf,
pub public_base_path: String,
pub password_disabled: bool,
/// `DBX_DEMO_MODE`:公网演示部署的封锁开关(见 `demo` 模块)。
pub demo_mode: bool,
pub password_hash: RwLock<Option<String>>,
pub sessions: RwLock<HashSet<String>>,
pub sse_channels: RwLock<HashMap<String, broadcast::Sender<String>>>,
@@ -61,6 +63,7 @@ impl WebState {
data_dir,
public_base_path: "/".to_string(),
password_disabled: false,
demo_mode: false,
password_hash: RwLock::new(None),
sessions: RwLock::new(HashSet::new()),
sse_channels: RwLock::new(HashMap::new()),