config visibility, PR review fixes, XSS hardening

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 <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-23 12:24:21 +02:00
parent 10f1602803
commit c6b35045d8
7 changed files with 126 additions and 27 deletions

View File

@@ -49,6 +49,7 @@ pub fn router(ctx: Arc<ServerCtx>) -> 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<Arc<ServerCtx>>) -> Json<StatsResponse> {
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<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
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),

View File

@@ -316,13 +316,62 @@ mod tests {
}
}
pub fn load_config(path: &str) -> Result<Config> {
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<ConfigLoad> {
// Try the given path first, then well-known locations (for service mode where cwd is /)
let candidates: Vec<std::path::PathBuf> = {
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<String, HashMap<QueryType, Vec<DnsRecord>>>;

View File

@@ -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(

View File

@@ -85,11 +85,10 @@ pub fn detect_lan_ip() -> Option<Ipv4Addr> {
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())
}

View File

@@ -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!(

View File

@@ -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());