Skip to main content

featherbit/plugins/native/
uri_blocker.rs

1//! URI block-rule plugin (`uri-blocker`).
2//!
3//! Port of APISIX's `uri-blocker`: matches the request URI (path plus query
4//! string) against a list of regexes and rejects matching requests with a
5//! configurable status code. Rejections are routed through the node's error
6//! port with error code `URI_BLOCKED`.
7
8use async_trait::async_trait;
9use bytes::Bytes;
10use regex::Regex;
11use std::collections::HashMap;
12
13use crate::context::{Context, GatewayError};
14use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
15
16/// Blocks requests whose URI matches any configured `block_rules` regex.
17///
18/// The matched subject is `crate::vars`' `request_uri` — the path plus
19/// `?query` when query parameters exist (APISIX matches `ctx.var.request_uri`).
20/// Regexes are compiled once at config load; `case_insensitive` prefixes each
21/// with `(?i)` like APISIX does with its concatenated rule string.
22pub struct UriBlockerPlugin {
23    /// Compiled block rules; a request matching any of them is rejected.
24    block_rules: Vec<Regex>,
25    /// HTTP status for rejections (default 403).
26    rejected_code: u16,
27    /// Optional rejection body message; when unset the response body is empty.
28    rejected_msg: Option<String>,
29}
30
31impl UriBlockerPlugin {
32    /// Builds the plugin from node config.
33    ///
34    /// Accepted keys:
35    /// - `block_rules` (array of regex strings, **required**, non-empty):
36    ///   rules tested against the request URI (path + query string). Invalid
37    ///   regexes and empty lists are config errors.
38    /// - `rejected_code` (integer 200–599, default `403`): rejection status.
39    /// - `rejected_msg` (string, optional): when set, rejections carry a JSON
40    ///   body `{"error_msg": ...}`; when unset the body is empty (APISIX
41    ///   parity).
42    /// - `case_insensitive` (bool, default `false`): match rules
43    ///   case-insensitively.
44    ///
45    /// ```yaml
46    /// type: uri-blocker
47    /// config:
48    ///   block_rules: ["root.exe", "root.m+", "^/admin/"]
49    ///   rejected_code: 404
50    ///   case_insensitive: true
51    /// ```
52    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
53        let case_insensitive = config
54            .get("case_insensitive")
55            .and_then(|v| v.as_bool())
56            .unwrap_or(false);
57
58        let rules = config
59            .get("block_rules")
60            .and_then(|v| v.as_array())
61            .ok_or_else(|| {
62                "uri-blocker: block_rules is required and must be an array".to_string()
63            })?;
64        if rules.is_empty() {
65            return Err("uri-blocker: block_rules must not be empty".to_string());
66        }
67
68        let block_rules = rules
69            .iter()
70            .map(|item| {
71                let s = item
72                    .as_str()
73                    .ok_or_else(|| "block_rules entries must be strings".to_string())?;
74                if s.is_empty() {
75                    return Err("block_rules entries must be non-empty".to_string());
76                }
77                let pattern = if case_insensitive {
78                    format!("(?i){}", s)
79                } else {
80                    s.to_string()
81                };
82                Regex::new(&pattern)
83                    .map_err(|e| format!("invalid regex '{}' in block_rules: {}", s, e))
84            })
85            .collect::<Result<Vec<_>, _>>()?;
86
87        let rejected_code = match config.get("rejected_code") {
88            None => 403,
89            Some(v) => {
90                let code = v
91                    .as_u64()
92                    .ok_or_else(|| "rejected_code must be an integer".to_string())?;
93                if !(200..=599).contains(&code) {
94                    return Err("rejected_code must be between 200 and 599".to_string());
95                }
96                code as u16
97            }
98        };
99
100        Ok(Self {
101            block_rules,
102            rejected_code,
103            rejected_msg: config
104                .get("rejected_msg")
105                .and_then(|v| v.as_str())
106                .map(String::from),
107        })
108    }
109}
110
111#[async_trait]
112impl Plugin for UriBlockerPlugin {
113    fn plugin_type(&self) -> &str {
114        "uri-blocker"
115    }
116
117    async fn execute(
118        &self,
119        ctx: Context,
120        _named_inputs: &HashMap<String, serde_json::Value>,
121    ) -> PluginResult {
122        let request_uri = crate::vars::resolve(&ctx, "request_uri")
123            .map(|v| v.into_owned())
124            .unwrap_or_else(|| ctx.request.path.clone());
125
126        if self.block_rules.iter().any(|re| re.is_match(&request_uri)) {
127            let mut ctx = ctx;
128            ctx.response.status_code = self.rejected_code;
129            if let Some(ref msg) = self.rejected_msg {
130                ctx.response.body =
131                    Bytes::from(serde_json::json!({ "error_msg": msg }).to_string());
132                ctx.response.headers.insert(
133                    "content-type".to_string(),
134                    vec!["application/json".to_string()],
135                );
136            } else {
137                ctx.response.body = Bytes::new();
138            }
139            return Err(PluginExecutionError {
140                context: ctx,
141                error: GatewayError {
142                    node_id: String::new(),
143                    code: "URI_BLOCKED".to_string(),
144                    message: self
145                        .rejected_msg
146                        .clone()
147                        .unwrap_or_else(|| "request URI is blocked".to_string()),
148                    metadata: HashMap::new(),
149                },
150            });
151        }
152
153        Ok(PluginOutput {
154            context: ctx,
155            named_outputs: HashMap::new(),
156        })
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
164
165    fn test_context(path: &str, query: &[(&str, &str)]) -> Context {
166        let mut query_params: HashMap<String, Vec<String>> = HashMap::new();
167        for (k, v) in query {
168            query_params
169                .entry(k.to_string())
170                .or_default()
171                .push(v.to_string());
172        }
173        Context {
174            request: GatewayRequest {
175                method: "GET".to_string(),
176                path: path.to_string(),
177                host: "localhost".to_string(),
178                scheme: "http".to_string(),
179                headers: HashMap::new(),
180                query_params,
181                body: Bytes::new(),
182                remote_addr: "127.0.0.1:12345".to_string(),
183                protocol: Protocol::Http1,
184            },
185            response: GatewayResponse {
186                status_code: 0,
187                headers: HashMap::new(),
188                body: Bytes::new(),
189            },
190            message: HashMap::new(),
191            errors: Vec::new(),
192        }
193    }
194
195    fn config(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
196        serde_json::from_value(json).unwrap()
197    }
198
199    #[test]
200    fn test_config_requires_block_rules() {
201        assert!(UriBlockerPlugin::from_config(&config(serde_json::json!({}))).is_err());
202        assert!(UriBlockerPlugin::from_config(&config(serde_json::json!({
203            "block_rules": []
204        })))
205        .is_err());
206        assert!(UriBlockerPlugin::from_config(&config(serde_json::json!({
207            "block_rules": ["("]
208        })))
209        .is_err());
210        assert!(UriBlockerPlugin::from_config(&config(serde_json::json!({
211            "block_rules": [42]
212        })))
213        .is_err());
214        assert!(UriBlockerPlugin::from_config(&config(serde_json::json!({
215            "block_rules": ["^/admin"], "rejected_code": 99
216        })))
217        .is_err());
218        assert!(UriBlockerPlugin::from_config(&config(serde_json::json!({
219            "block_rules": ["^/admin"]
220        })))
221        .is_ok());
222    }
223
224    #[tokio::test]
225    async fn test_blocks_matching_path() {
226        let plugin = UriBlockerPlugin::from_config(&config(serde_json::json!({
227            "block_rules": ["root.exe", "^/admin/"]
228        })))
229        .unwrap();
230
231        let err = plugin
232            .execute(test_context("/admin/users", &[]), &HashMap::new())
233            .await
234            .unwrap_err();
235        assert_eq!(err.error.code, "URI_BLOCKED");
236        assert_eq!(err.context.response.status_code, 403);
237        // no rejected_msg -> empty body (APISIX parity)
238        assert!(err.context.response.body.is_empty());
239
240        assert!(plugin
241            .execute(test_context("/public", &[]), &HashMap::new())
242            .await
243            .is_ok());
244    }
245
246    #[tokio::test]
247    async fn test_matches_query_string() {
248        let plugin = UriBlockerPlugin::from_config(&config(serde_json::json!({
249            "block_rules": ["root.exe"]
250        })))
251        .unwrap();
252
253        // rule matches inside the query string, like APISIX's request_uri
254        assert!(plugin
255            .execute(
256                test_context("/download", &[("file", "root.exe")]),
257                &HashMap::new()
258            )
259            .await
260            .is_err());
261        assert!(plugin
262            .execute(
263                test_context("/download", &[("file", "notes.txt")]),
264                &HashMap::new()
265            )
266            .await
267            .is_ok());
268    }
269
270    #[tokio::test]
271    async fn test_case_insensitive() {
272        let sensitive = UriBlockerPlugin::from_config(&config(serde_json::json!({
273            "block_rules": ["/admin"]
274        })))
275        .unwrap();
276        assert!(sensitive
277            .execute(test_context("/ADMIN/panel", &[]), &HashMap::new())
278            .await
279            .is_ok());
280
281        let insensitive = UriBlockerPlugin::from_config(&config(serde_json::json!({
282            "block_rules": ["/admin"], "case_insensitive": true
283        })))
284        .unwrap();
285        assert!(insensitive
286            .execute(test_context("/ADMIN/panel", &[]), &HashMap::new())
287            .await
288            .is_err());
289    }
290
291    #[tokio::test]
292    async fn test_custom_code_and_message() {
293        let plugin = UriBlockerPlugin::from_config(&config(serde_json::json!({
294            "block_rules": ["^/admin"], "rejected_code": 404, "rejected_msg": "not found"
295        })))
296        .unwrap();
297
298        let err = plugin
299            .execute(test_context("/admin", &[]), &HashMap::new())
300            .await
301            .unwrap_err();
302        assert_eq!(err.context.response.status_code, 404);
303        let body: serde_json::Value = serde_json::from_slice(&err.context.response.body).unwrap();
304        assert_eq!(body["error_msg"], "not found");
305    }
306}