Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
///
Expand Down
25 changes: 24 additions & 1 deletion src/search/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down Expand Up @@ -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.
}
}
191 changes: 183 additions & 8 deletions src/serve/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -1507,7 +1508,7 @@ impl ServeState {
tracing::warn!(
"IndexManager init failed for '{}': {} - FSW not restarted, searches still work",
alias, e
);
);
}
}
}
Expand Down Expand Up @@ -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<String> = 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<String>,
port: Option<u16>,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<Mutex<()>> = 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"
);
}
}
}
Loading