From c6b35045d80c68f1a53f1607d19a11a56bfac7b2 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 12:24:21 +0200 Subject: [PATCH] config visibility, PR review fixes, XSS hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config visibility: - startup banner shows config path, data dir, services path - config search: ./numa.toml → ~/.config/numa/ → /usr/local/var/numa/ - /stats API exposes config_path and data_dir, dashboard footer renders them - GET /ca.pem endpoint serves CA cert for cross-device TLS trust - load_config returns ConfigLoad with found flag, warns on not-found - ServerCtx stores PathBuf for config_dir/data_dir, string conversion at boundaries PR review fixes: - add explicit parens in resolve_route operator precedence (service_store.rs) - hostname portability: drop -s flag, trim domain with split('.') (lan.rs) - serve_ca uses spawn_blocking instead of sync fs::read in async handler - load_config: remove TOCTOU exists() check, read directly and handle NotFound XSS hardening: - HTML-escape all user-controlled interpolations in dashboard (service names, route paths, ports, URLs, block check domain/reason) Co-Authored-By: Claude Opus 4.6 --- site/dashboard.html | 36 +++++++++++++++----------- src/api.rs | 23 +++++++++++++++++ src/config.rs | 61 +++++++++++++++++++++++++++++++++++++++----- src/ctx.rs | 5 ++++ src/lan.rs | 3 +-- src/main.rs | 21 +++++++++++++-- src/service_store.rs | 4 +-- 7 files changed, 126 insertions(+), 27 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index 76c2a80..ef07202 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -875,6 +875,8 @@ async function refresh() { document.getElementById('uptime').textContent = formatUptime(stats.uptime_secs); document.getElementById('uptimeSub').textContent = formatUptimeSub(stats.uptime_secs); document.getElementById('footerUpstream').textContent = stats.upstream || ''; + document.getElementById('footerConfig').textContent = stats.config_path || ''; + document.getElementById('footerData').textContent = stats.data_dir || ''; // LAN status indicator const lanEl = document.getElementById('lanToggle'); @@ -1006,14 +1008,16 @@ async function checkDomain(event) { if (result.blocked) { el.style.background = 'rgba(181, 68, 58, 0.1)'; el.style.color = 'var(--rose)'; - el.innerHTML = `Blocked — ${result.reason}` + - (result.matched_rule ? `
Rule: ${result.matched_rule}` : '') + - ` `; + const hd = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); + el.innerHTML = `Blocked — ${hd(result.reason)}` + + (result.matched_rule ? `
Rule: ${hd(result.matched_rule)}` : '') + + ` `; } else { + const hd = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); el.style.background = 'rgba(82, 122, 82, 0.1)'; el.style.color = 'var(--emerald)'; - el.innerHTML = `Allowed — ${result.reason}` + - (result.matched_rule ? `
Rule: ${result.matched_rule}` : ''); + el.innerHTML = `Allowed — ${hd(result.reason)}` + + (result.matched_rule ? `
Rule: ${hd(result.matched_rule)}` : ''); } } catch (err) { el.style.display = 'block'; @@ -1118,26 +1122,27 @@ function renderServices(entries) { ? 'LAN' : 'local only') : ''; - const esc = s => s.replace(/'/g, "\\'").replace(/"/g, '"'); + const h = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); const routeLines = (e.routes || []).map(r => `
` + - `${r.path} ` + - `→ :${r.port}` + + `${h(r.path)} ` + + `→ :${parseInt(r.port)||0}` + (r.strip ? ` (strip)` : '') + - (e.name === 'numa' ? '' : ` `) + + (e.name === 'numa' ? '' : ` `) + `
` ).join(''); const deletable = e.source !== 'config' && e.name !== 'numa'; + const name = h(e.name); return `
-
${e.name}.numa${lanBadge}
-
localhost:${e.target_port} → proxied
+
${name}.numa${lanBadge}
+
localhost:${parseInt(e.target_port)||0} → proxied
${routeLines} - ${e.name === 'numa' ? '' : `
`} + ${e.name === 'numa' ? '' : `
`}
- ${deletable ? `` : ''} + ${deletable ? `` : ''}
`}).join(''); } @@ -1222,8 +1227,9 @@ setInterval(refresh, 2000);
- Upstream: - · Logs: macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f + Config: + · Data: + · Upstream: · GitHub
diff --git a/src/api.rs b/src/api.rs index be67e29..4166fb3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -49,6 +49,7 @@ pub fn router(ctx: Arc) -> Router { .route("/services/{name}/routes", get(list_routes)) .route("/services/{name}/routes", post(add_route)) .route("/services/{name}/routes", delete(remove_route)) + .route("/ca.pem", get(serve_ca)) .with_state(ctx) } @@ -130,6 +131,8 @@ struct QueryLogResponse { struct StatsResponse { uptime_secs: u64, upstream: String, + config_path: String, + data_dir: String, queries: QueriesStats, cache: CacheStats, overrides: OverrideStats, @@ -451,6 +454,8 @@ async fn stats(State(ctx): State>) -> Json { Json(StatsResponse { uptime_secs: snap.uptime_secs, upstream, + config_path: ctx.config_path.clone(), + data_dir: ctx.data_dir.to_string_lossy().to_string(), queries: QueriesStats { total: snap.total, forwarded: snap.forwarded, @@ -810,6 +815,24 @@ async fn remove_route( } } +async fn serve_ca(State(ctx): State>) -> Result { + let ca_path = ctx.data_dir.join("ca.pem"); + let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path)) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map_err(|_| StatusCode::NOT_FOUND)?; + Ok(( + [ + (header::CONTENT_TYPE, "application/x-pem-file"), + ( + header::CONTENT_DISPOSITION, + "attachment; filename=\"numa-ca.pem\"", + ), + ], + bytes, + )) +} + async fn check_tcp(addr: std::net::SocketAddr) -> bool { tokio::time::timeout( std::time::Duration::from_millis(100), diff --git a/src/config.rs b/src/config.rs index e7eb607..f0cd811 100644 --- a/src/config.rs +++ b/src/config.rs @@ -316,13 +316,62 @@ mod tests { } } -pub fn load_config(path: &str) -> Result { - if !Path::new(path).exists() { - return Ok(Config::default()); +pub struct ConfigLoad { + pub config: Config, + pub path: String, + pub found: bool, +} + +fn resolve_path(path: &str) -> String { + // canonicalize gives the real absolute path for existing files; + // for non-existent files, build an absolute path manually + std::fs::canonicalize(path) + .or_else(|_| std::env::current_dir().map(|cwd| cwd.join(path))) + .unwrap_or_else(|_| Path::new(path).to_path_buf()) + .to_string_lossy() + .to_string() +} + +pub fn load_config(path: &str) -> Result { + // Try the given path first, then well-known locations (for service mode where cwd is /) + let candidates: Vec = { + let p = Path::new(path); + let mut v = vec![p.to_path_buf()]; + if p.is_relative() { + let filename = p.file_name().unwrap_or(p.as_os_str()); + v.push(crate::config_dir().join(filename)); + v.push(crate::data_dir().join(filename)); + } + v + }; + + for candidate in &candidates { + match std::fs::read_to_string(candidate) { + Ok(contents) => { + let resolved = resolve_path(&candidate.to_string_lossy()); + let config: Config = toml::from_str(&contents)?; + return Ok(ConfigLoad { + config, + path: resolved, + found: true, + }); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e.into()), + } } - let contents = std::fs::read_to_string(path)?; - let config: Config = toml::from_str(&contents)?; - Ok(config) + + // Show config_dir candidate as the "expected" path — it's actionable + let display_path = candidates + .get(1) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| resolve_path(path)); + log::info!("config not found, using defaults (create {})", display_path); + Ok(ConfigLoad { + config: Config::default(), + path: display_path, + found: false, + }) } pub type ZoneMap = HashMap>>; diff --git a/src/ctx.rs b/src/ctx.rs index b5d896a..167caba 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1,4 +1,5 @@ use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Mutex; use std::time::{Duration, Instant, SystemTime}; @@ -40,6 +41,10 @@ pub struct ServerCtx { pub proxy_tld: String, pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation pub lan_enabled: bool, + pub config_path: String, + pub config_found: bool, + pub config_dir: PathBuf, + pub data_dir: PathBuf, } pub async fn handle_query( diff --git a/src/lan.rs b/src/lan.rs index e76fdd0..609a351 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -85,11 +85,10 @@ pub fn detect_lan_ip() -> Option { fn get_hostname() -> String { std::process::Command::new("hostname") - .arg("-s") .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|h| h.trim().to_string()) + .map(|h| h.trim().split('.').next().unwrap_or("numa").to_string()) .filter(|h| !h.is_empty()) .unwrap_or_else(|| "numa".to_string()) } diff --git a/src/main.rs b/src/main.rs index 6e09442..35029b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use tokio::net::UdpSocket; use numa::blocklist::{download_blocklists, parse_blocklist, BlocklistStore}; use numa::buffer::BytePacketBuffer; use numa::cache::DnsCache; -use numa::config::{build_zone_map, load_config}; +use numa::config::{build_zone_map, load_config, ConfigLoad}; use numa::ctx::{handle_query, ServerCtx}; use numa::override_store::OverrideStore; use numa::query_log::QueryLog; @@ -96,7 +96,11 @@ async fn main() -> numa::Result<()> { } else { arg1 // treat as config path for backwards compatibility }; - let config = load_config(&config_path)?; + let ConfigLoad { + config, + path: resolved_config_path, + found: config_found, + } = load_config(&config_path)?; // Discover system DNS in a single pass (upstream + forwarding rules) let system_dns = discover_system_dns(); @@ -160,6 +164,10 @@ async fn main() -> numa::Result<()> { }, proxy_tld: config.proxy.tld.clone(), lan_enabled: config.lan.enabled, + config_path: resolved_config_path, + config_found, + config_dir: numa::config_dir(), + data_dir: numa::data_dir(), }); let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); @@ -193,6 +201,15 @@ async fn main() -> numa::Result<()> { eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mRouting\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", format!("{} conditional rules", ctx.forwarding_rules.len())); } + eprintln!("\x1b[38;2;192;98;58m ╠──────────────────────────────────────────╣\x1b[0m"); + let config_label = if ctx.config_found { + ctx.config_path.clone() + } else { + format!("{} (defaults)", ctx.config_path) + }; + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;163;152;136mConfig\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", config_label); + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;163;152;136mData\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", ctx.data_dir.display()); + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;163;152;136mServices\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", ctx.config_dir.join("services.json").display()); eprintln!("\x1b[38;2;192;98;58m ╚══════════════════════════════════════════╝\x1b[0m\n"); info!( diff --git a/src/service_store.rs b/src/service_store.rs index 3e0bf9c..7db3ffd 100644 --- a/src/service_store.rs +++ b/src/service_store.rs @@ -29,9 +29,9 @@ impl ServiceEntry { .iter() .filter(|r| { request_path == r.path - || request_path.starts_with(&r.path) + || (request_path.starts_with(&r.path) && (r.path.ends_with('/') - || request_path.as_bytes().get(r.path.len()) == Some(&b'/')) + || request_path.as_bytes().get(r.path.len()) == Some(&b'/'))) }) .max_by_key(|r| r.path.len());