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, GatewayError};
14use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
15
16pub struct UriBlockerPlugin {
23 block_rules: Vec<Regex>,
25 rejected_code: u16,
27 rejected_msg: Option<String>,
29}
30
31impl UriBlockerPlugin {
32 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 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 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}