feat: support injecting real IP into X-Forwarded-For header via 'add_xff_header' config

This commit is contained in:
2026-01-19 21:15:10 +08:00
parent e51e401dd3
commit 72801b6ecb
6 changed files with 71 additions and 0 deletions

1
Cargo.lock generated
View File

@@ -2495,6 +2495,7 @@ dependencies = [
"async-trait",
"bytes",
"clickhouse",
"http",
"httparse",
"ipnet",
"libc",

View File

@@ -12,6 +12,7 @@ tokio = { version = "1", features = ["full"] }
clickhouse = { version = "0.14", features = ["time"] }
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
http = "1"
socket2 = "0.6"
libc = "0.2"
tracing = "0.1"

View File

@@ -102,6 +102,8 @@ pub struct BindEntry {
#[serde(default = "default_socket_mode", deserialize_with = "deserialize_mode")]
pub mode: u32,
pub tls: Option<TlsConfig>,
#[serde(default)]
pub add_xff_header: bool,
pub real_ip: Option<RealIpConfig>,
}
@@ -227,6 +229,19 @@ impl Config {
);
}
}
// Rule 3: Check for XFF loop
if bind.add_xff_header {
if let Some(real_ip) = &bind.real_ip {
if real_ip.source == RealIpSource::Xff {
anyhow::bail!(
"Service '{}' bind '{}' has 'add_xff_header: true' but 'real_ip.from' is 'xff'. This is not allowed as it would duplicate the IP.",
service.name,
bind.addr
);
}
}
}
}
}
Ok(())

View File

@@ -11,6 +11,7 @@ pub struct TrauditProxy {
pub service_config: ServiceConfig,
pub listen_addr: String,
pub real_ip: Option<crate::config::RealIpConfig>,
pub add_xff_header: bool,
}
pub struct HttpContext {
@@ -117,6 +118,30 @@ impl ProxyHttp for TrauditProxy {
ctx.src_ip = resolved_ip;
// 1.5. Inject X-Forwarded-For if configured
if self.add_xff_header {
let src_ip_str = resolved_ip.to_string();
// Collect existing headers to avoid double borrow
let mut owned_values: Vec<String> = session
.req_header()
.headers
.get_all("X-Forwarded-For")
.iter()
.filter_map(|v| v.to_str().ok().map(|s| s.to_string()))
.collect();
owned_values.push(src_ip_str);
let new_val = owned_values.join(", ");
if let Ok(valid_header) = http::header::HeaderValue::from_str(&new_val) {
// insert_header replaces all existing headers with this name
let _ = session
.req_header_mut()
.insert_header("X-Forwarded-For", valid_header);
}
}
// Log connection info
let src_fmt = resolved_ip.to_string();
let physical_fmt = peer_addr.to_string();

View File

@@ -172,6 +172,7 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
service_config: service_config.clone(),
listen_addr: bind_addr.clone(),
real_ip: real_ip_config.clone(),
add_xff_header: bind.add_xff_header,
};
let mut service_obj = http_proxy_service(&conf, inner_proxy);
let app = unsafe {
@@ -223,6 +224,7 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
service_config: svc_config.clone(),
listen_addr: bind.addr.clone(),
real_ip,
add_xff_header: bind.add_xff_header,
};
let mut service = http_proxy_service(&server.configuration, proxy);

View File

@@ -105,3 +105,30 @@ fn test_trusted_proxies_mixed_formats() {
assert!(!config.is_trusted("10.0.1.1".parse().unwrap())); // Outside /24
assert!(!config.is_trusted("2001:db9::1".parse().unwrap())); // Outside /32
}
#[tokio::test]
async fn test_error_xff_loop() {
let config_str = r#"
database:
type: clickhouse
dsn: "http://127.0.0.1:8123"
services:
- name: "loop-service"
type: "http"
forward_to: "127.0.0.1:8080"
binds:
- addr: "0.0.0.0:443"
proxy: v2
add_xff_header: true
real_ip:
from: "xff"
"#;
let mut file = tempfile::NamedTempFile::new().unwrap();
write!(file, "{}", config_str).unwrap();
let path = file.path().to_path_buf();
let res = Config::load(&path).await;
assert!(res.is_err());
let err = res.err().unwrap().to_string();
assert!(err.contains("duplicate the IP"));
}