WEB: Base Path: security + reload coverage fixes&tests

This commit is contained in:
Alexey
2026-09-26 14:25:24 +03:00
parent 5feded2919
commit 38eabc50e3
25 changed files with 1294 additions and 56 deletions
+3
View File
@@ -62,5 +62,8 @@ use reporting::log_changes;
#[cfg(test)]
use watcher::{ReloadState, reload_config};
#[cfg(test)]
#[path = "hot_reload/base_path_tests.rs"]
mod base_path_tests;
#[cfg(test)]
mod tests;
+81
View File
@@ -0,0 +1,81 @@
use base64::Engine as _;
use super::*;
fn write_base_path_config(path: &Path, base_path: &str) {
let base_path = if base_path.is_empty() {
String::new()
} else {
format!("base_path = \"{base_path}\"\n")
};
let config = format!(
r#"
[access.users]
alice = "000102030405060708090a0b0c0d0e0f"
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
proxy_protocol = false
web_client_ip_source = "x_forwarded_for"
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
[web]
enabled = true
[[web.vhosts]]
host = "proxy.example.com"
{base_path}public_addr = "203.0.113.10:443"
[web.vhosts.decoy]
mode = "http_upstream"
upstream = "http://127.0.0.1:18081"
[[web.vhosts.profiles]]
user = "alice"
secret_mode = "plain"
"#,
);
std::fs::write(path, config).unwrap();
}
#[test]
fn reload_rejects_invalid_base_then_publishes_route_identity_together() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
write_base_path_config(&path, "");
let initial = Arc::new(ProxyConfig::load(&path).unwrap());
let initial_hash = ProxyConfig::load_with_metadata(&path)
.unwrap()
.rendered_hash;
let initial_capability = initial.web.runtime.as_ref().unwrap().capabilities[0];
let (config_tx, _config_rx) = watch::channel(Arc::clone(&initial));
let (log_tx, _log_rx) = watch::channel(initial.general.log_level.clone());
let mut reload_state = ReloadState::new(Some(initial_hash));
write_base_path_config(&path, "/invalid");
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state);
let unchanged = config_tx.borrow().clone();
assert!(Arc::ptr_eq(&unchanged, &initial));
assert_eq!(unchanged.web.vhosts[0].base_path, "");
assert_eq!(
unchanged.web.runtime.as_ref().unwrap().capabilities[0],
initial_capability
);
write_base_path_config(&path, "dobry-cola-super-app");
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state);
let applied = config_tx.borrow().clone();
let runtime = applied.web.runtime.as_ref().unwrap();
let vhost = &runtime.vhosts["proxy.example.com"];
assert_eq!(applied.web.vhosts[0].base_path, "dobry-cola-super-app");
assert_eq!(vhost.base, "/dobry-cola-super-app/");
assert_eq!(vhost.capabilities[0], vhost.profiles[0].capability);
assert_eq!(runtime.capabilities.as_ref(), vhost.capabilities.as_ref());
assert!(!runtime.capabilities.contains(&initial_capability));
assert_eq!(
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(vhost.capabilities[0]),
"hHz99Xs93EN1j91G9gpNepXwGNNt5YdAFkEVk_LlqdQ"
);
}
+17
View File
@@ -38,6 +38,23 @@ fn capability_matches_reference_vectors() {
}
}
#[test]
fn capability_binds_the_exact_host_and_base_path_identity() {
let secret = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap();
let root = derive_web_capability(&secret, b"proxy.example.com", b"").unwrap();
let mixed = derive_web_capability(&secret, b"proxy.example.com", b"MixedCase/path").unwrap();
let lower = derive_web_capability(&secret, b"proxy.example.com", b"mixedcase/path").unwrap();
let other_path =
derive_web_capability(&secret, b"proxy.example.com", b"MixedCase/other").unwrap();
let other_host =
derive_web_capability(&secret, b"other.example.com", b"MixedCase/path").unwrap();
let identities = [root, mixed, lower, other_path, other_host]
.into_iter()
.collect::<std::collections::HashSet<_>>();
assert_eq!(identities.len(), 5);
}
#[cfg(unix)]
#[test]
fn static_snapshot_remains_anchored_after_root_path_replacement() {
+1 -7
View File
@@ -365,13 +365,7 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
"decoy_header_secs",
];
const WEB_VHOST_CONFIG_KEYS: &[&str] = &[
"host",
"base_path",
"public_addr",
"decoy",
"profiles",
];
const WEB_VHOST_CONFIG_KEYS: &[&str] = &["host", "base_path", "public_addr", "decoy", "profiles"];
const WEB_DECOY_CONFIG_KEYS: &[&str] = &["mode", "upstream", "directory", "index"];
const WEB_PROFILE_CONFIG_KEYS: &[&str] = &[
"user",
+3 -1
View File
@@ -85,7 +85,9 @@ fn validate_web_base_path(value: &str, field: &str) -> Result<()> {
&& !value.ends_with('/')
&& value.split('/').all(|segment| {
let mut bytes = segment.bytes();
bytes.next().is_some_and(|byte| byte.is_ascii_alphanumeric())
bytes
.next()
.is_some_and(|byte| byte.is_ascii_alphanumeric())
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
});
if value.is_empty() || valid {
@@ -1,5 +1,8 @@
use super::*;
#[path = "web_tests/base_path_tests.rs"]
mod base_path_tests;
const WEB_CONFIG: &str = r#"
[access.users]
alice = "000102030405060708090a0b0c0d0e0f"
@@ -64,7 +67,7 @@ fn web_config_builds_canonical_runtime_snapshot() {
#[test]
fn web_base_path_is_canonical_and_precomputed() {
let maximum = "a".repeat(128);
for base_path in ["Dobry-Cola/super_app", maximum.as_str()] {
for base_path in ["a", "a/b/c9_x-y", "Dobry-Cola/super_app", maximum.as_str()] {
let configured = WEB_CONFIG.replace(
"host = \"Proxy.Example.COM\"",
&format!("host = \"Proxy.Example.COM\"\nbase_path = \"{base_path}\""),
@@ -99,6 +102,11 @@ fn web_base_path_rejects_noncanonical_forms() {
"relay//nested",
"-relay",
"_relay",
"a/-lead",
"a/_lead",
"dot.ted",
"..",
"/",
"relay/.hidden",
"relay/%2fhidden",
"relay path",
@@ -0,0 +1,33 @@
use super::*;
#[test]
fn web_runtime_collects_every_vhost_capability() {
let configured = format!(
"{WEB_CONFIG}\n{}",
r#"
[[web.vhosts]]
host = "Other.Example.COM"
base_path = "other/path"
public_addr = "203.0.113.11:443"
[web.vhosts.decoy]
mode = "http_upstream"
upstream = "http://127.0.0.1:18082"
[[web.vhosts.profiles]]
user = "alice"
secret_mode = "dd"
"#
);
let config = load_config_from_temp_toml(&configured);
let runtime = config.web.runtime.as_ref().unwrap();
let first = &runtime.vhosts["proxy.example.com"];
let second = &runtime.vhosts["other.example.com"];
assert_eq!(runtime.capabilities.len(), 2);
assert_eq!(first.capabilities[0], first.profiles[0].capability);
assert_eq!(second.capabilities[0], second.profiles[0].capability);
assert_ne!(first.capabilities[0], second.capabilities[0]);
assert!(runtime.capabilities.contains(&first.capabilities[0]));
assert!(runtime.capabilities.contains(&second.capabilities[0]));
}