featherbit/plugins/native/
uri_blocker.rs1use 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
17pub struct UriBlockerPlugin {
24 block_rules: Vec<Regex>,
26 rejected_code: u16,
28 rejected_msg: Option<Template>,
33}
34
35impl UriBlockerPlugin {
36 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 .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 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 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}