Skip to main content

featherbit/plugins/native/
real_ip.rs

1//! The `real-ip` node — rewrites the client address seen by the rest of the
2//! pipeline (`context.request.remote_addr`) from a request variable such as
3//! `http_x_forwarded_for` or `http_x_real_ip`.
4//!
5//! Port of APISIX's `real-ip` plugin. The rewrite only happens when the
6//! *direct* peer address matches `trusted_addresses` (when configured), so a
7//! client cannot spoof its IP unless the request came through a trusted
8//! proxy. This plugin never fails at execution time: any non-match, missing
9//! header, or unparsable address is a silent passthrough.
10
11use 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
20/// Replaces `context.request.remote_addr` with the address carried by a
21/// configured variable, guarded by a trusted-proxy allowlist.
22///
23/// `source: http_x_forwarded_for` gets APISIX's special `X-Forwarded-For`
24/// handling (last header value, comma-splitting, optional recursive walk);
25/// any other `source` is resolved through the standard variable resolver
26/// ([`crate::vars::resolve`]), e.g. `http_x_real_ip` or `arg_realip`.
27pub struct RealIpPlugin {
28    /// Variable name the real address is read from.
29    source: String,
30    /// Only rewrite when the direct peer matches one of these networks.
31    /// `None` means "always rewrite" (mirrors APISIX, where the field is
32    /// optional).
33    trusted_addresses: Option<Vec<IpNet>>,
34    /// X-Forwarded-For only: walk the list right-to-left, skipping trusted
35    /// hops, instead of taking the last (rightmost) entry.
36    recursive: bool,
37}
38
39/// Parses `ip`, `ip:port`, `[v6]` or `[v6]:port`. Returns `None` for
40/// unparsable addresses and out-of-range ports (`0` is rejected, matching
41/// APISIX's port validation).
42fn parse_ip_port(addr: &str) -> Option<(IpAddr, Option<u16>)> {
43    let addr = addr.trim();
44
45    // Bracketed IPv6: [::1] or [::1]:8080
46    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    // Bare IPv4 or bare (unbracketed) IPv6.
60    if let Ok(ip) = addr.parse::<IpAddr>() {
61        return Some((ip, None));
62    }
63
64    // ip:port — an unbracketed IPv6 with a port is ambiguous, reject it.
65    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    /// Builds the plugin from node config.
76    ///
77    /// Accepted keys:
78    /// - `source` (string, **required**): variable holding the real address,
79    ///   e.g. `http_x_real_ip` or `http_x_forwarded_for` (the latter gets
80    ///   comma-list handling). See [`crate::vars::resolve`] for the variable
81    ///   namespace.
82    /// - `trusted_addresses` (array of IPs/CIDRs, optional): the rewrite only
83    ///   applies when the direct peer address matches one of these. When
84    ///   omitted the rewrite always applies. An empty array or an invalid
85    ///   IP/CIDR is a config error.
86    /// - `recursive` (bool, default `false`): for `http_x_forwarded_for` with
87    ///   `trusted_addresses`, walk the list from the rightmost entry, skip
88    ///   trusted hops, and take the first untrusted address (falling back to
89    ///   the leftmost entry when every hop is trusted). When `false`, the
90    ///   last (rightmost) entry is used.
91    ///
92    /// ```yaml
93    /// type: real-ip
94    /// config:
95    ///   source: http_x_forwarded_for
96    ///   trusted_addresses: ["127.0.0.0/24", "10.0.0.0/8"]
97    ///   recursive: true
98    /// ```
99    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    /// True when `ip` is inside one of the trusted networks. Always false
148    /// when no `trusted_addresses` are configured (callers guard on that).
149    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    /// Extracts the candidate real address from the configured source,
156    /// mirroring APISIX's `get_addr`.
157    fn get_addr(&self, ctx: &Context) -> Option<String> {
158        if self.source == "http_x_forwarded_for" {
159            // APISIX reads the *last* X-Forwarded-For header value when the
160            // header repeats, then splits that value on commas.
161            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                // Walk right-to-left (excluding the leftmost entry), taking
170                // the first hop that is not a trusted proxy; an unparsable
171                // entry counts as untrusted, matching APISIX's matcher.
172                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            // Non-recursive: the last (rightmost) entry.
182            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        // Only rewrite when the DIRECT peer is a trusted proxy.
208        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            // Bad address in the source variable: leave remote_addr alone.
222            return passthrough(ctx);
223        };
224
225        // Keep the original peer port when the source carries none.
226        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        // source is required
273        assert!(RealIpPlugin::from_config(&HashMap::new()).is_err());
274
275        // invalid trusted address rejected at config load
276        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        // empty trusted_addresses rejected (APISIX minItems: 1)
283        assert!(RealIpPlugin::from_config(&config(serde_json::json!({
284            "source": "http_x_real_ip",
285            "trusted_addresses": []
286        })))
287        .is_err());
288
289        // valid config accepted
290        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        // Source had no port: the original peer port is kept.
313        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        // Header absent
342        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        // Header present but not an IP
347        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        // Rightmost hops are trusted proxies; the first untrusted one from
383        // the right is the client address.
384        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        // Every hop trusted: fall back to the leftmost entry.
393        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        // No trusted_addresses: rewrite always applies (APISIX semantics).
410        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        // Bare v6 source, original peer port kept, bracketed for ip:port form.
441        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); // port out of range
460        assert_eq!(parse_ip_port("1.2.3.4:99999"), None);
461        assert_eq!(parse_ip_port("nonsense"), None);
462    }
463}