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(&self, mut ctx: Context) -> PluginResult {
196        let passthrough = |ctx: Context| Ok(PluginOutput::success(ctx));
197
198        // Only rewrite when the DIRECT peer is a trusted proxy.
199        let direct = parse_ip_port(&ctx.request.remote_addr);
200        if self.trusted_addresses.is_some() {
201            match direct {
202                Some((ip, _)) if self.is_trusted(ip) => {}
203                _ => return passthrough(ctx),
204            }
205        }
206
207        let Some(addr) = self.get_addr(&ctx) else {
208            return passthrough(ctx);
209        };
210
211        let Some((ip, port)) = parse_ip_port(&addr) else {
212            // Bad address in the source variable: leave remote_addr alone.
213            return passthrough(ctx);
214        };
215
216        // Keep the original peer port when the source carries none.
217        let port = port.or_else(|| direct.and_then(|(_, p)| p));
218        ctx.request.remote_addr = match (ip, port) {
219            (IpAddr::V6(v6), Some(p)) => format!("[{}]:{}", v6, p),
220            (ip, Some(p)) => format!("{}:{}", ip, p),
221            (ip, None) => ip.to_string(),
222        };
223
224        passthrough(ctx)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
232    use bytes::Bytes;
233
234    fn test_context(remote_addr: &str) -> Context {
235        Context {
236            request: GatewayRequest {
237                method: "GET".to_string(),
238                path: "/".to_string(),
239                host: "localhost".to_string(),
240                scheme: "http".to_string(),
241                headers: HashMap::new(),
242                query_params: HashMap::new(),
243                body: Bytes::new(),
244                remote_addr: remote_addr.to_string(),
245                protocol: Protocol::Http1,
246            },
247            response: GatewayResponse {
248                status_code: 0,
249                headers: HashMap::new(),
250                body: Bytes::new(),
251                stream: None,
252            },
253            message: HashMap::new(),
254            errors: Vec::new(),
255        }
256    }
257
258    fn config(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
259        serde_json::from_value(json).unwrap()
260    }
261
262    #[test]
263    fn test_real_ip_config_validation() {
264        // source is required
265        assert!(RealIpPlugin::from_config(&HashMap::new()).is_err());
266
267        // invalid trusted address rejected at config load
268        assert!(RealIpPlugin::from_config(&config(serde_json::json!({
269            "source": "http_x_real_ip",
270            "trusted_addresses": ["not-an-ip"]
271        })))
272        .is_err());
273
274        // empty trusted_addresses rejected (APISIX minItems: 1)
275        assert!(RealIpPlugin::from_config(&config(serde_json::json!({
276            "source": "http_x_real_ip",
277            "trusted_addresses": []
278        })))
279        .is_err());
280
281        // valid config accepted
282        assert!(RealIpPlugin::from_config(&config(serde_json::json!({
283            "source": "http_x_forwarded_for",
284            "trusted_addresses": ["10.0.0.0/8", "127.0.0.1"],
285            "recursive": true
286        })))
287        .is_ok());
288    }
289
290    #[tokio::test]
291    async fn test_real_ip_rewrites_from_x_real_ip() {
292        let plugin = RealIpPlugin::from_config(&config(serde_json::json!({
293            "source": "http_x_real_ip",
294            "trusted_addresses": ["127.0.0.0/24"]
295        })))
296        .unwrap();
297
298        let mut ctx = test_context("127.0.0.1:5000");
299        ctx.request
300            .headers
301            .insert("x-real-ip".to_string(), vec!["203.0.113.7".to_string()]);
302
303        let result = plugin.execute(ctx).await.unwrap();
304        // Source had no port: the original peer port is kept.
305        assert_eq!(result.context.request.remote_addr, "203.0.113.7:5000");
306    }
307
308    #[tokio::test]
309    async fn test_real_ip_untrusted_peer_is_passthrough() {
310        let plugin = RealIpPlugin::from_config(&config(serde_json::json!({
311            "source": "http_x_real_ip",
312            "trusted_addresses": ["127.0.0.0/24"]
313        })))
314        .unwrap();
315
316        let mut ctx = test_context("198.51.100.9:5000");
317        ctx.request
318            .headers
319            .insert("x-real-ip".to_string(), vec!["203.0.113.7".to_string()]);
320
321        let result = plugin.execute(ctx).await.unwrap();
322        assert_eq!(result.context.request.remote_addr, "198.51.100.9:5000");
323    }
324
325    #[tokio::test]
326    async fn test_real_ip_missing_or_bad_source_is_passthrough() {
327        let plugin = RealIpPlugin::from_config(&config(serde_json::json!({
328            "source": "http_x_real_ip",
329            "trusted_addresses": ["127.0.0.0/24"]
330        })))
331        .unwrap();
332
333        // Header absent
334        let ctx = test_context("127.0.0.1:5000");
335        let result = plugin.execute(ctx).await.unwrap();
336        assert_eq!(result.context.request.remote_addr, "127.0.0.1:5000");
337
338        // Header present but not an IP
339        let mut ctx = test_context("127.0.0.1:5000");
340        ctx.request
341            .headers
342            .insert("x-real-ip".to_string(), vec!["unknown".to_string()]);
343        let result = plugin.execute(ctx).await.unwrap();
344        assert_eq!(result.context.request.remote_addr, "127.0.0.1:5000");
345    }
346
347    #[tokio::test]
348    async fn test_real_ip_xff_non_recursive_takes_last() {
349        let plugin = RealIpPlugin::from_config(&config(serde_json::json!({
350            "source": "http_x_forwarded_for",
351            "trusted_addresses": ["127.0.0.0/24"]
352        })))
353        .unwrap();
354
355        let mut ctx = test_context("127.0.0.1:5000");
356        ctx.request.headers.insert(
357            "x-forwarded-for".to_string(),
358            vec!["203.0.113.7, 10.1.1.1, 10.2.2.2".to_string()],
359        );
360
361        let result = plugin.execute(ctx).await.unwrap();
362        assert_eq!(result.context.request.remote_addr, "10.2.2.2:5000");
363    }
364
365    #[tokio::test]
366    async fn test_real_ip_xff_recursive_skips_trusted_hops() {
367        let plugin = RealIpPlugin::from_config(&config(serde_json::json!({
368            "source": "http_x_forwarded_for",
369            "trusted_addresses": ["127.0.0.0/24", "10.0.0.0/8"],
370            "recursive": true
371        })))
372        .unwrap();
373
374        // Rightmost hops are trusted proxies; the first untrusted one from
375        // the right is the client address.
376        let mut ctx = test_context("127.0.0.1:5000");
377        ctx.request.headers.insert(
378            "x-forwarded-for".to_string(),
379            vec!["203.0.113.7, 10.1.1.1, 10.2.2.2".to_string()],
380        );
381        let result = plugin.execute(ctx).await.unwrap();
382        assert_eq!(result.context.request.remote_addr, "203.0.113.7:5000");
383
384        // Every hop trusted: fall back to the leftmost entry.
385        let mut ctx = test_context("127.0.0.1:5000");
386        ctx.request.headers.insert(
387            "x-forwarded-for".to_string(),
388            vec!["10.9.9.9, 10.1.1.1".to_string()],
389        );
390        let result = plugin.execute(ctx).await.unwrap();
391        assert_eq!(result.context.request.remote_addr, "10.9.9.9:5000");
392    }
393
394    #[tokio::test]
395    async fn test_real_ip_source_port_wins_over_peer_port() {
396        let plugin = RealIpPlugin::from_config(&config(serde_json::json!({
397            "source": "http_x_real_ip"
398        })))
399        .unwrap();
400
401        // No trusted_addresses: rewrite always applies (APISIX semantics).
402        let mut ctx = test_context("127.0.0.1:5000");
403        ctx.request.headers.insert(
404            "x-real-ip".to_string(),
405            vec!["203.0.113.7:8443".to_string()],
406        );
407        let result = plugin.execute(ctx).await.unwrap();
408        assert_eq!(result.context.request.remote_addr, "203.0.113.7:8443");
409    }
410
411    #[tokio::test]
412    async fn test_real_ip_ipv6_source() {
413        let plugin = RealIpPlugin::from_config(&config(serde_json::json!({
414            "source": "http_x_real_ip",
415            "trusted_addresses": ["127.0.0.0/24"]
416        })))
417        .unwrap();
418
419        let mut ctx = test_context("127.0.0.1:5000");
420        ctx.request.headers.insert(
421            "x-real-ip".to_string(),
422            vec!["[2001:db8::1]:9000".to_string()],
423        );
424        let result = plugin.execute(ctx).await.unwrap();
425        assert_eq!(result.context.request.remote_addr, "[2001:db8::1]:9000");
426
427        let mut ctx = test_context("127.0.0.1:5000");
428        ctx.request
429            .headers
430            .insert("x-real-ip".to_string(), vec!["2001:db8::1".to_string()]);
431        let result = plugin.execute(ctx).await.unwrap();
432        // Bare v6 source, original peer port kept, bracketed for ip:port form.
433        assert_eq!(result.context.request.remote_addr, "[2001:db8::1]:5000");
434    }
435
436    #[test]
437    fn test_real_ip_parse_ip_port_forms() {
438        assert_eq!(
439            parse_ip_port("1.2.3.4:80"),
440            Some(("1.2.3.4".parse().unwrap(), Some(80)))
441        );
442        assert_eq!(
443            parse_ip_port("1.2.3.4"),
444            Some(("1.2.3.4".parse().unwrap(), None))
445        );
446        assert_eq!(
447            parse_ip_port("[::1]:8080"),
448            Some(("::1".parse().unwrap(), Some(8080)))
449        );
450        assert_eq!(parse_ip_port("::1"), Some(("::1".parse().unwrap(), None)));
451        assert_eq!(parse_ip_port("1.2.3.4:0"), None); // port out of range
452        assert_eq!(parse_ip_port("1.2.3.4:99999"), None);
453        assert_eq!(parse_ip_port("nonsense"), None);
454    }
455}