commit b38936bbf89f342bc53320ba9f16f0854cdfbc63 from: Murilo Ijanc date: Tue Apr 7 00:33:29 2026 UTC Add CGI search, security hardening, and pledge/unveil support Add built-in CGI mode with INDEX.txt search, url_decode, and result limiting. Harden against XSS, path traversal, NUL injection, field injection, and javascript: URIs. Use pledge(2) and unveil(2) on OpenBSD to restrict filesystem access and syscalls. commit - d04a1dedc45b2aabb2ec9bae57604939a196b9be commit + b38936bbf89f342bc53320ba9f16f0854cdfbc63 blob - a636ec040dd42996f384f3d9894e6b126d0f2f53 blob + 146194c4142774a611adc3ce8fbff389c45ad55b --- wp.rs +++ wp.rs @@ -21,6 +21,43 @@ use std::{ path::{Path, PathBuf}, }; +#[cfg(target_os = "openbsd")] +mod sandbox { + use std::ffi::CString; + use std::os::raw::c_char; + use std::path::Path; + + extern "C" { + fn pledge(promises: *const c_char, execpromises: *const c_char) -> i32; + fn unveil(path: *const c_char, permissions: *const c_char) -> i32; + } + + pub fn do_unveil(path: &Path, perms: &str) { + let p = CString::new(path.to_str().expect("unveil: invalid path")) + .expect("unveil: nul in path"); + let f = CString::new(perms).expect("unveil: nul in perms"); + if unsafe { unveil(p.as_ptr(), f.as_ptr()) } == -1 { + let e = std::io::Error::last_os_error(); + super::err(&format!("unveil {}: {}", path.display(), e)); + } + } + + pub fn lock_unveil() { + if unsafe { unveil(std::ptr::null(), std::ptr::null()) } == -1 { + let e = std::io::Error::last_os_error(); + super::err(&format!("unveil lock: {}", e)); + } + } + + pub fn do_pledge(promises: &str) { + let p = CString::new(promises).expect("pledge: nul in promises"); + if unsafe { pledge(p.as_ptr(), std::ptr::null()) } == -1 { + let e = std::io::Error::last_os_error(); + super::err(&format!("pledge: {}", e)); + } + } +} + const VERSION: &str = "0.1.0"; const NAME: &str = "wp"; const FAVICON: &[u8] = include_bytes!("assets/favicon.ico"); @@ -223,6 +260,9 @@ fn resolve_modules( let modules = vars.get("MODULES").cloned().unwrap_or_default(); for module in modules.split_whitespace() { + if module.contains("..") { + continue; + } load_module(module, portsdir, cache); if let Some(modvars) = cache.get(module) { for (k, v) in modvars { @@ -301,6 +341,14 @@ fn parse_makefile(path: &Path) -> Option bool { + !s.is_empty() + && !s.contains('/') + && !s.contains('\0') + && s != "." + && s != ".." +} + fn port_from_makefile( category: &str, portname: &str, @@ -308,6 +356,9 @@ fn port_from_makefile( portsdir: &Path, modcache: &mut ModCache, ) -> Option { + if !safe_name(category) || !safe_name(portname) { + return None; + } let mf = portdir.join("Makefile"); let mut vars = parse_makefile(&mf)?; @@ -378,6 +429,9 @@ fn port_from_vars( portsdir: Option<&Path>, ) -> Option { let (category, name) = path.split_once('/')?; + if !safe_name(category) || !safe_name(name) { + return None; + } let comment = vars.get("COMMENT").filter(|s| !s.is_empty())?; let descr = if let Some(pd) = portsdir { @@ -418,16 +472,17 @@ fn load_dump(path: &Path, portsdir: Option<&Path>) -> for line in text.lines() { // format: category/port.VARIABLE=value - let Some(dot) = line.find('.') else { + // find the first '=' then look backwards for the '.' + let Some(eq_pos) = line.find('=') else { continue; }; + let prefix = &line[..eq_pos]; + let Some(dot) = prefix.rfind('.') else { + continue; + }; let portpath = &line[..dot]; - let rest = &line[dot + 1..]; - let Some(eq) = rest.find('=') else { - continue; - }; - let var = &rest[..eq]; - let val = &rest[eq + 1..]; + let var = &line[dot + 1..eq_pos]; + let val = &line[eq_pos + 1..]; by_port .entry(portpath.to_string()) .or_default() @@ -537,7 +592,7 @@ fn port_meta(p: &Port, depth: usize) -> String { } fn html_escape(s: &str) -> String { - if !s.contains(&['&', '<', '>', '"'][..]) { + if !s.contains(&['&', '<', '>', '"', '\''][..]) { return s.to_string(); } let mut out = String::with_capacity(s.len() + 8); @@ -547,6 +602,7 @@ fn html_escape(s: &str) -> String { '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), + '\'' => out.push_str("'"), _ => out.push(c), } } @@ -741,7 +797,10 @@ fn write_port_pages(ports: &[Port], outdir: &Path, ft: )); } - if !p.homepage.is_empty() { + if !p.homepage.is_empty() + && (p.homepage.starts_with("https://") + || p.homepage.starts_with("http://")) + { html.push_str(&format!( "

Homepage: {}

\n", html_escape(&p.homepage), @@ -887,6 +946,10 @@ fn write_alpha(ports: &[Port], outdir: &Path, ft: &str } } +fn sanitize_field(s: &str) -> String { + s.replace(['\t', '\n'], " ").replace('\r', "") +} + fn write_index_txt(ports: &[Port], outdir: &Path) { let mut out = String::new(); for p in ports { @@ -895,11 +958,11 @@ fn write_index_txt(ports: &[Port], outdir: &Path) { out.push('/'); out.push_str(&p.name); out.push('\t'); - out.push_str(&p.pkgname); + out.push_str(&sanitize_field(&p.pkgname)); out.push('\t'); - out.push_str(&p.comment); + out.push_str(&sanitize_field(&p.comment)); out.push('\t'); - out.push_str(&p.maintainer); + out.push_str(&sanitize_field(&p.maintainer)); out.push('\n'); } write_file(&outdir.join("INDEX.txt"), &out); @@ -915,7 +978,9 @@ fn url_decode(s: &str) -> String { let hex = [hi, lo]; if let Ok(s) = std::str::from_utf8(&hex) { if let Ok(n) = u8::from_str_radix(s, 16) { - out.push(n); + if n != 0 { + out.push(n); + } continue; } } @@ -930,12 +995,14 @@ fn url_decode(s: &str) -> String { } fn cgi_search(index_path: &Path) { - let qs = std::env::var("QUERY_STRING").unwrap_or_default(); - let query = qs + let mut qs = std::env::var("QUERY_STRING").unwrap_or_default(); + qs.truncate(1024); + let mut query = qs .split('&') .find_map(|p| p.strip_prefix("q=").map(url_decode)) .unwrap_or_default() .to_lowercase(); + query.truncate(128); print!("Content-Type: text/html\r\n\r\n"); @@ -987,6 +1054,9 @@ fn cgi_search(index_path: &Path) { None => continue, }; + if count >= 200 { + break; + } html.push_str(&format!( "\ {}{}{}\n", @@ -1000,6 +1070,9 @@ fn cgi_search(index_path: &Path) { } html.push_str("\n"); + if count >= 200 { + html.push_str("

Results limited to 200.

\n"); + } html.push_str(&format!( "

{} results for \"{}\"

\n", count, @@ -1034,6 +1107,12 @@ fn main() { // CGI mode: QUERY_STRING is set by the HTTP server if std::env::var("QUERY_STRING").is_ok() { let index = PathBuf::from("INDEX.txt"); + #[cfg(target_os = "openbsd")] + { + sandbox::do_unveil(&index, "r"); + sandbox::lock_unveil(); + sandbox::do_pledge("stdio rpath"); + } cgi_search(&index); return; } @@ -1072,6 +1151,21 @@ fn main() { i += 1; } + fs::create_dir_all(&outdir).unwrap_or_else(|e| { + errio(&outdir, &e); + }); + + #[cfg(target_os = "openbsd")] + { + sandbox::do_unveil(&portsdir, "r"); + if let Some(ref d) = dumpfile { + sandbox::do_unveil(d, "r"); + } + sandbox::do_unveil(&outdir, "rwc"); + sandbox::lock_unveil(); + sandbox::do_pledge("stdio rpath wpath cpath"); + } + let ports = if let Some(ref dump) = dumpfile { eprintln!("loading dump from {}", dump.display()); let pd = if portsdir.is_dir() {