featherbit/plugins/native/
ua_restriction.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 UaRestrictionPlugin {
24 allowlist: Vec<Regex>,
26 denylist: Vec<Regex>,
28 bypass_missing: bool,
30 rejected_code: u16,
32 rejected_msg: String,
34}
35
36fn parse_regex_list(
39 config: &HashMap<String, serde_json::Value>,
40 key: &str,
41) -> Result<Vec<Regex>, String> {
42 let Some(raw) = config.get(key) else {
43 return Ok(Vec::new());
44 };
45 let arr = raw
46 .as_array()
47 .ok_or_else(|| format!("{} must be an array of regex strings", key))?;
48 arr.iter()
49 .map(|item| {
50 let s = item
51 .as_str()
52 .ok_or_else(|| format!("{} entries must be strings", key))?;
53 if s.is_empty() {
54 return Err(format!("{} entries must be non-empty", key));
55 }
56 Regex::new(s).map_err(|e| format!("invalid regex '{}' in {}: {}", s, key, e))
57 })
58 .collect()
59}
60
61impl UaRestrictionPlugin {
62 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
84 let allowlist = parse_regex_list(config, "allowlist")?;
85 let denylist = parse_regex_list(config, "denylist")?;
86
87 if !allowlist.is_empty() && !denylist.is_empty() {
88 return Err("ua-restriction: allowlist and denylist cannot both be set".to_string());
89 }
90 if allowlist.is_empty() && denylist.is_empty() {
91 return Err(
92 "ua-restriction: exactly one of allowlist or denylist must be non-empty"
93 .to_string(),
94 );
95 }
96
97 let rejected_code = match config.get("rejected_code") {
98 None => 403,
99 Some(v) => {
100 let code = v
101 .as_u64()
102 .ok_or_else(|| "rejected_code must be an integer".to_string())?;
103 if !(200..=599).contains(&code) {
104 return Err("rejected_code must be between 200 and 599".to_string());
105 }
106 code as u16
107 }
108 };
109
110 let rejected_msg = config
111 .get("rejected_msg")
112 .and_then(|v| v.as_str())
113 .unwrap_or("Not allowed")
114 .to_string();
115
116 Ok(Self {
117 allowlist,
118 denylist,
119 bypass_missing: config
120 .get("bypass_missing")
121 .and_then(|v| v.as_bool())
122 .unwrap_or(false),
123 rejected_code,
124 rejected_msg,
125 })
126 }
127
128 fn reject(&self, mut ctx: Context) -> PluginResult {
131 ctx.response.status_code = self.rejected_code;
132 ctx.response.body =
133 Bytes::from(serde_json::json!({ "message": self.rejected_msg }).to_string());
134 ctx.response.headers.insert(
135 "content-type".to_string(),
136 vec!["application/json".to_string()],
137 );
138 Err(PluginExecutionError {
139 context: ctx,
140 error: GatewayError {
141 node_id: String::new(),
142 code: "UA_RESTRICTED".to_string(),
143 message: self.rejected_msg.clone(),
144 metadata: HashMap::new(),
145 },
146 })
147 }
148}
149
150#[async_trait]
151impl Plugin for UaRestrictionPlugin {
152 fn plugin_type(&self) -> &str {
153 "ua-restriction"
154 }
155
156 async fn execute(
157 &self,
158 ctx: Context,
159 _named_inputs: &HashMap<String, serde_json::Value>,
160 ) -> PluginResult {
161 let user_agents: Vec<&str> = ctx
162 .request
163 .headers
164 .get("user-agent")
165 .map(|values| values.iter().map(|v| v.trim()).collect())
166 .unwrap_or_default();
167
168 if user_agents.is_empty() {
169 if self.bypass_missing {
170 return Ok(PluginOutput {
171 context: ctx,
172 named_outputs: HashMap::new(),
173 });
174 }
175 return self.reject(ctx);
176 }
177
178 let passed = if !self.allowlist.is_empty() {
179 user_agents
181 .iter()
182 .any(|ua| self.allowlist.iter().any(|re| re.is_match(ua)))
183 } else {
184 !user_agents
186 .iter()
187 .any(|ua| self.denylist.iter().any(|re| re.is_match(ua)))
188 };
189
190 if !passed {
191 return self.reject(ctx);
192 }
193
194 Ok(PluginOutput {
195 context: ctx,
196 named_outputs: HashMap::new(),
197 })
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
205
206 fn test_context(user_agent: Option<&str>) -> Context {
207 let mut headers = HashMap::new();
208 if let Some(ua) = user_agent {
209 headers.insert("user-agent".to_string(), vec![ua.to_string()]);
210 }
211 Context {
212 request: GatewayRequest {
213 method: "GET".to_string(),
214 path: "/test".to_string(),
215 host: "localhost".to_string(),
216 scheme: "http".to_string(),
217 headers,
218 query_params: HashMap::new(),
219 body: Bytes::new(),
220 remote_addr: "127.0.0.1:12345".to_string(),
221 protocol: Protocol::Http1,
222 },
223 response: GatewayResponse {
224 status_code: 0,
225 headers: HashMap::new(),
226 body: Bytes::new(),
227 },
228 message: HashMap::new(),
229 errors: Vec::new(),
230 }
231 }
232
233 fn config(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
234 serde_json::from_value(json).unwrap()
235 }
236
237 #[test]
238 fn test_config_requires_exactly_one_list() {
239 assert!(UaRestrictionPlugin::from_config(&config(serde_json::json!({}))).is_err());
241 assert!(UaRestrictionPlugin::from_config(&config(serde_json::json!({
243 "allowlist": ["a"], "denylist": ["b"]
244 })))
245 .is_err());
246 assert!(UaRestrictionPlugin::from_config(&config(serde_json::json!({
248 "denylist": ["curl"]
249 })))
250 .is_ok());
251 }
252
253 #[test]
254 fn test_config_rejects_invalid_regex_and_bad_shapes() {
255 assert!(UaRestrictionPlugin::from_config(&config(serde_json::json!({
256 "denylist": ["("]
257 })))
258 .is_err());
259 assert!(UaRestrictionPlugin::from_config(&config(serde_json::json!({
260 "denylist": "curl"
261 })))
262 .is_err());
263 assert!(UaRestrictionPlugin::from_config(&config(serde_json::json!({
264 "denylist": [""]
265 })))
266 .is_err());
267 assert!(UaRestrictionPlugin::from_config(&config(serde_json::json!({
268 "denylist": ["curl"], "rejected_code": 100
269 })))
270 .is_err());
271 }
272
273 #[tokio::test]
274 async fn test_denylist_blocks_matching_ua() {
275 let plugin = UaRestrictionPlugin::from_config(&config(serde_json::json!({
276 "denylist": ["curl/.*", "(?i)spider"]
277 })))
278 .unwrap();
279
280 let err = plugin
281 .execute(test_context(Some("curl/8.1.2")), &HashMap::new())
282 .await
283 .unwrap_err();
284 assert_eq!(err.error.code, "UA_RESTRICTED");
285 assert_eq!(err.context.response.status_code, 403);
286
287 assert!(plugin
289 .execute(test_context(Some("Mozilla/5.0")), &HashMap::new())
290 .await
291 .is_ok());
292 }
293
294 #[tokio::test]
295 async fn test_allowlist_only_matching_ua_passes() {
296 let plugin = UaRestrictionPlugin::from_config(&config(serde_json::json!({
297 "allowlist": ["Mozilla.*"]
298 })))
299 .unwrap();
300
301 assert!(plugin
302 .execute(test_context(Some("Mozilla/5.0")), &HashMap::new())
303 .await
304 .is_ok());
305 assert!(plugin
307 .execute(test_context(Some(" Mozilla/5.0 ")), &HashMap::new())
308 .await
309 .is_ok());
310 assert!(plugin
311 .execute(test_context(Some("curl/8.1.2")), &HashMap::new())
312 .await
313 .is_err());
314 }
315
316 #[tokio::test]
317 async fn test_missing_ua_bypass() {
318 let deny = UaRestrictionPlugin::from_config(&config(serde_json::json!({
319 "denylist": ["curl"]
320 })))
321 .unwrap();
322 assert!(deny
324 .execute(test_context(None), &HashMap::new())
325 .await
326 .is_err());
327
328 let bypass = UaRestrictionPlugin::from_config(&config(serde_json::json!({
329 "denylist": ["curl"], "bypass_missing": true
330 })))
331 .unwrap();
332 assert!(bypass
333 .execute(test_context(None), &HashMap::new())
334 .await
335 .is_ok());
336 }
337
338 #[tokio::test]
339 async fn test_custom_code_and_message() {
340 let plugin = UaRestrictionPlugin::from_config(&config(serde_json::json!({
341 "denylist": ["curl"], "rejected_code": 405, "rejected_msg": "go away"
342 })))
343 .unwrap();
344 let err = plugin
345 .execute(test_context(Some("curl/8.1.2")), &HashMap::new())
346 .await
347 .unwrap_err();
348 assert_eq!(err.context.response.status_code, 405);
349 let body: serde_json::Value = serde_json::from_slice(&err.context.response.body).unwrap();
350 assert_eq!(body["message"], "go away");
351 }
352}