WEB: Resolve opt-in decoy hostnames during config preparation #929

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-10-04 14:32:06 +03:00
parent d3de9865cf
commit bc1c130f38
30 changed files with 1251 additions and 168 deletions
+3
View File
@@ -1,5 +1,6 @@
use super::*;
/// Typed source together with its exact include graph and rendered input.
pub(super) type DecodedSourceGraph = (
ProxyConfig,
BTreeSet<PathBuf>,
@@ -7,6 +8,7 @@ pub(super) type DecodedSourceGraph = (
String,
);
/// Decodes source while retaining the metadata needed for atomic mutations.
pub(super) fn decode_source_graph(graph: ConfigSourceGraph) -> Result<DecodedSourceGraph> {
let ConfigSourceGraph {
source_contents,
@@ -17,6 +19,7 @@ pub(super) fn decode_source_graph(graph: ConfigSourceGraph) -> Result<DecodedSou
let parsed_toml: toml::Value =
toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?;
handle_unknown_config_keys(&parsed_toml)?;
ProxyConfig::validate_decoy_source_keys(&parsed_toml)?;
let general_table = parsed_toml
.get("general")
.and_then(|value| value.as_table());
+204
View File
@@ -0,0 +1,204 @@
use std::future::Future;
use std::time::Duration;
use super::*;
/// Rejects mode-specific keys even when unknown-key handling is permissive.
pub(super) fn validate_mode_keys(document: &toml::Value) -> Result<()> {
if let Some(vhosts) = document
.get("web")
.and_then(|web| web.get("vhosts"))
.and_then(toml::Value::as_array)
{
for (idx, vhost) in vhosts.iter().enumerate() {
if let Some(decoy) = vhost.get("decoy")
&& decoy.get("resolve").is_some()
&& decoy.get("mode").and_then(toml::Value::as_str) != Some("http_upstream")
{
return Err(ProxyError::Config(format!(
"web.vhosts[{idx}].decoy.resolve is only valid for mode=http_upstream"
)));
}
}
}
Ok(())
}
/// Parses one origin and preserves the existing literal-address containment policy.
pub(super) fn parse_origin(
idx: usize,
upstream: &str,
resolve: WebDecoyResolve,
) -> Result<url::Url> {
let parsed = url::Url::parse(upstream).map_err(|error| {
ProxyError::Config(format!(
"web.vhosts[{idx}].decoy.upstream is invalid: {error}"
))
})?;
if parsed.scheme() != "http"
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
|| parsed.path() != "/"
|| parsed.port() == Some(0)
{
return Err(ProxyError::Config(format!(
"web.vhosts[{idx}].decoy.upstream must be an http origin without credentials, path, query, or fragment"
)));
}
match parsed.host() {
Some(url::Host::Ipv4(ip)) => validate_address(idx, IpAddr::V4(ip))?,
Some(url::Host::Ipv6(ip)) => validate_address(idx, IpAddr::V6(ip))?,
Some(url::Host::Domain(_)) if resolve == WebDecoyResolve::Startup => {}
_ => {
return Err(ProxyError::Config(format!(
"web.vhosts[{idx}].decoy.upstream host must be a loopback or private IP literal unless resolve = \"startup\""
)));
}
}
Ok(parsed)
}
fn validate_address(idx: usize, ip: IpAddr) -> Result<()> {
let private = match ip {
IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(),
IpAddr::V6(ip) => ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local(),
};
if !private {
return Err(ProxyError::Config(format!(
"web.vhosts[{idx}].decoy.upstream must remain inside loopback or a private network"
)));
}
Ok(())
}
/// Returns all captured answers so non-selected addresses cannot hide a listener loop.
pub(super) fn addresses(
config: &ProxyConfig,
idx: usize,
parsed: &url::Url,
require_resolved: bool,
) -> Result<Vec<SocketAddr>> {
let port = parsed
.port_or_known_default()
.ok_or_else(|| ProxyError::Config("WEB decoy port cannot be resolved".to_string()))?;
let answers = match parsed.host() {
Some(url::Host::Ipv4(ip)) => vec![SocketAddr::new(IpAddr::V4(ip), port)],
Some(url::Host::Ipv6(ip)) => vec![SocketAddr::new(IpAddr::V6(ip), port)],
Some(url::Host::Domain(host)) => {
let key = (host.to_string(), port);
match config.web.decoy_dns.origins.get(&key) {
Some(answers) if !answers.is_empty() => answers.clone(),
_ if !require_resolved => return Ok(Vec::new()),
_ => {
return Err(ProxyError::Config(format!(
"web.vhosts[{idx}].decoy.upstream requires async preparation for resolve = \"startup\""
)));
}
}
}
_ => return Err(ProxyError::Config("WEB decoy host is missing".to_string())),
};
for addr in &answers {
validate_address(idx, addr.ip())?;
if addr.port() != port {
return Err(ProxyError::Config(
"WEB decoy DNS snapshot port mismatch".to_string(),
));
}
}
Ok(answers)
}
/// Captures fresh resolver evidence without publishing any partially prepared configuration.
pub(super) async fn prepare<F, Fut>(config: &mut ProxyConfig, mut lookup: F) -> Result<()>
where
F: FnMut(String, u16) -> Fut,
Fut: Future<Output = std::io::Result<Vec<SocketAddr>>>,
{
let mut snapshot = WebDecoyDnsSnapshot::default();
for (idx, vhost) in config.web.vhosts.iter().enumerate() {
let WebDecoyConfig::HttpUpstream { upstream, resolve } = &vhost.decoy else {
continue;
};
let parsed = parse_origin(idx, upstream, *resolve)?;
let Some(url::Host::Domain(host)) = parsed.host() else {
continue;
};
let port = parsed
.port_or_known_default()
.ok_or_else(|| ProxyError::Config("WEB decoy port cannot be resolved".to_string()))?;
let key = (host.to_string(), port);
if snapshot.origins.contains_key(&key) {
continue;
}
// Dropping this wait does not forcibly cancel a running OS resolver call.
let answers = tokio::time::timeout(
Duration::from_secs(config.web.timeouts.decoy_resolve_secs),
lookup(key.0.clone(), port),
)
.await
.map_err(|_| {
ProxyError::Config(format!(
"web.vhosts[{idx}].decoy.upstream DNS resolution timed out after {} seconds",
config.web.timeouts.decoy_resolve_secs
))
})?
.map_err(|error| {
ProxyError::Config(format!(
"web.vhosts[{idx}].decoy.upstream DNS resolution failed: {error}"
))
})?;
if answers.is_empty() {
return Err(ProxyError::Config(format!(
"web.vhosts[{idx}].decoy.upstream DNS resolution returned no addresses"
)));
}
for addr in &answers {
validate_address(idx, addr.ip())?;
}
snapshot.origins.insert(key, answers);
}
config.web.decoy_dns = Arc::new(snapshot);
validate_web::validate_decoy_listener_separation(config)
}
/// Builds the unchanged request-time tuple from immutable configuration evidence.
pub(super) fn build_upstream(
config: &ProxyConfig,
idx: usize,
upstream: &str,
resolve: WebDecoyResolve,
) -> Result<WebRuntimeDecoy> {
let parsed = parse_origin(idx, upstream, resolve)?;
let addr = addresses(config, idx, &parsed, true)?
.first()
.copied()
.ok_or_else(|| ProxyError::Config("WEB decoy DNS snapshot is empty".to_string()))?;
// URL authority keeps the configured hostname and brackets IPv6 literals.
let authority = parsed[url::Position::BeforeHost..url::Position::AfterPort].to_string();
Ok(WebRuntimeDecoy::HttpUpstream { addr, authority })
}
impl ProxyConfig {
/// Compares selected endpoints separately from source-only configuration equality.
pub(crate) fn web_decoy_endpoints_equal(&self, other: &Self) -> bool {
fn selected(config: &ProxyConfig) -> impl Iterator<Item = (&String, SocketAddr)> {
config
.web
.runtime
.iter()
.flat_map(|runtime| runtime.vhosts.iter())
.filter_map(|(host, vhost)| match vhost.decoy {
WebRuntimeDecoy::HttpUpstream { addr, .. } => Some((host, addr)),
WebRuntimeDecoy::StaticDirectory(_) => None,
})
}
selected(self).eq(selected(other))
}
}
#[cfg(test)]
mod tests;
+330
View File
@@ -0,0 +1,330 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
const SOURCE: &str = r#"
[access.users]
alice = "000102030405060708090a0b0c0d0e0f"
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
[web]
enabled = true
[[web.vhosts]]
host = "proxy.example.com"
public_addr = "203.0.113.10:443"
[web.vhosts.decoy]
mode = "http_upstream"
upstream = "http://Example.COM:18081"
resolve = "startup"
[[web.vhosts.profiles]]
user = "alice"
secret_mode = "dd"
"#;
fn source(text: &str) -> ParsedConfigSource {
pipeline::parse_source_graph(ConfigSourceGraph {
rendered: text.to_string(),
source_contents: BTreeMap::new(),
})
.unwrap()
}
async fn prepared(answers: &[&str]) -> LoadedConfig {
let mut parsed = source(SOURCE);
let answers: Vec<SocketAddr> = answers.iter().map(|addr| addr.parse().unwrap()).collect();
prepare(&mut parsed.config, |_, _| {
std::future::ready(Ok(answers.clone()))
})
.await
.unwrap();
pipeline::finish(parsed).unwrap()
}
fn endpoint(config: &ProxyConfig) -> (SocketAddr, &str) {
let runtime = config.web.runtime.as_ref().unwrap();
match &runtime.vhosts["proxy.example.com"].decoy {
WebRuntimeDecoy::HttpUpstream { addr, authority } => (*addr, authority),
_ => panic!("expected HTTP decoy"),
}
}
#[tokio::test]
async fn decoy_dns_snapshot_deduplicates_normalized_origins_and_preserves_order() {
let mut parsed = source(SOURCE);
let mut second = parsed.config.web.vhosts[0].clone();
second.host = "bsi.bund.de".to_string();
second.decoy = WebDecoyConfig::HttpUpstream {
upstream: "http://example.com:18081/".to_string(),
resolve: WebDecoyResolve::Startup,
};
parsed.config.web.vhosts.push(second);
let calls = AtomicUsize::new(0);
prepare(&mut parsed.config, |host, port| {
assert_eq!((host.as_str(), port), ("example.com", 18081));
calls.fetch_add(1, Ordering::SeqCst);
std::future::ready(Ok(vec![
"[fd00::2]:18081".parse().unwrap(),
"10.0.0.2:18081".parse().unwrap(),
]))
})
.await
.unwrap();
let mut loaded = pipeline::finish(parsed).unwrap();
for _ in 0..3 {
loaded.config.validate_effective_web().unwrap();
loaded.config.rebuild_runtime_web().unwrap();
}
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(
endpoint(&loaded.config),
("[fd00::2]:18081".parse().unwrap(), "example.com:18081")
);
assert_eq!(
loaded
.config
.web
.decoy_dns
.origins
.values()
.next()
.unwrap()
.len(),
2
);
assert!(
!serde_json::to_string(&loaded.config)
.unwrap()
.contains("fd00::2")
);
}
#[tokio::test]
async fn decoy_dns_rejects_every_public_answer_including_non_selected() {
for disallowed in [
"8.8.8.8:18081",
"[2001:4860:4860::8888]:18081",
"[::ffff:127.0.0.1]:18081",
] {
let mut parsed = source(SOURCE);
let error = prepare(&mut parsed.config, |_, _| {
std::future::ready(Ok(vec![
"127.0.0.2:18081".parse().unwrap(),
disallowed.parse().unwrap(),
]))
})
.await
.unwrap_err();
assert!(error.to_string().contains("must remain inside"));
assert!(parsed.config.web.runtime.is_none());
}
}
#[tokio::test]
async fn decoy_dns_rejects_non_selected_direct_and_wildcard_listener_loops() {
for listener in ["127.0.0.1", "0.0.0.0"] {
let mut parsed = source(&SOURCE.replace("Example.COM:18081", "example.com:18080"));
parsed.config.server.listeners[0].ip = listener.parse().unwrap();
let error = prepare(&mut parsed.config, |_, _| {
std::future::ready(Ok(vec![
"[fd00::2]:18080".parse().unwrap(),
"127.0.0.1:18080".parse().unwrap(),
]))
})
.await
.unwrap_err();
assert!(error.to_string().contains("overlaps WEB listener"));
}
}
#[tokio::test]
async fn decoy_dns_empty_answers_and_resolver_errors_fail_closed() {
let mut parsed = source(SOURCE);
let error = prepare(&mut parsed.config, |_, _| {
std::future::ready(Ok(Vec::new()))
})
.await
.unwrap_err();
assert!(error.to_string().contains("returned no addresses"));
let error = prepare(&mut parsed.config, |_, _| {
std::future::ready(Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"test resolver failure",
)))
})
.await
.unwrap_err();
assert!(error.to_string().contains("DNS resolution failed"));
}
#[tokio::test]
async fn decoy_dns_timeout_is_bounded() {
let mut parsed = source(SOURCE);
parsed.config.web.timeouts.decoy_resolve_secs = 1;
let error = tokio::time::timeout(
Duration::from_secs(3),
prepare(&mut parsed.config, |_, _| {
std::future::pending::<std::io::Result<Vec<SocketAddr>>>()
}),
)
.await
.unwrap()
.unwrap_err();
assert!(error.to_string().contains("timed out after 1 seconds"));
}
#[tokio::test]
async fn decoy_dns_literal_startup_never_looks_up_and_keeps_ipv6_authority() {
let mut parsed = source(&SOURCE.replace("Example.COM:18081", "[::1]:18081"));
prepare(&mut parsed.config, |_, _| {
std::future::ready(Err(std::io::Error::other("IP literals must not reach DNS")))
})
.await
.unwrap();
let loaded = pipeline::finish(parsed).unwrap();
assert_eq!(
endpoint(&loaded.config),
("[::1]:18081".parse().unwrap(), "[::1]:18081")
);
}
#[test]
fn decoy_dns_source_parse_is_not_a_prepared_runtime() {
let parsed = source(SOURCE);
assert!(parsed.config.web.runtime.is_none());
assert!(parsed.config.web.decoy_dns.origins.is_empty());
assert!(
pipeline::finish(parsed)
.unwrap_err()
.to_string()
.contains("requires async preparation")
);
}
#[tokio::test]
async fn decoy_dns_evidence_is_bound_to_origin_and_port() {
let loaded = prepared(&["10.0.0.2:18081"]).await;
for changed in ["http://telekom.com:18081", "http://example.com:18082"] {
let mut config = loaded.config.clone();
config.web.vhosts[0].decoy = WebDecoyConfig::HttpUpstream {
upstream: changed.to_string(),
resolve: WebDecoyResolve::Startup,
};
assert!(
config
.validate_effective_web()
.unwrap_err()
.to_string()
.contains("requires async preparation")
);
assert!(config.rebuild_runtime_web().is_err());
}
}
#[tokio::test]
async fn decoy_dns_reload_pins_old_generation_and_detects_dns_only_changes() {
let old = prepared(&["10.0.0.2:18081"]).await;
let new = prepared(&["10.0.0.3:18081"]).await;
assert_eq!(old.rendered_hash, new.rendered_hash);
assert_eq!(
serde_json::to_value(&old.config).unwrap(),
serde_json::to_value(&new.config).unwrap()
);
let resolved =
crate::maestro::runtime_build::resolve_reload_config(&old.config, &new.config).unwrap();
assert!(resolved.runtime_changed);
assert!(!old.config.web_decoy_endpoints_equal(&new.config));
assert_eq!(endpoint(&old.config).0, "10.0.0.2:18081".parse().unwrap());
let same = prepared(&["10.0.0.3:18081", "10.0.0.4:18081"]).await;
assert!(same.config.web_decoy_endpoints_equal(&new.config));
}
#[tokio::test]
async fn decoy_dns_effective_listener_overlay_rechecks_every_answer() {
let old = prepared(&["10.0.0.2:18081"]).await;
let mut parsed = source(SOURCE);
parsed.config.server.listeners[0].port = Some(18082);
parsed.config.web.vhosts[0].decoy = WebDecoyConfig::HttpUpstream {
upstream: "http://example.com:18080".to_string(),
resolve: WebDecoyResolve::Startup,
};
prepare(&mut parsed.config, |_, _| {
std::future::ready(Ok(vec![
"10.0.0.2:18080".parse().unwrap(),
"127.0.0.1:18080".parse().unwrap(),
]))
})
.await
.unwrap();
let desired = pipeline::finish(parsed).unwrap();
assert!(
crate::maestro::runtime_build::resolve_reload_config(&old.config, &desired.config)
.err()
.unwrap()
.contains("overlaps WEB listener")
);
}
#[tokio::test]
async fn decoy_dns_rejects_ipv6_direct_and_wildcard_listener_loops() {
for listener in ["::1", "::"] {
let mut parsed = source(&SOURCE.replace("Example.COM:18081", "example.com:18080"));
parsed.config.network.ipv6 = Some(true);
parsed.config.server.listeners[0].ip = listener.parse().unwrap();
let error = prepare(&mut parsed.config, |_, _| {
std::future::ready(Ok(vec![
"10.0.0.2:18080".parse().unwrap(),
"[::1]:18080".parse().unwrap(),
]))
})
.await
.unwrap_err();
assert!(error.to_string().contains("overlaps WEB listener"));
}
}
#[test]
fn decoy_dns_startup_does_not_relax_origin_url_rules() {
for url in [
"https://example.com",
"http://user@example.com",
"http://user:pass@example.com",
"http://example.com/path",
"http://example.com/?q=1",
"http://example.com/#fragment",
"http://example.com:0",
] {
assert!(
parse_origin(0, url, WebDecoyResolve::Startup).is_err(),
"accepted {url}"
);
}
}
#[tokio::test]
async fn decoy_dns_default_port_authority_and_evidence_port_fence() {
let mut parsed = source(&SOURCE.replace("Example.COM:18081", "example.com"));
prepare(&mut parsed.config, |_, port| {
assert_eq!(port, 80);
std::future::ready(Ok(vec!["127.0.0.2:80".parse().unwrap()]))
})
.await
.unwrap();
let loaded = pipeline::finish(parsed).unwrap();
assert_eq!(endpoint(&loaded.config).1, "example.com");
let mut config = loaded.config.clone();
Arc::make_mut(&mut config.web.decoy_dns).origins.insert(
("example.com".to_string(), 80),
vec!["127.0.0.2:81".parse().unwrap()],
);
assert!(
config
.validate_effective_web()
.unwrap_err()
.to_string()
.contains("port mismatch")
);
assert!(config.rebuild_runtime_web().is_err());
}
+1 -1
View File
@@ -1,5 +1,6 @@
use super::*;
/// Normalizes source policy and authentication data before external decoy preparation.
pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
// Normalize optional TLS fetch scope: whitespace-only values disable scoped routing.
config.censorship.tls_fetch_scope = config.censorship.tls_fetch_scope.trim().to_string();
@@ -217,6 +218,5 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
validate_logging_config(&config.logging)?;
validate_upstreams(config)?;
config.rebuild_runtime_user_auth()?;
config.rebuild_runtime_web()?;
Ok(())
}
+35 -2
View File
@@ -1,18 +1,51 @@
use super::*;
/// Completes legacy synchronous loads without performing hostname resolution.
pub(super) fn load_source_graph(graph: ConfigSourceGraph) -> Result<LoadedConfig> {
finish(parse_source_graph(graph)?)
}
/// Normalizes source while leaving DNS and immutable site acquisition to preparation.
pub(super) fn parse_source_graph(graph: ConfigSourceGraph) -> Result<ParsedConfigSource> {
let (mut config, source_files, source_contents, processed) =
decode::decode_source_graph(graph)?;
validate_core::validate(&mut config)?;
validate_runtime::validate(&mut config)?;
validate_me::validate(&mut config)?;
validate_server::validate(&mut config)?;
validate_web::validate(&mut config)?;
validate_web::validate_source(&mut config)?;
effective::apply(&mut config)?;
Ok(LoadedConfig {
Ok(ParsedConfigSource {
config,
source_files: source_files.into_iter().collect(),
source_contents,
rendered_hash: hash_rendered_snapshot(&processed),
})
}
/// Completes synchronous validation and static acquisition after DNS is captured.
pub(super) fn finish(mut parsed: ParsedConfigSource) -> Result<LoadedConfig> {
parsed.config.validate_effective_web()?;
parsed.config.rebuild_runtime_web()?;
Ok(LoadedConfig {
config: parsed.config,
source_files: parsed.source_files,
source_contents: parsed.source_contents,
rendered_hash: parsed.rendered_hash,
})
}
impl ParsedConfigSource {
/// Resolves each unique decoy origin once, then builds a fully validated candidate.
pub(crate) async fn prepare(mut self) -> Result<LoadedConfig> {
decoy_dns::prepare(&mut self.config, |host, port| async move {
tokio::net::lookup_host((host.as_str(), port))
.await
.map(|answers| answers.collect())
})
.await?;
tokio::task::spawn_blocking(move || finish(self))
.await
.map_err(|error| ProxyError::Config(format!("config preparation failed: {error}")))?
}
}
+7 -31
View File
@@ -47,8 +47,10 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
let mut static_bytes = 0usize;
let carrier_candidates: Arc<[WebCarrier]> = config.web.carrier_candidates().into();
for vhost in &config.web.vhosts {
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
let decoy = build_decoy(
config,
vhost_idx,
vhost,
&config.web.limits,
&mut static_files,
@@ -182,42 +184,16 @@ fn client_secret(secret: [u8; 16], mode: WebSecretMode) -> ([u8; 17], usize) {
}
fn build_decoy(
config: &ProxyConfig,
vhost_idx: usize,
vhost: &WebVhostConfig,
limits: &WebLimitsConfig,
static_files: &mut usize,
static_bytes: &mut usize,
) -> Result<WebRuntimeDecoy> {
match &vhost.decoy {
WebDecoyConfig::HttpUpstream { upstream } => {
let parsed = url::Url::parse(upstream).map_err(|error| {
ProxyError::Config(format!(
"WEB decoy upstream for `{}` is invalid: {error}",
vhost.host
))
})?;
let ip = match parsed.host() {
Some(url::Host::Ipv4(ip)) => std::net::IpAddr::V4(ip),
Some(url::Host::Ipv6(ip)) => std::net::IpAddr::V6(ip),
_ => {
return Err(ProxyError::Config(
"WEB decoy host must be an IP literal".to_string(),
));
}
};
let host = ip.to_string();
let port = parsed.port_or_known_default().ok_or_else(|| {
ProxyError::Config("WEB decoy port cannot be resolved".to_string())
})?;
let authority = match (ip, parsed.port()) {
(std::net::IpAddr::V6(_), Some(_)) => format!("[{host}]:{port}"),
(std::net::IpAddr::V6(_), None) => format!("[{host}]"),
(std::net::IpAddr::V4(_), Some(_)) => format!("{host}:{port}"),
(std::net::IpAddr::V4(_), None) => host.clone(),
};
Ok(WebRuntimeDecoy::HttpUpstream {
addr: SocketAddr::new(ip, port),
authority,
})
WebDecoyConfig::HttpUpstream { upstream, resolve } => {
decoy_dns::build_upstream(config, vhost_idx, upstream, *resolve)
}
WebDecoyConfig::StaticDirectory { directory, index } => {
let site = load_static_site(directory, index, limits, static_files, static_bytes)?;
+2 -1
View File
@@ -365,10 +365,11 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
"http_overload_timeout_ms",
"shutdown_secs",
"decoy_header_secs",
"decoy_resolve_secs",
];
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_DECOY_CONFIG_KEYS: &[&str] = &["mode", "upstream", "directory", "index", "resolve"];
const WEB_PROFILE_CONFIG_KEYS: &[&str] = &[
"user",
"secret_mode",
+25 -23
View File
@@ -27,6 +27,12 @@ const MAX_WEB_MEMORY_ENVELOPE_BYTES: usize = 4 * 1024 * 1024 * 1024;
/// Validates WEB policy and resource bounds before building runtime state.
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
validate_source(config)?;
validate_decoy_listener_separation(config)
}
/// Validates source policy without requiring DNS evidence for opted-in origins.
pub(super) fn validate_source(config: &mut ProxyConfig) -> Result<()> {
let web_listener_count = config
.server
.listeners
@@ -83,12 +89,19 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
timeouts::validate(&config.web.timeouts)?;
websocket::validate(&carriers, &config.web.limits, &config.web.timeouts)?;
validate_vhosts(config)?;
validate_decoy_listener_separation(config)?;
validate_decoy_listener_separation_inner(config, false)?;
Ok(())
}
/// Rejects a direct decoy recursion into an effective WEB listener.
pub(super) fn validate_decoy_listener_separation(config: &ProxyConfig) -> Result<()> {
validate_decoy_listener_separation_inner(config, true)
}
fn validate_decoy_listener_separation_inner(
config: &ProxyConfig,
require_resolved: bool,
) -> Result<()> {
let web_listeners = config
.server
.listeners
@@ -101,30 +114,19 @@ pub(super) fn validate_decoy_listener_separation(config: &ProxyConfig) -> Result
.map(|listener| SocketAddr::new(listener.ip, listener.port.unwrap_or(config.server.port)))
.collect::<Vec<_>>();
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
let WebDecoyConfig::HttpUpstream { upstream } = &vhost.decoy else {
let WebDecoyConfig::HttpUpstream { upstream, resolve } = &vhost.decoy else {
continue;
};
let parsed = url::Url::parse(upstream).map_err(|error| {
ProxyError::Config(format!(
"web.vhosts[{vhost_idx}].decoy.upstream is invalid: {error}"
))
})?;
let Some(port) = parsed.port_or_known_default() else {
continue;
};
let upstream_ip = match parsed.host() {
Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip),
Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip),
_ => continue,
};
let upstream_addr = SocketAddr::new(upstream_ip, port);
if web_listeners
.iter()
.any(|listener| listener_covers(*listener, upstream_addr))
{
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy upstream overlaps WEB listener {upstream_addr}"
));
let parsed = decoy_dns::parse_origin(vhost_idx, upstream, *resolve)?;
for upstream_addr in decoy_dns::addresses(config, vhost_idx, &parsed, require_resolved)? {
if web_listeners
.iter()
.any(|listener| listener_covers(*listener, upstream_addr))
{
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy upstream overlaps WEB listener {upstream_addr}"
));
}
}
}
Ok(())
+1
View File
@@ -26,6 +26,7 @@ pub(super) fn validate(timeouts: &WebTimeoutsConfig) -> Result<()> {
("http_idle_secs", timeouts.http_idle_secs),
("shutdown_secs", timeouts.shutdown_secs),
("decoy_header_secs", timeouts.decoy_header_secs),
("decoy_resolve_secs", timeouts.decoy_resolve_secs),
];
if let Some((field, _)) = values
.into_iter()
+2 -39
View File
@@ -150,45 +150,8 @@ pub(super) fn web_host_last_label_is_numeric(host: &str) -> bool {
pub(super) fn validate_decoy(vhost_idx: usize, decoy: &WebDecoyConfig) -> Result<()> {
match decoy {
WebDecoyConfig::HttpUpstream { upstream } => {
let parsed = url::Url::parse(upstream).map_err(|error| {
ProxyError::Config(format!(
"web.vhosts[{vhost_idx}].decoy.upstream is invalid: {error}"
))
})?;
if parsed.scheme() != "http"
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
|| parsed.path() != "/"
|| parsed.port() == Some(0)
{
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy.upstream must be an http origin without credentials, path, query, or fragment"
));
}
let ip = match parsed.host() {
Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip),
Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip),
_ => {
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy.upstream host must be a loopback or private IP literal"
));
}
};
let private = match ip {
IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(),
IpAddr::V6(ip) => {
ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local()
}
};
if !private {
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy.upstream must remain inside loopback or a private network"
));
}
WebDecoyConfig::HttpUpstream { upstream, resolve } => {
decoy_dns::parse_origin(vhost_idx, upstream, *resolve)?;
}
WebDecoyConfig::StaticDirectory { directory, index } => {
if !directory.is_absolute() {