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