From 2fa5b9298ae9fb85028c2454b92df729d08f473f Mon Sep 17 00:00:00 2001 From: Jinpy Date: Thu, 1 Oct 2026 01:01:37 +0800 Subject: [PATCH] fix(web): confine docs notes paths to allowlisted roots --- crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/docs.rs | 232 +++++++++++++++++++++++++++++- crates/dbx-web/src/routes/mq.rs | 1 + crates/dbx-web/src/state.rs | 5 + 4 files changed, 232 insertions(+), 7 deletions(-) diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 28147e60fb..dbe8f57695 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -471,6 +471,7 @@ async fn serve() { let web_state = Arc::new(WebState { app: app_state, data_dir, + notes_roots: routes::docs::notes_roots_from_env(std::env::var_os("DBX_DOCS_NOTES_ROOTS").as_deref()), public_base_path: public_base_path.clone(), demo_mode, password_disabled, diff --git a/crates/dbx-web/src/routes/docs.rs b/crates/dbx-web/src/routes/docs.rs index 8ecfedd62c..e8e9ecf94f 100644 --- a/crates/dbx-web/src/routes/docs.rs +++ b/crates/dbx-web/src/routes/docs.rs @@ -85,11 +85,57 @@ pub struct DocsSaveRequest { pub annotations: dbx_core::docs::annotations::AnnotationFile, } -/// `WebState` already carries the data directory (`pub data_dir: PathBuf`), so -/// this needs no lookup and no new dependency — dbx-web does NOT depend on -/// dbx-mcp. -fn notes_path_for(state: &Arc, config: &ConnectionConfig) -> std::path::PathBuf { - dbx_core::docs::annotations::resolve_notes_path(&config.id, config.docs_notes_path.as_deref(), &state.data_dir) +/// On the HTTP surface an explicit connection-level `docsNotesPath` is a +/// drive-by arbitrary-path write primitive: `/api/connection/save` stores it +/// verbatim and `annotations/save` then creates and overwrites whatever it +/// points at, with the server process's permissions. The desktop keeps its +/// "point notes at a repo file" flow; the server confines notes under +/// `data_dir/docs-notes` unless the operator explicitly allowlists additional +/// absolute roots via `DBX_DOCS_NOTES_ROOTS`, injected through +/// `WebState::notes_roots` so the containment path stays testable. +fn notes_path_for(state: &Arc, config: &ConnectionConfig) -> Result { + let explicit = config.docs_notes_path.as_deref().map(str::trim).filter(|value| !value.is_empty()); + let Some(explicit) = explicit else { + return Ok(dbx_core::docs::annotations::resolve_notes_path(&config.id, None, &state.data_dir)); + }; + let default_root = state.data_dir.join("docs-notes"); + let candidate = std::path::PathBuf::from(explicit); + // Relative candidates resolve under the default root first; absolute ones + // must prefix-match an allowlisted root (or the default). Any `..` in the + // matched suffix rejects — traversal shapes are never worth allowing here. + let resolved = if candidate.is_absolute() { candidate } else { default_root.join(&candidate) }; + let contained = + std::iter::once(&default_root).chain(state.notes_roots.iter()).find_map(|root| contain_within(root, &resolved)); + contained.ok_or_else(|| { + AppError::from(format!( + "docsNotesPath \"{explicit}\" is outside the allowed notes roots; set DBX_DOCS_NOTES_ROOTS to allow this server's notes to live elsewhere" + )) + }) +} + +/// Allowlist roots are deployment-static: resolved once at startup (from +/// `DBX_DOCS_NOTES_ROOTS`), not per request. `split_paths` keeps Windows drive +/// roots (`C:\notes`) parseable, which a naive `:` split would break. +pub(crate) fn notes_roots_from_env(raw: Option<&std::ffi::OsStr>) -> Vec { + raw.map(std::env::split_paths) + .into_iter() + .flatten() + .filter(|root| !root.as_os_str().is_empty() && root.is_absolute()) + .collect() +} + +/// Prefix-based containment: the candidate must lexically sit under `base`, +/// and `..` is rejected anywhere in the remaining suffix so the rejoin can +/// never climb back out. No symlink resolution: notes files are host-authored +/// JSON, and create_dir_all would otherwise follow an attacker-planted link — +/// the containment boundary is the prefix check. +fn contain_within(base: &std::path::Path, candidate: &std::path::Path) -> Option { + use std::path::Component; + let suffix = candidate.strip_prefix(base).ok()?; + if suffix.components().any(|component| component == Component::ParentDir) { + return None; + } + Some(base.join(suffix)) } pub async fn load_annotations( @@ -97,7 +143,7 @@ pub async fn load_annotations( Json(request): Json, ) -> Result>, AppError> { let config = load_connection(&state, &request.connection_id).await?; - let path = notes_path_for(&state, &config); + let path = notes_path_for(&state, &config)?; Ok(Json(dbx_core::docs::annotations::load_annotations(&path).map_err(AppError::from)?)) } @@ -116,7 +162,7 @@ pub async fn save_annotations( Json(request): Json, ) -> Result, AppError> { let config = load_connection(&state, &request.connection_id).await?; - let path = notes_path_for(&state, &config); + let path = notes_path_for(&state, &config)?; dbx_core::docs::annotations::save_annotations(&path, &request.annotations).map_err(AppError::from)?; Ok(Json(())) } @@ -143,3 +189,175 @@ pub async fn export_html(Json(request): Json) -> Result) -> ConnectionConfig { + ConnectionConfig { + docs_notes_path: docs_notes_path.map(str::to_string), + id: id.to_string(), + name: "Postgres".to_string(), + note: String::new(), + db_type: DatabaseType::Postgres, + driver_profile: None, + driver_label: None, + url_params: None, + agent_java_options: Vec::new(), + host: String::new(), + port: 0, + username: String::new(), + password: String::new(), + database: None, + default_schema: None, + visible_databases: None, + visible_database_patterns: None, + visible_schemas: None, + show_system_schemas: false, + sidebar_auto_load_all_tables: false, + attached_databases: Vec::new(), + init_script: None, + color: None, + transport_layers: Vec::new(), + connect_timeout_secs: default_connect_timeout_secs(), + query_timeout_secs: default_query_timeout_secs(), + idle_timeout_secs: default_idle_timeout_secs(), + keepalive_interval_secs: default_keepalive_interval_secs(), + ssl: false, + ca_cert_path: String::new(), + client_cert_path: String::new(), + client_key_path: String::new(), + sysdba: false, + oracle_connection_type: None, + connection_string: None, + redis_connection_mode: None, + redis_sentinel_master: String::new(), + redis_sentinel_nodes: String::new(), + redis_sentinel_username: String::new(), + redis_sentinel_password: String::new(), + redis_sentinel_tls: false, + redis_cluster_nodes: String::new(), + redis_key_separator: dbx_core::models::connection::default_redis_key_separator(), + redis_scan_page_size: None, + redis_database_aliases: Default::default(), + redis_key_templates: Vec::new(), + redis_key_grouping: None, + etcd_endpoints: String::new(), + gbase_server: String::new(), + informix_server: String::new(), + external_config: None, + plugin_id: None, + plugin_connection_provider: None, + plugin_connection_type: None, + connection_secrets: Default::default(), + jdbc_driver_class: None, + jdbc_driver_paths: Vec::new(), + one_time: false, + save_password: true, + read_only: false, + is_production: false, + production_databases: vec![], + database_info: None, + } + } + + async fn test_web_state(notes_roots: Vec) -> (Arc, tempfile::TempDir) { + let directory = tempfile::tempdir().unwrap(); + let storage = + dbx_core::persistence::test_storage::open_unmigrated(&directory.path().join("dbx.db")).await.unwrap(); + let mut state = WebState::for_tests( + std::sync::Arc::new(dbx_core::connection::AppState::new(storage)), + directory.path().to_path_buf(), + ); + state.notes_roots = notes_roots; + (std::sync::Arc::new(state), directory) + } + + #[tokio::test] + async fn notes_paths_stay_inside_the_data_dir_by_default() { + let (state, _data_dir_guard) = test_web_state(Vec::new()).await; + let data_dir = state.data_dir.clone(); + + // 未配置 docsNotesPath 时使用默认的 data_dir/docs-notes/.json。 + let config = postgres_config("conn-1", None); + let path = notes_path_for(&state, &config).unwrap(); + assert_eq!(path, data_dir.join("docs-notes").join("conn-1.json")); + + // 指向 docs-notes 内部的相对路径可用(仓库笔记工作流的受控形态)。 + let config = postgres_config("conn-1", Some("repo/notes.json")); + let path = notes_path_for(&state, &config).unwrap(); + assert_eq!(path, data_dir.join("docs-notes").join("repo").join("notes.json")); + + // ../ 逃逸与 default root 之外的绝对路径都拒绝:settings 写入不得变成任意文件覆盖。 + let escaping = postgres_config("conn-1", Some("../escape.json")); + assert!(notes_path_for(&state, &escaping).is_err()); + let absolute_outside = postgres_config("conn-1", Some(data_dir.join("escape.json").to_str().unwrap())); + assert!(notes_path_for(&state, &absolute_outside).is_err()); + } + + #[tokio::test] + async fn allowlisted_roots_admit_absolute_paths_inside_them() { + let repo_root = tempfile::tempdir().unwrap(); + let (state, _data_dir_guard) = test_web_state(vec![repo_root.path().to_path_buf()]).await; + let data_dir = state.data_dir.clone(); + + // allowlist 之外的绝对路径仍拒绝。 + let outside = postgres_config("conn-1", Some(data_dir.join("escape.json").to_str().unwrap())); + assert!(notes_path_for(&state, &outside).is_err()); + + // allowlist 之内的绝对路径放行(solo 部署把笔记指到仓库)。 + let inside = + postgres_config("conn-1", Some(repo_root.path().join("project").join("notes.json").to_str().unwrap())); + let path = notes_path_for(&state, &inside).unwrap(); + assert_eq!(path, repo_root.path().join("project").join("notes.json")); + + // root 本身也放行。 + let at_root = postgres_config("conn-1", Some(repo_root.path().to_str().unwrap())); + assert_eq!(notes_path_for(&state, &at_root).unwrap(), repo_root.path()); + + // suffix 里的 `..` 爬出 root 仍拒绝。 + let climbing = postgres_config( + "conn-1", + Some(repo_root.path().join("project").join("..").join("escape.json").to_str().unwrap()), + ); + assert!(notes_path_for(&state, &climbing).is_err()); + } + + #[test] + fn env_roots_parse_via_split_paths_and_require_absolute() { + assert_eq!(notes_roots_from_env(None), Vec::::new()); + assert_eq!(notes_roots_from_env(Some(OsStr::new(""))), Vec::::new()); + assert_eq!( + notes_roots_from_env(Some(OsStr::new("relative/path"))), + Vec::::new(), + "roots must be absolute" + ); + + #[cfg(windows)] + let roots = [PathBuf::from(r"C:\srv\notes"), PathBuf::from(r"D:\tmp\x")]; + #[cfg(not(windows))] + let roots = [PathBuf::from("/srv/notes"), PathBuf::from("/tmp/x")]; + let joined = std::env::join_paths(roots.iter()).unwrap(); + assert_eq!(notes_roots_from_env(Some(joined.as_os_str())), roots); + } + + #[test] + fn contain_within_rejects_parent_components_and_keeps_prefix_matches() { + let base = PathBuf::from("/srv/notes"); + assert_eq!(contain_within(&base, Path::new("/srv/notes/a/b.json")), Some(base.join("a").join("b.json"))); + assert_eq!(contain_within(&base, Path::new("/srv/notes/../escape.json")), None); + assert_eq!(contain_within(&base, Path::new("/srv/other.json")), None); + assert_eq!(contain_within(&base, Path::new("/etc/passwd")), None); + assert_eq!(contain_within(&base, Path::new("relative/x.json")), None); + } +} diff --git a/crates/dbx-web/src/routes/mq.rs b/crates/dbx-web/src/routes/mq.rs index 04bf79911d..c4017c8369 100644 --- a/crates/dbx-web/src/routes/mq.rs +++ b/crates/dbx-web/src/routes/mq.rs @@ -1607,6 +1607,7 @@ mod tests { let state = Arc::new(WebState { app, data_dir: dir.clone(), + notes_roots: Vec::new(), public_base_path: "/".to_string(), password_disabled: false, demo_mode: false, diff --git a/crates/dbx-web/src/state.rs b/crates/dbx-web/src/state.rs index d987f7e8b0..531aefb624 100644 --- a/crates/dbx-web/src/state.rs +++ b/crates/dbx-web/src/state.rs @@ -31,6 +31,10 @@ pub struct NacosImportContext { pub struct WebState { pub app: Arc, pub data_dir: PathBuf, + /// Extra absolute roots allowed for connection-level `docsNotesPath` + /// beyond `data_dir/docs-notes` (`DBX_DOCS_NOTES_ROOTS`), resolved once at + /// startup; injectable so notes containment is testable (see `docs`). + pub notes_roots: Vec, pub public_base_path: String, pub password_disabled: bool, /// `DBX_DEMO_MODE`:公网演示部署的封锁开关(见 `demo` 模块)。 @@ -62,6 +66,7 @@ impl WebState { Self { app, data_dir, + notes_roots: Vec::new(), public_base_path: "/".to_string(), password_disabled: false, demo_mode: false,