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//! error routed through the node's error port.
6
7use async_trait::async_trait;
8use bytes::Bytes;
9use std::collections::HashMap;
10use std::net::IpAddr;
11
12use crate::context::{Context, GatewayError};
13use crate::plugins::{Plugin, PluginExecutionError, 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(
120        &self,
121        ctx: Context,
122        _named_inputs: &HashMap<String, serde_json::Value>,
123    ) -> PluginResult {
124        let addr = &ctx.request.remote_addr;
125
126        // Deny list takes precedence
127        if !self.deny.is_empty() && Self::ip_matches(&self.deny, addr) {
128            let mut ctx = ctx;
129            ctx.response.status_code = 403;
130            ctx.response.body =
131                Bytes::from(r#"{"error": "forbidden", "message": "IP address denied"}"#);
132            ctx.response.headers.insert(
133                "content-type".to_string(),
134                vec!["application/json".to_string()],
135            );
136            return Err(PluginExecutionError {
137                context: ctx,
138                error: GatewayError {
139                    node_id: String::new(),
140                    code: "IP_DENIED".to_string(),
141                    message: "IP address denied".to_string(),
142                    metadata: HashMap::new(),
143                },
144            });
145        }
146
147        // If allow list is non-empty, IP must be in it
148        if !self.allow.is_empty() && !Self::ip_matches(&self.allow, addr) {
149            let mut ctx = ctx;
150            ctx.response.status_code = 403;
151            ctx.response.body =
152                Bytes::from(r#"{"error": "forbidden", "message": "IP address not allowed"}"#);
153            ctx.response.headers.insert(
154                "content-type".to_string(),
155                vec!["application/json".to_string()],
156            );
157            return Err(PluginExecutionError {
158                context: ctx,
159                error: GatewayError {
160                    node_id: String::new(),
161                    code: "IP_NOT_ALLOWED".to_string(),
162                    message: "IP address not allowed".to_string(),
163                    metadata: HashMap::new(),
164                },
165            });
166        }
167
168        Ok(PluginOutput {
169            context: ctx,
170            named_outputs: HashMap::new(),
171        })
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    //! Behavioral tests translated from Apache APISIX's `t/plugin/ip-restriction.t`,
178    //! adapted to featherbit's keys (`allow`/`deny` for APISIX's
179    //! `whitelist`/`blacklist`). featherbit does not implement APISIX's custom
180    //! `message` / `response_code` (its rejection is a fixed 403), so APISIX
181    //! TEST 26-35 are deviations and are not mirrored.
182    use super::*;
183    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
184
185    fn ctx(remote_addr: &str) -> Context {
186        Context {
187            request: GatewayRequest {
188                method: "GET".to_string(),
189                path: "/hello".to_string(),
190                host: "h".to_string(),
191                scheme: "http".to_string(),
192                headers: HashMap::new(),
193                query_params: HashMap::new(),
194                body: Bytes::new(),
195                remote_addr: remote_addr.to_string(),
196                protocol: Protocol::Http1,
197            },
198            response: GatewayResponse {
199                status_code: 0,
200                headers: HashMap::new(),
201                body: Bytes::new(),
202            },
203            message: HashMap::new(),
204            errors: Vec::new(),
205        }
206    }
207
208    fn plugin(config: serde_json::Value) -> IpRestrictionPlugin {
209        let map: HashMap<String, serde_json::Value> =
210            config.as_object().unwrap().clone().into_iter().collect();
211        IpRestrictionPlugin::from_config(&map).unwrap()
212    }
213
214    /// APISIX TEST 8: an IP inside a whitelisted /24 CIDR is allowed.
215    #[tokio::test]
216    async fn test_allow_cidr_match() {
217        let p = plugin(serde_json::json!({ "allow": ["127.0.0.0/24", "113.74.26.106"] }));
218        assert!(p.execute(ctx("127.0.0.1:5"), &HashMap::new()).await.is_ok());
219    }
220
221    /// APISIX TEST 9: an exact whitelisted IP is allowed.
222    #[tokio::test]
223    async fn test_allow_exact_match() {
224        let p = plugin(serde_json::json!({ "allow": ["127.0.0.0/24", "113.74.26.106"] }));
225        assert!(p
226            .execute(ctx("113.74.26.106:80"), &HashMap::new())
227            .await
228            .is_ok());
229    }
230
231    /// APISIX TEST 10: an IP not on the whitelist is rejected with 403.
232    #[tokio::test]
233    async fn test_allow_unlisted_rejected() {
234        let p = plugin(serde_json::json!({ "allow": ["127.0.0.0/24"] }));
235        let err = p
236            .execute(ctx("114.114.114.114:5"), &HashMap::new())
237            .await
238            .unwrap_err();
239        assert_eq!(err.error.code, "IP_NOT_ALLOWED");
240        assert_eq!(err.context.response.status_code, 403);
241    }
242
243    /// APISIX TEST 13-14: a blacklisted IP (CIDR or exact) is rejected with 403.
244    #[tokio::test]
245    async fn test_deny_match_rejected() {
246        let p = plugin(serde_json::json!({ "deny": ["127.0.0.0/24", "113.74.26.106"] }));
247        let by_cidr = p
248            .execute(ctx("127.0.0.1:5"), &HashMap::new())
249            .await
250            .unwrap_err();
251        assert_eq!(by_cidr.error.code, "IP_DENIED");
252        assert_eq!(by_cidr.context.response.status_code, 403);
253        assert!(p
254            .execute(ctx("113.74.26.106:5"), &HashMap::new())
255            .await
256            .is_err());
257    }
258
259    /// APISIX TEST 15: an IP not on the blacklist is allowed.
260    #[tokio::test]
261    async fn test_deny_unlisted_allowed() {
262        let p = plugin(serde_json::json!({ "deny": ["127.0.0.0/24"] }));
263        assert!(p
264            .execute(ctx("114.114.114.114:5"), &HashMap::new())
265            .await
266            .is_ok());
267    }
268
269    /// APISIX TEST 22-23: IPv6 blacklisting, exact and by CIDR (fe80::/32).
270    #[tokio::test]
271    async fn test_deny_ipv6() {
272        let p = plugin(serde_json::json!({ "deny": ["::1", "fe80::/32"] }));
273        assert!(p.execute(ctx("::1"), &HashMap::new()).await.is_err()); // exact
274        assert!(p.execute(ctx("fe80::1:1"), &HashMap::new()).await.is_err()); // in fe80::/32
275    }
276
277    /// APISIX TEST 21: an IPv4 client is allowed by an IPv6-only blacklist.
278    #[tokio::test]
279    async fn test_deny_ipv6_allows_ipv4() {
280        let p = plugin(serde_json::json!({ "deny": ["::1", "fe80::/32"] }));
281        assert!(p.execute(ctx("127.0.0.1:5"), &HashMap::new()).await.is_ok());
282    }
283
284    /// Deny takes precedence over allow when an IP is on both lists.
285    #[tokio::test]
286    async fn test_deny_precedes_allow() {
287        let p = plugin(serde_json::json!({ "allow": ["10.0.0.1"], "deny": ["10.0.0.1"] }));
288        let err = p
289            .execute(ctx("10.0.0.1:5"), &HashMap::new())
290            .await
291            .unwrap_err();
292        assert_eq!(err.error.code, "IP_DENIED");
293    }
294}