diff --git a/src/constants.rs b/src/constants.rs index df5c529c..531107bd 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -234,6 +234,31 @@ pub const SERVE_API_KEY_ENV: &str = "CODESEARCH_SERVE_API_KEY"; /// Example: `CODESEARCH_ALLOWED_ROOTS=/home/user/repos;/opt/code` pub const ALLOWED_ROOTS_ENV: &str = "CODESEARCH_ALLOWED_ROOTS"; +/// Environment variable to override the rmcp Streamable HTTP server's +/// `allowed_hosts` list (DNS-rebinding defence, GHSA-89vp-x53w-74fx). +/// +/// rmcp's default is loopback-only (`["localhost", "127.0.0.1", "::1"]`), +/// which rejects the container hostname in containerised deployments with +/// `WARN ... rejected request with disallowed Host header`. Setting this +/// env var to a comma-separated list of hostnames / `host:port` replaces +/// the default allowlist. +/// +/// When unset or empty, the rmcp default applies. See issue #149. +/// Example: `CODESEARCH_ALLOWED_HOSTS=codesearch.internal, codesearch:39725` +pub const ALLOWED_HOSTS_ENV: &str = "CODESEARCH_ALLOWED_HOSTS"; + +/// Environment variable to disable the rmcp Streamable HTTP server's +/// `Host` header validation entirely. +/// +/// **Dangerous**: turns off DNS-rebinding protection (GHSA-89vp-x53w-74fx). +/// Only set when codesearch runs behind a reverse proxy (nginx, Caddy, +/// Traefik) that itself validates the `Host` header against an allowlist. +/// Any other value leaves validation enabled. +/// +/// Accepts `1` or `true` (case-insensitive) to disable. +/// Example: `CODESEARCH_DISABLE_HOST_VALIDATION=1` +pub const DISABLE_HOST_VALIDATION_ENV: &str = "CODESEARCH_DISABLE_HOST_VALIDATION"; + /// Default base URL for connecting to a local `codesearch serve` instance. /// Used as the clap `--url` default and in `serve_base_url()`. /// diff --git a/src/search/mod.rs b/src/search/mod.rs index f84f0f7f..3fcbf3eb 100644 --- a/src/search/mod.rs +++ b/src/search/mod.rs @@ -1340,7 +1340,12 @@ fn print_result( .join(" "); let snippet = if snippet.len() > 100 { - format!("{}...", &snippet[..100]) + // Truncate at the largest UTF-8 char boundary <= 100 bytes. + // Plain `&snippet[..100]` panics if byte 100 falls inside a + // multi-byte character (box-drawing separators, CJK, emoji) — + // see issue #148. + let cut = snippet.floor_char_boundary(100); + format!("{}...", &snippet[..cut]) } else { snippet }; @@ -1698,4 +1703,22 @@ mod tests { // Lone ESC at end assert_eq!(sanitize_for_terminal("text\x1b"), "text"); } + + #[test] + fn test_byte_truncation_preserves_char_boundary() { + // Regression for issue #148: `&snippet[..100]` panicked when byte + // offset 100 fell inside a multi-byte character (box-drawing U+2500 + // in comment-art, CJK, emoji). 40 × U+2500 = 120 bytes, so byte 100 + // is inside char #34 (bytes 99..102). + let s: String = std::iter::repeat_n('─', 40).collect(); + assert!(s.len() > 100, "fixture must exceed 100 bytes"); + let cut = s.floor_char_boundary(100); + assert!(cut <= 100); + assert!(s.is_char_boundary(cut), "cut must land on a char boundary"); + let truncated = &s[..cut]; + // All chars are 3 bytes; cut must be a multiple of 3. + assert_eq!(cut % 3, 0); + assert_eq!(truncated.chars().count(), cut / 3); + // The pre-fix code (`&s[..100]`) would panic on this fixture. + } } diff --git a/src/serve/mod.rs b/src/serve/mod.rs index a4f1169a..38817d19 100644 --- a/src/serve/mod.rs +++ b/src/serve/mod.rs @@ -35,12 +35,13 @@ use tracing::{info, warn}; use crate::cache::safe_canonicalize; use crate::constants::{ - ALLOWED_ROOTS_ENV, CHUNK_PATH, CSHARP_PREWARM_ENABLED_ENV, CSHARP_PREWARM_MAX_SYMBOLS, - CSHARP_SCIP_CONCURRENCY_DEFAULT, CSHARP_SCIP_CONCURRENCY_ENV, DB_DIR_NAME, DEFAULT_SERVE_PORT, - EXPLORE_PATH, FIND_PATH, HEALTHZ_PATH, HEALTH_PATH, LANG_CSHARP, MAX_INDEXING_SECS, - MAX_INDEXING_SECS_ENV, MCP_ENDPOINT_PATH, PERSIST_DEBOUNCE_SECS, REAPER_INTERVAL_SECS, - REMOTES_PATH, REPO_IDLE_TIMEOUT_ENV, REPO_IDLE_TIMEOUT_SECS, SEARCH_PATH, SERVE_API_KEY_ENV, - SERVE_PORT_ENV, STATUS_PATH, + ALLOWED_HOSTS_ENV, ALLOWED_ROOTS_ENV, CHUNK_PATH, CSHARP_PREWARM_ENABLED_ENV, + CSHARP_PREWARM_MAX_SYMBOLS, CSHARP_SCIP_CONCURRENCY_DEFAULT, CSHARP_SCIP_CONCURRENCY_ENV, + DB_DIR_NAME, DEFAULT_SERVE_PORT, DISABLE_HOST_VALIDATION_ENV, EXPLORE_PATH, FIND_PATH, + HEALTHZ_PATH, HEALTH_PATH, LANG_CSHARP, MAX_INDEXING_SECS, MAX_INDEXING_SECS_ENV, + MCP_ENDPOINT_PATH, PERSIST_DEBOUNCE_SECS, REAPER_INTERVAL_SECS, REMOTES_PATH, + REPO_IDLE_TIMEOUT_ENV, REPO_IDLE_TIMEOUT_SECS, SEARCH_PATH, SERVE_API_KEY_ENV, SERVE_PORT_ENV, + STATUS_PATH, }; use crate::db_discovery::repos::{config_dir, ReposConfig}; use crate::index::{CSharpRebuildNotifier, IndexManager, IndexingStatusCallback, SharedStores}; @@ -1507,7 +1508,7 @@ impl ServeState { tracing::warn!( "IndexManager init failed for '{}': {} - FSW not restarted, searches still work", alias, e - ); + ); } } } @@ -3839,6 +3840,64 @@ fn raise_fd_limit(repo_count: usize) { } } +/// Build the rmcp `StreamableHttpServerConfig`, applying env-var overrides for +/// the DNS-rebinding `Host` header validation (GHSA-89vp-x53w-74fx, fixed +/// upstream in rmcp 1.4.0; default allowlist is loopback-only). +/// +/// Resolution order (first match wins): +/// 1. `CODESEARCH_DISABLE_HOST_VALIDATION=1|true` → `disable_allowed_hosts()` +/// (only safe behind a reverse proxy that validates Host itself). Logged +/// at WARN. +/// 2. `CODESEARCH_ALLOWED_HOSTS=host[,host:port,...]` → `with_allowed_hosts(...)` +/// (comma-separated, whitespace-trimmed, empties dropped). Logged at INFO. +/// 3. Both unset (or `ALLOWED_HOSTS` empty after trim) → rmcp loopback-only +/// default (`["localhost", "127.0.0.1", "::1"]`). +/// +/// See issue #149. +fn build_streamable_http_config() -> StreamableHttpServerConfig { + let config = StreamableHttpServerConfig::default(); + + if std::env::var(DISABLE_HOST_VALIDATION_ENV) + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) + { + warn!( + "DNS rebinding protection (rmcp allowed_hosts) DISABLED via {DISABLE_HOST_VALIDATION_ENV}. \ + Only safe behind a reverse proxy that validates the Host header." + ); + return config.disable_allowed_hosts(); + } + + match std::env::var(ALLOWED_HOSTS_ENV) + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + { + Some(raw) => { + let hosts: Vec = raw + .split(',') + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .collect(); + if hosts.is_empty() { + warn!( + "{ALLOWED_HOSTS_ENV} was set but contained no valid host entries; \ + using rmcp loopback-only default" + ); + config + } else { + info!( + "Overriding rmcp allowed_hosts with {} entry/entries from {ALLOWED_HOSTS_ENV}: [{}]", + hosts.len(), + hosts.join(", ") + ); + config.with_allowed_hosts(hosts) + } + } + None => config, + } +} + pub async fn run_serve( host: Option, port: Option, @@ -3980,7 +4039,10 @@ pub async fn run_serve( let mut session_manager = LocalSessionManager::default(); session_manager.session_config.keep_alive = None; let session_manager = Arc::new(session_manager); - let config = StreamableHttpServerConfig::default(); + + // Configure the rmcp Streamable HTTP server's DNS-rebinding defence + // (GHSA-89vp-x53w-74fx, fixed upstream in rmcp 1.4.0). See issue #149. + let config = build_streamable_http_config(); let mcp_service = StreamableHttpService::new(service_factory, session_manager, config); @@ -5347,4 +5409,117 @@ mod tests { // "all" is never stored — an unknown real group still errors. assert!(state.resolve_group_aliases("does-not-exist").is_err()); } + + /// Tests for `build_streamable_http_config` — DNS rebinding defence env vars + /// (`CODESEARCH_ALLOWED_HOSTS`, `CODESEARCH_DISABLE_HOST_VALIDATION`) added + /// for issue #149 / GHSA-89vp-x53w-74fx. + mod allowed_hosts_tests { + use super::*; + use std::sync::Mutex; + + /// Serialize env var mutations across parallel test threads (same pattern + /// as `allowed_roots_tests`). Different env vars from `allowed_roots_tests` + /// so cross-module parallelism is safe. + static ENV_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + + fn lock() -> std::sync::MutexGuard<'static, ()> { + ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() + } + + fn clear_env() { + std::env::remove_var(ALLOWED_HOSTS_ENV); + std::env::remove_var(DISABLE_HOST_VALIDATION_ENV); + } + + #[test] + fn default_is_loopback_only() { + let _guard = lock(); + clear_env(); + let config = build_streamable_http_config(); + assert_eq!( + config.allowed_hosts, + vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + "::1".to_string(), + ] + ); + } + + #[test] + fn custom_allowed_hosts_replaces_default() { + let _guard = lock(); + clear_env(); + std::env::set_var(ALLOWED_HOSTS_ENV, "codesearch.internal, codesearch:39725"); + let config = build_streamable_http_config(); + assert_eq!( + config.allowed_hosts, + vec![ + "codesearch.internal".to_string(), + "codesearch:39725".to_string(), + ] + ); + } + + #[test] + fn disable_validation_clears_allowlist() { + let _guard = lock(); + clear_env(); + std::env::set_var(DISABLE_HOST_VALIDATION_ENV, "1"); + let config = build_streamable_http_config(); + assert!( + config.allowed_hosts.is_empty(), + "disable_allowed_hosts() should produce an empty allowlist" + ); + } + + #[test] + fn disable_validation_accepts_true_case_insensitive() { + let _guard = lock(); + clear_env(); + std::env::set_var(DISABLE_HOST_VALIDATION_ENV, "TRUE"); + let config = build_streamable_http_config(); + assert!(config.allowed_hosts.is_empty()); + } + + #[test] + fn disable_validation_ignores_other_values() { + let _guard = lock(); + clear_env(); + std::env::set_var(DISABLE_HOST_VALIDATION_ENV, "yes"); + let config = build_streamable_http_config(); + // Not "1" or "true" → rmcp default applies. + assert_eq!(config.allowed_hosts.len(), 3); + } + + #[test] + fn empty_allowed_hosts_falls_back_to_default() { + let _guard = lock(); + clear_env(); + std::env::set_var(ALLOWED_HOSTS_ENV, " , , "); + let config = build_streamable_http_config(); + assert_eq!( + config.allowed_hosts, + vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + "::1".to_string(), + ], + "all-empty entries should leave the rmcp default intact" + ); + } + + #[test] + fn disable_overrides_allowed_hosts() { + let _guard = lock(); + clear_env(); + std::env::set_var(ALLOWED_HOSTS_ENV, "codesearch.internal"); + std::env::set_var(DISABLE_HOST_VALIDATION_ENV, "true"); + let config = build_streamable_http_config(); + assert!( + config.allowed_hosts.is_empty(), + "DISABLE_HOST_VALIDATION takes precedence over ALLOWED_HOSTS" + ); + } + } }