mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-12 08:06:54 +00:00
466 lines
17 KiB
Rust
466 lines
17 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//! Proxy validator for verifying proxy chains and extracting client information.
|
|
|
|
use axum::http::HeaderMap;
|
|
use std::net::{IpAddr, SocketAddr};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
use tracing::{debug, trace, warn};
|
|
|
|
use crate::{
|
|
CacheConfig, CacheStats, IpValidationCache, ProxyChainAnalyzer, ProxyError, ProxyMetrics, TrustedProxyConfig, ValidationMode,
|
|
};
|
|
|
|
/// Information about the client extracted from the request and proxy headers.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ClientInfo {
|
|
/// The verified real IP address of the client.
|
|
pub real_ip: IpAddr,
|
|
/// The original host requested by the client (if provided by a trusted proxy).
|
|
pub forwarded_host: Option<String>,
|
|
/// The original protocol (http/https) used by the client (if provided by a trusted proxy).
|
|
pub forwarded_proto: Option<String>,
|
|
/// Whether the request was received from a trusted proxy.
|
|
pub is_from_trusted_proxy: bool,
|
|
/// The IP address of the proxy that directly connected to this server.
|
|
pub proxy_ip: Option<IpAddr>,
|
|
/// The number of proxy hops identified in the chain.
|
|
pub proxy_hops: usize,
|
|
/// The validation mode used for this request.
|
|
pub validation_mode: ValidationMode,
|
|
/// Any warnings generated during the validation process.
|
|
pub warnings: Vec<String>,
|
|
}
|
|
|
|
impl ClientInfo {
|
|
/// Creates a `ClientInfo` for a direct connection without any proxies.
|
|
pub fn direct(addr: SocketAddr) -> Self {
|
|
Self {
|
|
real_ip: addr.ip(),
|
|
forwarded_host: None,
|
|
forwarded_proto: None,
|
|
is_from_trusted_proxy: false,
|
|
proxy_ip: None,
|
|
proxy_hops: 0,
|
|
validation_mode: ValidationMode::Lenient,
|
|
warnings: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Creates a `ClientInfo` for a request received through a trusted proxy.
|
|
pub fn from_trusted_proxy(
|
|
real_ip: IpAddr,
|
|
forwarded_host: Option<String>,
|
|
forwarded_proto: Option<String>,
|
|
proxy_ip: IpAddr,
|
|
proxy_hops: usize,
|
|
validation_mode: ValidationMode,
|
|
warnings: Vec<String>,
|
|
) -> Self {
|
|
Self {
|
|
real_ip,
|
|
forwarded_host,
|
|
forwarded_proto,
|
|
is_from_trusted_proxy: true,
|
|
proxy_ip: Some(proxy_ip),
|
|
proxy_hops,
|
|
validation_mode,
|
|
warnings,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Core validator that processes incoming requests to verify proxy chains.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProxyValidator {
|
|
/// Configuration for trusted proxies.
|
|
config: TrustedProxyConfig,
|
|
/// Analyzer for verifying the integrity of the proxy chain.
|
|
chain_analyzer: ProxyChainAnalyzer,
|
|
/// Cache for repeated direct-peer trusted proxy decisions.
|
|
validation_cache: Arc<IpValidationCache>,
|
|
/// Metrics collector for observability.
|
|
metrics: Option<ProxyMetrics>,
|
|
}
|
|
|
|
impl ProxyValidator {
|
|
/// Creates a new `ProxyValidator` with the given configuration and metrics.
|
|
pub fn new(config: TrustedProxyConfig, metrics: Option<ProxyMetrics>) -> Self {
|
|
Self::with_cache_config(config, CacheConfig::default(), metrics)
|
|
}
|
|
|
|
/// Creates a new `ProxyValidator` with explicit cache configuration.
|
|
pub fn with_cache_config(config: TrustedProxyConfig, cache_config: CacheConfig, metrics: Option<ProxyMetrics>) -> Self {
|
|
let chain_analyzer = ProxyChainAnalyzer::new(config.clone());
|
|
let cache_enabled = cache_config.capacity > 0 && cache_config.ttl_seconds > 0;
|
|
let validation_cache = Arc::new(IpValidationCache::new(
|
|
cache_config.capacity,
|
|
cache_config.ttl_duration(),
|
|
cache_enabled,
|
|
metrics.clone(),
|
|
));
|
|
|
|
Self {
|
|
config,
|
|
chain_analyzer,
|
|
validation_cache,
|
|
metrics,
|
|
}
|
|
}
|
|
|
|
/// Validates an incoming request and extracts client information.
|
|
pub fn validate_request(&self, peer_addr: Option<SocketAddr>, headers: &HeaderMap) -> Result<ClientInfo, ProxyError> {
|
|
let start_time = Instant::now();
|
|
|
|
// Record the start of the validation attempt.
|
|
self.record_metric_start();
|
|
|
|
// Perform the internal validation logic.
|
|
let result = self.validate_request_internal(peer_addr, headers);
|
|
|
|
// Record the result and duration.
|
|
let duration = start_time.elapsed();
|
|
self.record_metric_result(&result, duration);
|
|
|
|
result
|
|
}
|
|
|
|
/// Internal logic for request validation.
|
|
fn validate_request_internal(&self, peer_addr: Option<SocketAddr>, headers: &HeaderMap) -> Result<ClientInfo, ProxyError> {
|
|
let Some(peer_addr) = peer_addr else {
|
|
debug!(
|
|
event = "proxy_validation.evaluate",
|
|
component = "trusted_proxies",
|
|
subsystem = "validator",
|
|
result = "direct",
|
|
reason = "missing_peer_addr",
|
|
"trusted proxy evaluation skipped"
|
|
);
|
|
return Ok(ClientInfo::direct(SocketAddr::new(IpAddr::from([0, 0, 0, 0]), 0)));
|
|
};
|
|
|
|
let peer_ip = peer_addr.ip();
|
|
if peer_ip.is_unspecified() {
|
|
debug!(
|
|
event = "proxy_validation.evaluate",
|
|
component = "trusted_proxies",
|
|
subsystem = "validator",
|
|
result = "direct",
|
|
reason = "unspecified_peer_addr",
|
|
peer_ip = %peer_ip,
|
|
"trusted proxy evaluation skipped"
|
|
);
|
|
return Ok(ClientInfo::direct(peer_addr));
|
|
}
|
|
|
|
let is_trusted_proxy = self
|
|
.validation_cache
|
|
.is_trusted(&peer_ip, |ip| self.chain_analyzer.is_ip_trusted(ip));
|
|
|
|
// Check if the direct peer is a trusted proxy.
|
|
if is_trusted_proxy {
|
|
trace!(
|
|
event = "proxy_validation.peer",
|
|
component = "trusted_proxies",
|
|
subsystem = "validator",
|
|
result = "trusted_proxy",
|
|
peer_ip = %peer_ip,
|
|
validation_mode = self.config.validation_mode.as_str(),
|
|
"trusted proxy peer accepted"
|
|
);
|
|
|
|
// Parse and validate headers from the trusted proxy.
|
|
self.validate_trusted_proxy_request(&peer_addr, headers)
|
|
} else {
|
|
// Log a warning if the request is from a private network but not trusted.
|
|
if self.config.is_private_network(&peer_ip) {
|
|
warn!(
|
|
event = "proxy_validation.peer",
|
|
component = "trusted_proxies",
|
|
subsystem = "validator",
|
|
result = "direct",
|
|
fallback = "socket_peer",
|
|
reason = "private_network_untrusted",
|
|
peer_ip = %peer_ip,
|
|
"trusted proxy validation downgraded to direct peer"
|
|
);
|
|
} else {
|
|
trace!(
|
|
event = "proxy_validation.peer",
|
|
component = "trusted_proxies",
|
|
subsystem = "validator",
|
|
result = "direct",
|
|
fallback = "socket_peer",
|
|
reason = "peer_not_trusted",
|
|
peer_ip = %peer_ip,
|
|
"trusted proxy validation resolved direct peer"
|
|
);
|
|
}
|
|
|
|
// Treat as a direct connection if the peer is not trusted.
|
|
Ok(ClientInfo::direct(peer_addr))
|
|
}
|
|
}
|
|
|
|
/// Returns cache statistics for direct-peer validation decisions.
|
|
pub fn cache_stats(&self) -> CacheStats {
|
|
self.validation_cache.stats()
|
|
}
|
|
|
|
pub(crate) fn spawn_cache_maintenance_task(self: &Arc<Self>, cleanup_interval: Duration) {
|
|
if cleanup_interval.is_zero() || !self.validation_cache.is_enabled() {
|
|
return;
|
|
}
|
|
|
|
let Ok(handle) = tokio::runtime::Handle::try_current() else {
|
|
tracing::debug!(
|
|
event = "proxy_validation.cache_maintenance",
|
|
component = "trusted_proxies",
|
|
subsystem = "validator",
|
|
state = "disabled",
|
|
reason = "missing_tokio_runtime",
|
|
"trusted proxy cache maintenance unavailable"
|
|
);
|
|
return;
|
|
};
|
|
|
|
let cache = self.validation_cache.clone();
|
|
handle.spawn(async move {
|
|
let mut interval = tokio::time::interval(cleanup_interval);
|
|
loop {
|
|
interval.tick().await;
|
|
cache.run_maintenance();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Validates a request that originated from a trusted proxy.
|
|
fn validate_trusted_proxy_request(&self, proxy_addr: &SocketAddr, headers: &HeaderMap) -> Result<ClientInfo, ProxyError> {
|
|
let proxy_ip = proxy_addr.ip();
|
|
|
|
// Prefer RFC 7239 "Forwarded" header if enabled, otherwise fallback to legacy headers.
|
|
let client_info = if self.config.enable_rfc7239 {
|
|
self.try_parse_rfc7239_headers(headers, proxy_ip)
|
|
.unwrap_or_else(|| self.parse_legacy_headers(headers))
|
|
} else {
|
|
self.parse_legacy_headers(headers)
|
|
};
|
|
|
|
// Analyze the integrity and continuity of the proxy chain.
|
|
let chain_analysis = self
|
|
.chain_analyzer
|
|
.analyze_chain(&client_info.proxy_chain, proxy_ip, headers)?;
|
|
|
|
// Enforce maximum hop limit.
|
|
if chain_analysis.hops > self.config.max_hops {
|
|
return Err(ProxyError::ChainTooLong(chain_analysis.hops, self.config.max_hops));
|
|
}
|
|
|
|
// Enforce chain continuity if enabled.
|
|
if self.config.enable_chain_continuity_check && !chain_analysis.is_continuous {
|
|
return Err(ProxyError::ChainNotContinuous);
|
|
}
|
|
|
|
trace!(
|
|
event = "proxy_validation.chain",
|
|
component = "trusted_proxies",
|
|
subsystem = "validator",
|
|
result = "accepted",
|
|
proxy_ip = %proxy_ip,
|
|
client_ip = %chain_analysis.client_ip,
|
|
proxy_hops = chain_analysis.hops,
|
|
warning_count = chain_analysis.warnings.len(),
|
|
validation_mode = chain_analysis.validation_mode.as_str(),
|
|
trusted_proxy_count = chain_analysis.trusted_chain.len(),
|
|
"trusted proxy chain accepted"
|
|
);
|
|
|
|
Ok(ClientInfo::from_trusted_proxy(
|
|
chain_analysis.client_ip,
|
|
client_info.forwarded_host,
|
|
client_info.forwarded_proto,
|
|
proxy_ip,
|
|
chain_analysis.hops,
|
|
self.config.validation_mode,
|
|
chain_analysis.warnings,
|
|
))
|
|
}
|
|
|
|
/// Attempts to parse the RFC 7239 "Forwarded" header.
|
|
fn try_parse_rfc7239_headers(&self, headers: &HeaderMap, proxy_ip: IpAddr) -> Option<ParsedHeaders> {
|
|
headers
|
|
.get("forwarded")
|
|
.and_then(|h| h.to_str().ok())
|
|
.and_then(|s| Self::parse_forwarded_header(s, proxy_ip))
|
|
}
|
|
|
|
/// Parses legacy proxy headers (X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto).
|
|
fn parse_legacy_headers(&self, headers: &HeaderMap) -> ParsedHeaders {
|
|
let forwarded_host = headers
|
|
.get("x-forwarded-host")
|
|
.and_then(|h| h.to_str().ok())
|
|
.map(String::from);
|
|
|
|
let forwarded_proto = headers
|
|
.get("x-forwarded-proto")
|
|
.and_then(|h| h.to_str().ok())
|
|
.map(String::from);
|
|
|
|
let proxy_chain = headers
|
|
.get("x-forwarded-for")
|
|
.and_then(|h| h.to_str().ok())
|
|
.map(Self::parse_x_forwarded_for)
|
|
.unwrap_or_default();
|
|
|
|
ParsedHeaders {
|
|
proxy_chain,
|
|
forwarded_host,
|
|
forwarded_proto,
|
|
}
|
|
}
|
|
|
|
/// Parses the RFC 7239 "Forwarded" header value.
|
|
///
|
|
/// Every comma-separated element is parsed in wire order (client-closest
|
|
/// first, each proxy appends its node to the right) so that the resulting
|
|
/// `proxy_chain` can be validated by the same right-to-left chain analysis
|
|
/// used for `X-Forwarded-For`. This prevents a client from spoofing the
|
|
/// real IP by supplying only the leftmost element.
|
|
///
|
|
/// `host`/`proto` are taken solely from the last (most recent) element,
|
|
/// which is appended by the directly connected trusted proxy — mirroring
|
|
/// how the legacy path reads proxy-set `X-Forwarded-Host`/`Proto` headers.
|
|
/// Values injected by the client in earlier elements are ignored.
|
|
fn parse_forwarded_header(header_value: &str, proxy_ip: IpAddr) -> Option<ParsedHeaders> {
|
|
let elements: Vec<&str> = header_value
|
|
.split(',')
|
|
.map(|element| element.trim())
|
|
.filter(|element| !element.is_empty())
|
|
.collect();
|
|
|
|
let mut proxy_chain = Vec::new();
|
|
let mut forwarded_host = None;
|
|
let mut forwarded_proto = None;
|
|
|
|
let last_index = elements.len().saturating_sub(1);
|
|
for (index, element) in elements.iter().enumerate() {
|
|
let is_last = index == last_index;
|
|
for part in element.split(';') {
|
|
let part = part.trim();
|
|
if let Some((key, value)) = part.split_once('=') {
|
|
let key = key.trim().to_lowercase();
|
|
let value = value.trim().trim_matches('"');
|
|
|
|
match key.as_str() {
|
|
"for" => {
|
|
if let Some(ip) = Self::parse_forwarded_node(value) {
|
|
proxy_chain.push(ip);
|
|
}
|
|
}
|
|
// Only honor host/proto from the trusted proxy-appended node.
|
|
"host" if is_last => {
|
|
forwarded_host = Some(value.to_string());
|
|
}
|
|
"proto" if is_last => {
|
|
forwarded_proto = Some(value.to_string());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to the proxy IP if no client IP was found in the header.
|
|
if proxy_chain.is_empty() {
|
|
proxy_chain.push(proxy_ip);
|
|
}
|
|
|
|
Some(ParsedHeaders {
|
|
proxy_chain,
|
|
forwarded_host,
|
|
forwarded_proto,
|
|
})
|
|
}
|
|
|
|
/// Parses the X-Forwarded-For header into a list of IP addresses.
|
|
pub fn parse_x_forwarded_for(header_value: &str) -> Vec<IpAddr> {
|
|
header_value
|
|
.split(',')
|
|
.map(|s| s.trim())
|
|
.filter(|s| !s.is_empty())
|
|
.filter_map(Self::parse_forwarded_node)
|
|
.collect()
|
|
}
|
|
|
|
/// Parses a single forwarded node token into an `IpAddr`.
|
|
///
|
|
/// Handles bracketed IPv6 (`[2001:db8::1]` and `[2001:db8::1]:443`),
|
|
/// IPv4 with an optional `:port`, and BARE IPv6 addresses. A trailing
|
|
/// `:port` is only stripped when the token is not itself a valid bare
|
|
/// address, so `2001:db8::1` is no longer truncated to `2001`.
|
|
fn parse_forwarded_node(token: &str) -> Option<IpAddr> {
|
|
let token = token.trim();
|
|
if token.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let ip_str = if let Some(rest) = token.strip_prefix('[') {
|
|
// Bracketed IPv6, optionally followed by ":port".
|
|
rest.split(']').next()?
|
|
} else if token.parse::<IpAddr>().is_ok() {
|
|
// Bare address (IPv4 or IPv6) with no port; parse the whole token.
|
|
token
|
|
} else {
|
|
// Not a bare address: strip a trailing ":port" (IPv4 host:port).
|
|
token.rsplit_once(':').map(|(host, _)| host).unwrap_or(token)
|
|
};
|
|
|
|
ip_str.parse::<IpAddr>().ok()
|
|
}
|
|
|
|
/// Records the start of a validation attempt in metrics.
|
|
fn record_metric_start(&self) {
|
|
if let Some(metrics) = &self.metrics {
|
|
metrics.increment_validation_attempts();
|
|
}
|
|
}
|
|
|
|
/// Records the result of a validation attempt in metrics.
|
|
fn record_metric_result(&self, result: &Result<ClientInfo, ProxyError>, duration: std::time::Duration) {
|
|
if let Some(metrics) = &self.metrics {
|
|
match result {
|
|
Ok(client_info) => {
|
|
metrics.record_validation_success(client_info.is_from_trusted_proxy, client_info.proxy_hops, duration);
|
|
}
|
|
Err(err) => {
|
|
metrics.record_validation_failure(err, duration);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Internal structure for holding parsed header information.
|
|
#[derive(Debug, Clone)]
|
|
struct ParsedHeaders {
|
|
/// The chain of proxy IPs (client IP is typically the first).
|
|
proxy_chain: Vec<IpAddr>,
|
|
/// The original host requested.
|
|
forwarded_host: Option<String>,
|
|
/// The original protocol used.
|
|
forwarded_proto: Option<String>,
|
|
}
|