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