use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct PhpConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_document_root")]
pub document_root: String,
pub workers: Option<usize>,
#[serde(default)]
pub ini: std::collections::HashMap<String, String>,
#[serde(default = "default_extensions")]
pub extensions: Vec<String>,
#[serde(default = "default_index")]
pub index: String,
#[serde(default = "default_max_body")]
pub max_body_bytes: usize,
#[serde(default = "default_max_execution_time")]
pub max_execution_time: u32,
#[serde(default = "default_true_val")]
pub cache_responses: bool,
#[serde(default = "default_max_requests")]
pub max_requests: u64,
pub worker_script: Option<String>,
}
impl Default for PhpConfig {
fn default() -> Self {
Self {
enabled: false,
document_root: default_document_root(),
workers: None,
ini: std::collections::HashMap::new(),
extensions: default_extensions(),
index: default_index(),
max_body_bytes: default_max_body(),
max_execution_time: default_max_execution_time(),
max_requests: default_max_requests(),
worker_script: None,
cache_responses: true,
}
}
}
fn default_true_val() -> bool {
true
}
impl PhpConfig {
pub fn effective_workers(&self) -> usize {
let requested = self.workers.unwrap_or_else(|| {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
});
#[cfg(not(php_zts))]
{
if requested > 1 {
tracing::warn!(
requested = requested,
"PHP is NTS (non-thread-safe) — clamping to 1 worker. \
Rebuild PHP with --enable-zts for multi-threaded mode."
);
}
#[allow(clippy::needless_return)]
return 1;
}
#[cfg(php_zts)]
requested
}
pub fn ini_entries_string(&self) -> String {
let mut entries = String::new();
let defaults = [
("max_execution_time", self.max_execution_time.to_string()),
("zend.max_allowed_stack_size", "-1".into()),
("opcache.enable", "1".into()),
("opcache.enable_cli", "1".into()),
("opcache.validate_timestamps", "0".into()),
("opcache.memory_consumption", "128".into()),
("opcache.max_accelerated_files", "10000".into()),
("opcache.interned_strings_buffer", "16".into()),
("realpath_cache_size", "4096K".into()),
("realpath_cache_ttl", "600".into()),
("open_basedir", format!("{}:/tmp", self.document_root)),
("enable_dl", "0".into()),
("disable_functions", [
"exec", "system", "passthru", "shell_exec",
"proc_open", "popen", "proc_close", "proc_terminate",
"proc_get_status", "proc_nice",
"pcntl_exec", "pcntl_fork", "pcntl_signal", "pcntl_waitpid",
"pcntl_wexitstatus", "pcntl_alarm",
"dl",
"putenv", "apache_setenv",
"show_source", "phpinfo",
].join(",").into()),
("expose_php", "Off".into()),
("display_errors", "Off".into()),
("log_errors", "On".into()),
];
for (key, value) in &defaults {
if !self.ini.contains_key(*key) {
if key.contains('\n')
|| key.contains('\r')
|| key.contains('\0')
|| value.contains('\n')
|| value.contains('\r')
|| value.contains('\0')
{
eprintln!(
"[SECURITY] Skipping INI entry with unsafe characters: {}",
key
);
continue;
}
entries.push_str(&format!("{}={}\n", key, value));
}
}
const SECURITY_CRITICAL_KEYS: &[&str] = &[
"disable_functions",
"open_basedir",
"enable_dl",
"allow_url_include",
"allow_url_fopen",
];
for (key, value) in &self.ini {
if key.contains('\n')
|| key.contains('\r')
|| key.contains('\0')
|| value.contains('\n')
|| value.contains('\r')
|| value.contains('\0')
{
eprintln!(
"[SECURITY] Skipping INI entry with unsafe characters: {}",
key
);
continue;
}
if SECURITY_CRITICAL_KEYS.iter().any(|&k| k == key.as_str()) {
eprintln!(
"[SECURITY WARNING] PHP INI override for '{}' — this weakens the \
default sandbox. Ensure this is intentional. Value: '{}'",
key, value
);
tracing::warn!(
key = key.as_str(),
value = value.as_str(),
"PHP security-critical INI override — default sandbox weakened"
);
}
entries.push_str(&format!("{}={}\n", key, value));
}
entries
}
pub fn is_worker_mode(&self) -> bool {
if self.worker_script.is_none() {
return false;
}
#[cfg(php_zts)]
{
true
}
#[cfg(not(php_zts))]
{
tracing::warn!(
"Worker mode requires ZTS PHP (--enable-zts). \
Falling back to classic mode."
);
false
}
}
pub fn is_php_request(&self, path: &str) -> bool {
for ext in &self.extensions {
if path.ends_with(ext.as_str()) {
return true;
}
}
if path.ends_with('/') || !path.contains('.') {
return true; }
false
}
pub fn resolve_script(&self, request_path: &str) -> Option<String> {
let doc_root = std::path::Path::new(&self.document_root);
let relative = request_path.trim_start_matches('/');
if relative.contains("..") || relative.contains('\0') {
return None;
}
let candidate = doc_root.join(relative);
if candidate.is_file() {
let canon_root = std::fs::canonicalize(doc_root).ok()?;
let canon_file = std::fs::canonicalize(&candidate).ok()?;
if !canon_file.starts_with(&canon_root) {
tracing::warn!(
path = %request_path,
resolved = %canon_file.display(),
root = %canon_root.display(),
"Path traversal blocked (symlink escape)"
);
return None;
}
return canon_file.to_str().map(|s| s.to_string());
}
let index_candidate = doc_root.join(relative).join(&self.index);
if index_candidate.is_file() {
return index_candidate.to_str().map(|s| s.to_string());
}
let router = doc_root.join(&self.index);
if router.is_file() {
return router.to_str().map(|s| s.to_string());
}
None
}
}
fn default_document_root() -> String {
"./public".into()
}
fn default_extensions() -> Vec<String> {
vec![".php".into()]
}
fn default_index() -> String {
"index.php".into()
}
fn default_max_body() -> usize {
8 * 1024 * 1024
}
fn default_max_execution_time() -> u32 {
30
}
fn default_max_requests() -> u64 {
10_000
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config() {
let config = PhpConfig::default();
assert!(!config.enabled);
assert_eq!(config.document_root, "./public");
assert_eq!(config.extensions, vec![".php"]);
assert_eq!(config.index, "index.php");
assert_eq!(config.max_body_bytes, 8 * 1024 * 1024);
assert_eq!(config.max_execution_time, 30);
assert_eq!(config.max_requests, 10_000);
assert!(config.workers.is_none());
assert!(config.ini.is_empty());
}
#[test]
fn ini_entries_string() {
let mut config = PhpConfig::default();
config.ini.insert("memory_limit".into(), "256M".into());
config.ini.insert("display_errors".into(), "Off".into());
let ini = config.ini_entries_string();
assert!(ini.contains("max_execution_time=30"));
assert!(ini.contains("memory_limit=256M"));
assert!(ini.contains("display_errors=Off"));
}
#[test]
fn ini_entries_includes_defaults_when_no_overrides() {
let config = PhpConfig::default();
let ini = config.ini_entries_string();
assert!(ini.contains("max_execution_time=30"));
assert!(ini.contains("opcache.enable=1"));
assert!(ini.contains("opcache.validate_timestamps=0"));
assert!(ini.contains("realpath_cache_size=4096K"));
assert!(!ini.contains("opcache.jit="));
}
#[test]
fn ini_entries_custom_execution_time() {
let mut config = PhpConfig::default();
config.max_execution_time = 120;
let ini = config.ini_entries_string();
assert!(ini.starts_with("max_execution_time=120\n"));
}
#[test]
fn is_php_request_extensions() {
let config = PhpConfig::default();
assert!(config.is_php_request("/index.php"));
assert!(config.is_php_request("/api/users.php"));
assert!(config.is_php_request("/")); assert!(config.is_php_request("/api/users")); assert!(!config.is_php_request("/style.css"));
assert!(!config.is_php_request("/script.js"));
assert!(!config.is_php_request("/image.png"));
}
#[test]
fn is_php_request_custom_extensions() {
let mut config = PhpConfig::default();
config.extensions = vec![".php".into(), ".phtml".into(), ".php7".into()];
assert!(config.is_php_request("/template.phtml"));
assert!(config.is_php_request("/legacy.php7"));
assert!(config.is_php_request("/index.php"));
assert!(!config.is_php_request("/style.css"));
}
#[test]
fn is_php_request_trailing_slash() {
let config = PhpConfig::default();
assert!(config.is_php_request("/admin/"));
assert!(config.is_php_request("/"));
assert!(config.is_php_request("/api/v2/"));
}
#[test]
fn is_php_request_no_extension_paths() {
let config = PhpConfig::default();
assert!(config.is_php_request("/users"));
assert!(config.is_php_request("/api/v2/products"));
assert!(config.is_php_request("/dashboard"));
}
#[test]
fn is_php_request_static_files_rejected() {
let config = PhpConfig::default();
assert!(!config.is_php_request("/favicon.ico"));
assert!(!config.is_php_request("/robots.txt"));
assert!(!config.is_php_request("/sitemap.xml"));
assert!(!config.is_php_request("/assets/app.js"));
assert!(!config.is_php_request("/css/main.css"));
assert!(!config.is_php_request("/images/logo.png"));
assert!(!config.is_php_request("/fonts/inter.woff2"));
}
#[test]
fn resolve_script_direct_file() {
let tmp = std::env::temp_dir().join("bext-php-test-resolve");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("info.php"), "<?php phpinfo();").unwrap();
let mut config = PhpConfig::default();
config.document_root = tmp.to_str().unwrap().to_string();
let resolved = config.resolve_script("/info.php");
assert!(resolved.is_some());
assert!(resolved.unwrap().ends_with("info.php"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn resolve_script_index_file() {
let tmp = std::env::temp_dir().join("bext-php-test-index");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("index.php"), "<?php echo 'home';").unwrap();
let mut config = PhpConfig::default();
config.document_root = tmp.to_str().unwrap().to_string();
let resolved = config.resolve_script("/");
assert!(resolved.is_some());
assert!(resolved.unwrap().ends_with("index.php"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn resolve_script_front_controller() {
let tmp = std::env::temp_dir().join("bext-php-test-router");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("index.php"), "<?php /* router */").unwrap();
let mut config = PhpConfig::default();
config.document_root = tmp.to_str().unwrap().to_string();
let resolved = config.resolve_script("/api/users/42");
assert!(resolved.is_some());
assert!(resolved.unwrap().ends_with("index.php"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn resolve_script_subdirectory() {
let tmp = std::env::temp_dir().join("bext-php-test-subdir");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join("admin")).unwrap();
std::fs::write(tmp.join("admin/dashboard.php"), "<?php").unwrap();
let mut config = PhpConfig::default();
config.document_root = tmp.to_str().unwrap().to_string();
let resolved = config.resolve_script("/admin/dashboard.php");
assert!(resolved.is_some());
assert!(resolved.unwrap().contains("admin/dashboard.php"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn resolve_script_missing_doc_root() {
let config = PhpConfig {
document_root: "/nonexistent/path/xyz".into(),
..PhpConfig::default()
};
assert!(config.resolve_script("/index.php").is_none());
}
#[test]
fn resolve_script_path_traversal_blocked() {
let config = PhpConfig::default();
assert!(config.resolve_script("/../../../etc/passwd").is_none());
assert!(config
.resolve_script("/admin/../../../etc/shadow")
.is_none());
assert!(config.resolve_script("/..").is_none());
}
#[test]
fn resolve_script_custom_index() {
let tmp = std::env::temp_dir().join("bext-php-test-custom-idx");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("app.php"), "<?php").unwrap();
let config = PhpConfig {
document_root: tmp.to_str().unwrap().to_string(),
index: "app.php".into(),
..PhpConfig::default()
};
let resolved = config.resolve_script("/");
assert!(resolved.is_some());
assert!(resolved.unwrap().ends_with("app.php"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn effective_workers_default() {
let config = PhpConfig::default();
assert!(config.effective_workers() > 0);
}
#[test]
fn effective_workers_override() {
let mut config = PhpConfig::default();
config.workers = Some(8);
#[cfg(php_zts)]
assert_eq!(config.effective_workers(), 8);
#[cfg(not(php_zts))]
assert_eq!(config.effective_workers(), 1);
}
#[test]
fn toml_round_trip() {
let toml_str = r#"
enabled = true
document_root = "/var/www/html"
workers = 4
index = "app.php"
max_execution_time = 60
max_requests = 5000
extensions = [".php", ".phtml"]
[ini]
memory_limit = "512M"
"opcache.enable" = "1"
"opcache.jit" = "1255"
"#;
let config: PhpConfig = toml::from_str(toml_str).unwrap();
assert!(config.enabled);
assert_eq!(config.document_root, "/var/www/html");
assert_eq!(config.workers, Some(4));
assert_eq!(config.index, "app.php");
assert_eq!(config.max_execution_time, 60);
assert_eq!(config.max_requests, 5000);
assert_eq!(config.ini.get("memory_limit").unwrap(), "512M");
assert_eq!(config.ini.get("opcache.enable").unwrap(), "1");
assert_eq!(config.extensions, vec![".php", ".phtml"]);
}
#[test]
fn toml_minimal() {
let toml_str = r#"enabled = true"#;
let config: PhpConfig = toml::from_str(toml_str).unwrap();
assert!(config.enabled);
assert_eq!(config.document_root, "./public");
assert_eq!(config.extensions, vec![".php"]);
}
#[test]
fn toml_empty() {
let config: PhpConfig = toml::from_str("").unwrap();
assert!(!config.enabled);
}
#[test]
fn toml_max_body_override() {
let toml_str = r#"max_body_bytes = 1048576"#;
let config: PhpConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.max_body_bytes, 1_048_576); }
#[test]
fn toml_nested_in_server_config() {
let toml_str = r#"
[php]
enabled = true
document_root = "/srv/app/public"
workers = 2
[php.ini]
"session.save_handler" = "files"
"#;
#[derive(serde::Deserialize)]
struct Wrapper {
php: PhpConfig,
}
let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
assert!(wrapper.php.enabled);
assert_eq!(wrapper.php.document_root, "/srv/app/public");
assert_eq!(wrapper.php.workers, Some(2));
assert_eq!(
wrapper.php.ini.get("session.save_handler").unwrap(),
"files"
);
}
#[test]
fn is_worker_mode_without_script() {
let config = PhpConfig::default();
assert!(!config.is_worker_mode());
}
#[test]
fn is_worker_mode_with_script() {
let config = PhpConfig {
worker_script: Some("./worker.php".into()),
..PhpConfig::default()
};
#[cfg(php_zts)]
assert!(config.is_worker_mode());
#[cfg(not(php_zts))]
assert!(!config.is_worker_mode());
}
#[test]
fn cache_responses_default_true() {
let config = PhpConfig::default();
assert!(config.cache_responses);
}
#[test]
fn cache_responses_disable() {
let toml_str = r#"cache_responses = false"#;
let config: PhpConfig = toml::from_str(toml_str).unwrap();
assert!(!config.cache_responses);
}
#[test]
fn toml_worker_script() {
let toml_str = r#"
enabled = true
worker_script = "./bootstrap/worker.php"
workers = 4
"#;
let config: PhpConfig = toml::from_str(toml_str).unwrap();
assert_eq!(
config.worker_script.as_deref(),
Some("./bootstrap/worker.php")
);
assert_eq!(config.workers, Some(4));
}
#[test]
fn toml_full_with_all_fields() {
let toml_str = r#"
enabled = true
document_root = "/var/www"
workers = 8
max_execution_time = 120
max_requests = 50000
max_body_bytes = 16777216
cache_responses = false
worker_script = "/opt/app/worker.php"
extensions = [".php", ".phtml"]
index = "app.php"
[ini]
memory_limit = "1G"
"#;
let config: PhpConfig = toml::from_str(toml_str).unwrap();
assert!(config.enabled);
assert_eq!(config.document_root, "/var/www");
assert_eq!(config.workers, Some(8));
assert_eq!(config.max_execution_time, 120);
assert_eq!(config.max_requests, 50000);
assert_eq!(config.max_body_bytes, 16_777_216);
assert!(!config.cache_responses);
assert_eq!(config.worker_script.as_deref(), Some("/opt/app/worker.php"));
assert_eq!(config.extensions, vec![".php", ".phtml"]);
assert_eq!(config.index, "app.php");
assert_eq!(config.ini.get("memory_limit").unwrap(), "1G");
}
#[test]
fn ini_user_overrides_default() {
let mut config = PhpConfig::default();
config.ini.insert("opcache.enable".into(), "0".into());
let ini = config.ini_entries_string();
assert!(ini.contains("opcache.enable=0"));
let count = ini.matches("opcache.enable=").count();
assert_eq!(count, 1);
}
#[test]
fn is_php_request_empty_path() {
let config = PhpConfig::default();
assert!(config.is_php_request(""));
}
#[test]
fn is_php_request_double_extension() {
let config = PhpConfig::default();
assert!(config.is_php_request("/file.backup.php"));
assert!(!config.is_php_request("/file.php.bak"));
}
#[test]
fn is_php_request_with_query_component() {
let config = PhpConfig::default();
assert!(config.is_php_request("/index.php"));
assert!(!config.is_php_request("/index.php?foo=bar"));
}
#[test]
fn resolve_script_empty_path() {
let tmp = std::env::temp_dir().join("bext-php-test-empty");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("index.php"), "<?php").unwrap();
let config = PhpConfig {
document_root: tmp.to_str().unwrap().to_string(),
..PhpConfig::default()
};
let resolved = config.resolve_script("");
assert!(resolved.is_some());
assert!(resolved.unwrap().ends_with("index.php"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn resolve_script_dot_dot_in_component() {
let config = PhpConfig::default();
assert!(config.resolve_script("/foo/../bar.php").is_none());
assert!(config.resolve_script("/../index.php").is_none());
assert!(config.resolve_script("/a/b/../../c").is_none());
}
#[test]
fn resolve_script_null_byte_blocked() {
let config = PhpConfig::default();
assert!(config.resolve_script("/index.php\0.jpg").is_none());
assert!(config.resolve_script("/\0/etc/passwd").is_none());
}
#[test]
fn resolve_script_symlink_escape_blocked() {
let tmp = std::env::temp_dir().join("bext-php-test-symlink");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("legit.php"), "<?php").unwrap();
#[cfg(unix)]
{
let _ = std::os::unix::fs::symlink("/etc/hostname", tmp.join("escape.php"));
let config = PhpConfig {
document_root: tmp.to_str().unwrap().to_string(),
..PhpConfig::default()
};
assert!(config.resolve_script("/legit.php").is_some());
assert!(config.resolve_script("/escape.php").is_none());
}
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn resolve_script_encoded_traversal_blocked() {
let config = PhpConfig::default();
assert!(config.resolve_script("/..%2f..%2fetc/passwd").is_none());
assert!(config.resolve_script("/....//....//etc/passwd").is_none());
}
#[test]
fn is_php_request_bext_internal_skipped() {
let config = PhpConfig::default();
assert!(config.is_php_request("/__bext/jsc-render"));
assert!(config.is_php_request("/__bext/php-call"));
}
#[test]
fn ini_no_injection() {
let mut config = PhpConfig::default();
config.ini.insert("safe_key".into(), "safe_value".into());
config
.ini
.insert("inject\nmalicious".into(), "value".into());
let ini = config.ini_entries_string();
assert!(ini.contains("safe_key=safe_value"));
}
}