Skip to main content

featherbit/plugins/native/
ip_restriction.rs

1//! IP allow/deny list plugin (`ip-restriction`).
2//!
3//! Filters requests by the client's remote address against configured allow
4//! and deny lists (exact IPs or CIDR blocks); denied clients receive a 403
5//! response routed through the node's `denied` port.
6
7use async_trait::async_trait;
8use bytes::Bytes;
9use std::collections::HashMap;
10use std::net::IpAddr;
11
12use crate::context::Context;
13use crate::plugins::{Plugin, PluginOutput, PluginResult};
14
15/// Restricts access based on the request's `remote_addr`.
16///
17/// The deny list is evaluated first (a match rejects with error code
18/// `IP_DENIED`); when the allow list is non-empty, the client IP must match
19/// one of its entries or the request is rejected with `IP_NOT_ALLOWED`.
20/// Rejections produce a 403 JSON response. Does not write to
21/// `context.message`.
22pub struct IpRestrictionPlugin {
23    /// Allowed IPs/CIDRs; when non-empty, acts as a whitelist.
24    allow: Vec<String>,
25    /// Denied IPs/CIDRs; checked before the allow list.
26    deny: Vec<String>,
27}
28
29impl IpRestrictionPlugin {
30    /// Builds the plugin from node config. Never fails; both lists default to
31    /// empty (which permits all traffic).
32    ///
33    /// Accepted keys:
34    /// - `allow` (array of strings, default `[]`): IPs or CIDR blocks
35    ///   (e.g. `"10.0.0.0/8"`) that may pass when the list is non-empty.
36    /// - `deny` (array of strings, default `[]`): IPs or CIDR blocks that are
37    ///   always rejected; takes precedence over `allow`.
38    ///
39    /// ```yaml
40    /// type: ip-restriction
41    /// config:
42    ///   allow: ["10.0.0.0/8", "192.168.1.5"]
43    ///   deny: ["10.1.2.3"]
44    /// ```
45    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
46        let allow = config
47            .get("allow")
48            .and_then(|v| v.as_array())
49            .map(|seq| {
50                seq.iter()
51                    .filter_map(|v| v.as_str().map(String::from))
52                    .collect()
53            })
54            .unwrap_or_default();
55
56        let deny = config
57            .get("deny")
58            .and_then(|v| v.as_array())
59            .map(|seq| {
60                seq.iter()
61                    .filter_map(|v| v.as_str().map(String::from))
62                    .collect()
63            })
64            .unwrap_or_default();
65
66        Ok(Self { allow, deny })
67    }
68
69    /// Checks whether `addr` (an IP, optionally with a `:port` suffix)
70    /// matches any pattern — either an exact IP or a `net/bits` CIDR block
71    /// (IPv4 and IPv6). Unparseable addresses never match.
72    fn ip_matches(patterns: &[String], addr: &str) -> bool {
73        let ip: IpAddr = match addr.parse() {
74            Ok(ip) => ip,
75            Err(_) => {
76                // Try stripping port
77                match addr.rsplit_once(':').and_then(|(ip, _)| ip.parse().ok()) {
78                    Some(ip) => ip,
79                    None => return false,
80                }
81            }
82        };
83
84        for pattern in patterns {
85            if pattern == addr || pattern == &ip.to_string() {
86                return true;
87            }
88            // Simple CIDR check
89            if let Some((net, bits)) = pattern.split_once('/') {
90                if let (Ok(net_ip), Ok(bits)) = (net.parse::<IpAddr>(), bits.parse::<u32>()) {
91                    match (net_ip, ip) {
92                        (IpAddr::V4(net), IpAddr::V4(addr)) => {
93                            let mask = if bits == 0 { 0 } else { !0u32 << (32 - bits) };
94                            if u32::from(net) & mask == u32::from(addr) & mask {
95                                return true;
96                            }
97                        }
98                        (IpAddr::V6(net), IpAddr::V6(addr)) => {
99                            let mask = if bits == 0 { 0 } else { !0u128 << (128 - bits) };
100                            if u128::from(net) & mask == u128::from(addr) & mask {
101                                return true;
102                            }
103                        }
104                        _ => {}
105                    }
106                }
107            }
108        }
109        false
110    }
111}
112
113#[async_trait]
114impl Plugin for IpRestrictionPlugin {
115    fn plugin_type(&self) -> &str {
116        "ip-restriction"
117    }
118
119    async fn execute(&self, ctx: Context) -> PluginResult {
120        let addr = &ctx.request.remote_addr;
121
122        // Deny list takes precedence
123        if !self.deny.is_empty() && Self::ip_matches(&self.deny, addr) {
124            let mut ctx = ctx;
125            ctx.response.status_code = 403;
126            ctx.response.body =
127                Bytes::from(r#"{"error": "forbidden", "message": "IP address denied"}"#);
128            ctx.response.headers.insert(
129                "content-type".to_string(),
130                vec!["application/json".to_string()],
131            );
132            return Ok(PluginOutput::on_port(ctx, "denied"));
133        }
134
135        // If allow list is non-empty, IP must be in it
136        if !self.allow.is_empty() && !Self::ip_matches(&self.allow, addr) {
137            let mut ctx = ctx;
138            ctx.response.status_code = 403;
139            ctx.response.body =
140                Bytes::from(r#"{"error": "forbidden", "message": "IP address not allowed"}"#);
141            ctx.response.headers.insert(
142                "content-type".to_string(),
143                vec!["application/json".to_string()],
144            );
145            return Ok(PluginOutput::on_port(ctx, "denied"));
146        }
147
148        Ok(PluginOutput::success(ctx))
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    //! Behavioral tests translated from Apache APISIX's `t/plugin/ip-restriction.t`,
155    //! adapted to featherbit's keys (`allow`/`deny` for APISIX's
156    //! `whitelist`/`blacklist`). featherbit does not implement APISIX's custom
157    //! `message` / `response_code` (its rejection is a fixed 403), so APISIX
158    //! TEST 26-35 are deviations and are not mirrored.
159    use super::*;
160    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
161
162    fn ctx(remote_addr: &str) -> Context {
163        Context {
164            request: GatewayRequest {
165                method: "GET".to_string(),
166                path: "/hello".to_string(),
167                host: "h".to_string(),
168                scheme: "http".to_string(),
169                headers: HashMap::new(),
170                query_params: HashMap::new(),
171                body: Bytes::new(),
172                remote_addr: remote_addr.to_string(),
173                protocol: Protocol::Http1,
174            },
175            response: GatewayResponse {
176                status_code: 0,
177                headers: HashMap::new(),
178                body: Bytes::new(),
179                stream: None,
180            },
181            message: HashMap::new(),
182            errors: Vec::new(),
183        }
184    }
185
186    fn plugin(config: serde_json::Value) -> IpRestrictionPlugin {
187        let map: HashMap<String, serde_json::Value> =
188            config.as_object().unwrap().clone().into_iter().collect();
189        IpRestrictionPlugin::from_config(&map).unwrap()
190    }
191
192    /// APISIX TEST 8: an IP inside a whitelisted /24 CIDR is allowed.
193    #[tokio::test]
194    async fn test_allow_cidr_match() {
195        let p = plugin(serde_json::json!({ "allow": ["127.0.0.0/24", "113.74.26.106"] }));
196        assert!(p.execute(ctx("127.0.0.1:5")).await.unwrap().port.is_none());
197    }
198
199    /// APISIX TEST 9: an exact whitelisted IP is allowed.
200    #[tokio::test]
201    async fn test_allow_exact_match() {
202        let p = plugin(serde_json::json!({ "allow": ["127.0.0.0/24", "113.74.26.106"] }));
203        assert!(p
204            .execute(ctx("113.74.26.106:80"))
205            .await
206            .unwrap()
207            .port
208            .is_none());
209    }
210
211    /// APISIX TEST 10: an IP not on the whitelist is rejected on `denied`.
212    #[tokio::test]
213    async fn test_allow_unlisted_rejected() {
214        let p = plugin(serde_json::json!({ "allow": ["127.0.0.0/24"] }));
215        let out = p.execute(ctx("114.114.114.114:5")).await.unwrap();
216        assert_eq!(out.port, Some("denied"));
217        assert_eq!(out.context.response.status_code, 403);
218    }
219
220    /// APISIX TEST 13-14: a blacklisted IP (CIDR or exact) is rejected on `denied`.
221    #[tokio::test]
222    async fn test_deny_match_rejected() {
223        let p = plugin(serde_json::json!({ "deny": ["127.0.0.0/24", "113.74.26.106"] }));
224        let by_cidr = p.execute(ctx("127.0.0.1:5")).await.unwrap();
225        assert_eq!(by_cidr.port, Some("denied"));
226        assert_eq!(by_cidr.context.response.status_code, 403);
227        let out = p.execute(ctx("113.74.26.106:5")).await.unwrap();
228        assert_eq!(out.port, Some("denied"));
229    }
230
231    /// APISIX TEST 15: an IP not on the blacklist is allowed.
232    #[tokio::test]
233    async fn test_deny_unlisted_allowed() {
234        let p = plugin(serde_json::json!({ "deny": ["127.0.0.0/24"] }));
235        assert!(p
236            .execute(ctx("114.114.114.114:5"))
237            .await
238            .unwrap()
239            .port
240            .is_none());
241    }
242
243    /// APISIX TEST 22-23: IPv6 blacklisting, exact and by CIDR (fe80::/32).
244    #[tokio::test]
245    async fn test_deny_ipv6() {
246        let p = plugin(serde_json::json!({ "deny": ["::1", "fe80::/32"] }));
247        assert_eq!(p.execute(ctx("::1")).await.unwrap().port, Some("denied")); // exact
248        assert_eq!(
249            p.execute(ctx("fe80::1:1")).await.unwrap().port,
250            Some("denied")
251        ); // in fe80::/32
252    }
253
254    /// APISIX TEST 21: an IPv4 client is allowed by an IPv6-only blacklist.
255    #[tokio::test]
256    async fn test_deny_ipv6_allows_ipv4() {
257        let p = plugin(serde_json::json!({ "deny": ["::1", "fe80::/32"] }));
258        assert!(p.execute(ctx("127.0.0.1:5")).await.unwrap().port.is_none());
259    }
260
261    /// Deny takes precedence over allow when an IP is on both lists.
262    #[tokio::test]
263    async fn test_deny_precedes_allow() {
264        let p = plugin(serde_json::json!({ "allow": ["10.0.0.1"], "deny": ["10.0.0.1"] }));
265        let out = p.execute(ctx("10.0.0.1:5")).await.unwrap();
266        assert_eq!(out.port, Some("denied"));
267    }
268}