featherbit/plugins/native/
real_ip.rs1use async_trait::async_trait;
12use std::collections::HashMap;
13use std::net::IpAddr;
14
15use ipnet::IpNet;
16
17use crate::context::Context;
18use crate::plugins::{Plugin, PluginOutput, PluginResult};
19
20pub struct RealIpPlugin {
28 source: String,
30 trusted_addresses: Option<Vec<IpNet>>,
34 recursive: bool,
37}
38
39fn parse_ip_port(addr: &str) -> Option<(IpAddr, Option<u16>)> {
43 let addr = addr.trim();
44
45 if let Some(rest) = addr.strip_prefix('[') {
47 let (ip, tail) = rest.split_once(']')?;
48 let ip: IpAddr = ip.parse().ok()?;
49 return match tail.strip_prefix(':') {
50 Some(p) => {
51 let port = p.parse::<u16>().ok().filter(|p| *p > 0)?;
52 Some((ip, Some(port)))
53 }
54 None if tail.is_empty() => Some((ip, None)),
55 None => None,
56 };
57 }
58
59 if let Ok(ip) = addr.parse::<IpAddr>() {
61 return Some((ip, None));
62 }
63
64 let (ip, port) = addr.rsplit_once(':')?;
66 if ip.contains(':') {
67 return None;
68 }
69 let ip: IpAddr = ip.parse().ok()?;
70 let port = port.parse::<u16>().ok().filter(|p| *p > 0)?;
71 Some((ip, Some(port)))
72}
73
74impl RealIpPlugin {
75 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
100 let source = config
101 .get("source")
102 .and_then(|v| v.as_str())
103 .filter(|s| !s.is_empty())
104 .map(String::from)
105 .ok_or("real-ip plugin requires 'source' (a variable name, e.g. http_x_real_ip)")?;
106
107 let trusted_addresses = match config.get("trusted_addresses") {
108 None => None,
109 Some(raw) => {
110 let items = raw
111 .as_array()
112 .ok_or("trusted_addresses must be an array of IPs/CIDRs")?;
113 if items.is_empty() {
114 return Err("trusted_addresses must contain at least one IP/CIDR".to_string());
115 }
116 let nets = items
117 .iter()
118 .map(|item| {
119 let s = item
120 .as_str()
121 .ok_or("trusted_addresses items must be strings")?;
122 if let Ok(net) = s.parse::<IpNet>() {
123 Ok(net)
124 } else if let Ok(ip) = s.parse::<IpAddr>() {
125 Ok(IpNet::from(ip))
126 } else {
127 Err(format!("invalid ip address: {}", s))
128 }
129 })
130 .collect::<Result<Vec<_>, String>>()?;
131 Some(nets)
132 }
133 };
134
135 let recursive = config
136 .get("recursive")
137 .and_then(|v| v.as_bool())
138 .unwrap_or(false);
139
140 Ok(Self {
141 source,
142 trusted_addresses,
143 recursive,
144 })
145 }
146
147 fn is_trusted(&self, ip: IpAddr) -> bool {
150 self.trusted_addresses
151 .as_ref()
152 .is_some_and(|nets| nets.iter().any(|net| net.contains(&ip)))
153 }
154
155 fn get_addr(&self, ctx: &Context) -> Option<String> {
158 if self.source == "http_x_forwarded_for" {
159 let value = ctx.request.headers.get("x-forwarded-for")?.last()?;
162 let parts: Vec<&str> = value.split(',').map(str::trim).collect();
163
164 if parts.len() == 1 {
165 return Some(parts[0].to_string());
166 }
167
168 if self.recursive && self.trusted_addresses.is_some() {
169 for part in parts[1..].iter().rev() {
173 let trusted = part.parse::<IpAddr>().is_ok_and(|ip| self.is_trusted(ip));
174 if !trusted {
175 return Some(part.to_string());
176 }
177 }
178 return Some(parts[0].to_string());
179 }
180
181 return parts.last().map(|s| s.to_string());
183 }
184
185 crate::vars::resolve(ctx, &self.source).map(|v| v.into_owned())
186 }
187}
188
189#[async_trait]
190impl Plugin for RealIpPlugin {
191 fn plugin_type(&self) -> &str {
192 "real-ip"
193 }
194
195 async fn execute(
196 &self,
197 mut ctx: Context,
198 _named_inputs: &HashMap<String, serde_json::Value>,
199 ) -> PluginResult {
200 let passthrough = |ctx: Context| {
201 Ok(PluginOutput {
202 context: ctx,
203 named_outputs: HashMap::new(),
204 })
205 };
206
207 let direct = parse_ip_port(&ctx.request.remote_addr);
209 if self.trusted_addresses.is_some() {
210 match direct {
211 Some((ip, _)) if self.is_trusted(ip) => {}
212 _ => return passthrough(ctx),
213 }
214 }
215
216 let Some(addr) = self.get_addr(&ctx) else {
217 return passthrough(ctx);
218 };
219
220 let Some((ip, port)) = parse_ip_port(&addr) else {
221 return passthrough(ctx);
223 };
224
225 let port = port.or_else(|| direct.and_then(|(_, p)| p));
227 ctx.request.remote_addr = match (ip, port) {
228 (IpAddr::V6(v6), Some(p)) => format!("[{}]:{}", v6, p),
229 (ip, Some(p)) => format!("{}:{}", ip, p),
230 (ip, None) => ip.to_string(),
231 };
232
233 passthrough(ctx)
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
241 use bytes::Bytes;
242
243 fn test_context(remote_addr: &str) -> Context {
244 Context {
245 request: GatewayRequest {
246 method: "GET".to_string(),
247 path: "/".to_string(),
248 host: "localhost".to_string(),
249 scheme: "http".to_string(),
250 headers: HashMap::new(),
251 query_params: HashMap::new(),
252 body: Bytes::new(),
253 remote_addr: remote_addr.to_string(),
254 protocol: Protocol::Http1,
255 },
256 response: GatewayResponse {
257 status_code: 0,
258 headers: HashMap::new(),
259 body: Bytes::new(),
260 },
261 message: HashMap::new(),
262 errors: Vec::new(),
263 }
264 }
265
266 fn config(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
267 serde_json::from_value(json).unwrap()
268 }
269
270 #[test]
271 fn test_real_ip_config_validation() {
272 assert!(RealIpPlugin::from_config(&HashMap::new()).is_err());
274
275 assert!(RealIpPlugin::from_config(&config(serde_json::json!({
277 "source": "http_x_real_ip",
278 "trusted_addresses": ["not-an-ip"]
279 })))
280 .is_err());
281
282 assert!(RealIpPlugin::from_config(&config(serde_json::json!({
284 "source": "http_x_real_ip",
285 "trusted_addresses": []
286 })))
287 .is_err());
288
289 assert!(RealIpPlugin::from_config(&config(serde_json::json!({
291 "source": "http_x_forwarded_for",
292 "trusted_addresses": ["10.0.0.0/8", "127.0.0.1"],
293 "recursive": true
294 })))
295 .is_ok());
296 }
297
298 #[tokio::test]
299 async fn test_real_ip_rewrites_from_x_real_ip() {
300 let plugin = RealIpPlugin::from_config(&config(serde_json::json!({
301 "source": "http_x_real_ip",
302 "trusted_addresses": ["127.0.0.0/24"]
303 })))
304 .unwrap();
305
306 let mut ctx = test_context("127.0.0.1:5000");
307 ctx.request
308 .headers
309 .insert("x-real-ip".to_string(), vec!["203.0.113.7".to_string()]);
310
311 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
312 assert_eq!(result.context.request.remote_addr, "203.0.113.7:5000");
314 }
315
316 #[tokio::test]
317 async fn test_real_ip_untrusted_peer_is_passthrough() {
318 let plugin = RealIpPlugin::from_config(&config(serde_json::json!({
319 "source": "http_x_real_ip",
320 "trusted_addresses": ["127.0.0.0/24"]
321 })))
322 .unwrap();
323
324 let mut ctx = test_context("198.51.100.9:5000");
325 ctx.request
326 .headers
327 .insert("x-real-ip".to_string(), vec!["203.0.113.7".to_string()]);
328
329 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
330 assert_eq!(result.context.request.remote_addr, "198.51.100.9:5000");
331 }
332
333 #[tokio::test]
334 async fn test_real_ip_missing_or_bad_source_is_passthrough() {
335 let plugin = RealIpPlugin::from_config(&config(serde_json::json!({
336 "source": "http_x_real_ip",
337 "trusted_addresses": ["127.0.0.0/24"]
338 })))
339 .unwrap();
340
341 let ctx = test_context("127.0.0.1:5000");
343 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
344 assert_eq!(result.context.request.remote_addr, "127.0.0.1:5000");
345
346 let mut ctx = test_context("127.0.0.1:5000");
348 ctx.request
349 .headers
350 .insert("x-real-ip".to_string(), vec!["unknown".to_string()]);
351 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
352 assert_eq!(result.context.request.remote_addr, "127.0.0.1:5000");
353 }
354
355 #[tokio::test]
356 async fn test_real_ip_xff_non_recursive_takes_last() {
357 let plugin = RealIpPlugin::from_config(&config(serde_json::json!({
358 "source": "http_x_forwarded_for",
359 "trusted_addresses": ["127.0.0.0/24"]
360 })))
361 .unwrap();
362
363 let mut ctx = test_context("127.0.0.1:5000");
364 ctx.request.headers.insert(
365 "x-forwarded-for".to_string(),
366 vec!["203.0.113.7, 10.1.1.1, 10.2.2.2".to_string()],
367 );
368
369 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
370 assert_eq!(result.context.request.remote_addr, "10.2.2.2:5000");
371 }
372
373 #[tokio::test]
374 async fn test_real_ip_xff_recursive_skips_trusted_hops() {
375 let plugin = RealIpPlugin::from_config(&config(serde_json::json!({
376 "source": "http_x_forwarded_for",
377 "trusted_addresses": ["127.0.0.0/24", "10.0.0.0/8"],
378 "recursive": true
379 })))
380 .unwrap();
381
382 let mut ctx = test_context("127.0.0.1:5000");
385 ctx.request.headers.insert(
386 "x-forwarded-for".to_string(),
387 vec!["203.0.113.7, 10.1.1.1, 10.2.2.2".to_string()],
388 );
389 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
390 assert_eq!(result.context.request.remote_addr, "203.0.113.7:5000");
391
392 let mut ctx = test_context("127.0.0.1:5000");
394 ctx.request.headers.insert(
395 "x-forwarded-for".to_string(),
396 vec!["10.9.9.9, 10.1.1.1".to_string()],
397 );
398 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
399 assert_eq!(result.context.request.remote_addr, "10.9.9.9:5000");
400 }
401
402 #[tokio::test]
403 async fn test_real_ip_source_port_wins_over_peer_port() {
404 let plugin = RealIpPlugin::from_config(&config(serde_json::json!({
405 "source": "http_x_real_ip"
406 })))
407 .unwrap();
408
409 let mut ctx = test_context("127.0.0.1:5000");
411 ctx.request.headers.insert(
412 "x-real-ip".to_string(),
413 vec!["203.0.113.7:8443".to_string()],
414 );
415 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
416 assert_eq!(result.context.request.remote_addr, "203.0.113.7:8443");
417 }
418
419 #[tokio::test]
420 async fn test_real_ip_ipv6_source() {
421 let plugin = RealIpPlugin::from_config(&config(serde_json::json!({
422 "source": "http_x_real_ip",
423 "trusted_addresses": ["127.0.0.0/24"]
424 })))
425 .unwrap();
426
427 let mut ctx = test_context("127.0.0.1:5000");
428 ctx.request.headers.insert(
429 "x-real-ip".to_string(),
430 vec!["[2001:db8::1]:9000".to_string()],
431 );
432 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
433 assert_eq!(result.context.request.remote_addr, "[2001:db8::1]:9000");
434
435 let mut ctx = test_context("127.0.0.1:5000");
436 ctx.request
437 .headers
438 .insert("x-real-ip".to_string(), vec!["2001:db8::1".to_string()]);
439 let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
440 assert_eq!(result.context.request.remote_addr, "[2001:db8::1]:5000");
442 }
443
444 #[test]
445 fn test_real_ip_parse_ip_port_forms() {
446 assert_eq!(
447 parse_ip_port("1.2.3.4:80"),
448 Some(("1.2.3.4".parse().unwrap(), Some(80)))
449 );
450 assert_eq!(
451 parse_ip_port("1.2.3.4"),
452 Some(("1.2.3.4".parse().unwrap(), None))
453 );
454 assert_eq!(
455 parse_ip_port("[::1]:8080"),
456 Some(("::1".parse().unwrap(), Some(8080)))
457 );
458 assert_eq!(parse_ip_port("::1"), Some(("::1".parse().unwrap(), None)));
459 assert_eq!(parse_ip_port("1.2.3.4:0"), None); assert_eq!(parse_ip_port("1.2.3.4:99999"), None);
461 assert_eq!(parse_ip_port("nonsense"), None);
462 }
463}