featherbit/plugins/native/
referer_restriction.rs1use async_trait::async_trait;
9use bytes::Bytes;
10use std::collections::HashMap;
11
12use crate::context::{Context, GatewayError};
13use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
14
15pub struct RefererRestrictionPlugin {
21 whitelist: HostMatcher,
23 blacklist: HostMatcher,
25 bypass_missing: bool,
27 message: String,
29}
30
31struct HostMatcher {
34 exact: Vec<String>,
36 suffixes: Vec<String>,
39}
40
41impl HostMatcher {
42 fn is_empty(&self) -> bool {
43 self.exact.is_empty() && self.suffixes.is_empty()
44 }
45
46 fn matches(&self, host: &str) -> bool {
48 self.exact.iter().any(|h| h == host)
49 || self.suffixes.iter().any(|s| host.ends_with(s.as_str()))
50 }
51}
52
53fn parse_host_list(
56 config: &HashMap<String, serde_json::Value>,
57 key: &str,
58) -> Result<HostMatcher, String> {
59 let mut matcher = HostMatcher {
60 exact: Vec::new(),
61 suffixes: Vec::new(),
62 };
63 let Some(raw) = config.get(key) else {
64 return Ok(matcher);
65 };
66 let arr = raw
67 .as_array()
68 .ok_or_else(|| format!("{} must be an array of host patterns", key))?;
69 for item in arr {
70 let s = item
71 .as_str()
72 .ok_or_else(|| format!("{} entries must be strings", key))?;
73 if s.is_empty() || s == "*" {
74 return Err(format!(
75 "{} entries must be hosts like example.com or *.example.com",
76 key
77 ));
78 }
79 if let Some(suffix) = s.strip_prefix('*') {
80 matcher.suffixes.push(suffix.to_ascii_lowercase());
81 } else {
82 matcher.exact.push(s.to_ascii_lowercase());
83 }
84 }
85 Ok(matcher)
86}
87
88fn referer_host(referer: &str) -> Option<String> {
92 let (scheme, rest) = referer.split_once("://")?;
93 if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
94 return None;
95 }
96 let end = rest.find([':', '/', '?', '#']).unwrap_or(rest.len());
97 let host = &rest[..end];
98 if host.is_empty() {
99 None
100 } else {
101 Some(host.to_ascii_lowercase())
102 }
103}
104
105impl RefererRestrictionPlugin {
106 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
127 let whitelist = parse_host_list(config, "whitelist")?;
128 let blacklist = parse_host_list(config, "blacklist")?;
129
130 if !whitelist.is_empty() && !blacklist.is_empty() {
131 return Err(
132 "referer-restriction: whitelist and blacklist cannot both be set".to_string(),
133 );
134 }
135 if whitelist.is_empty() && blacklist.is_empty() {
136 return Err(
137 "referer-restriction: exactly one of whitelist or blacklist must be non-empty"
138 .to_string(),
139 );
140 }
141
142 Ok(Self {
143 whitelist,
144 blacklist,
145 bypass_missing: config
146 .get("bypass_missing")
147 .and_then(|v| v.as_bool())
148 .unwrap_or(false),
149 message: config
150 .get("message")
151 .and_then(|v| v.as_str())
152 .unwrap_or("Your referer host is not allowed")
153 .to_string(),
154 })
155 }
156
157 fn reject(&self, mut ctx: Context) -> PluginResult {
160 ctx.response.status_code = 403;
161 ctx.response.body = Bytes::from(serde_json::json!({ "message": self.message }).to_string());
162 ctx.response.headers.insert(
163 "content-type".to_string(),
164 vec!["application/json".to_string()],
165 );
166 Err(PluginExecutionError {
167 context: ctx,
168 error: GatewayError {
169 node_id: String::new(),
170 code: "REFERER_RESTRICTED".to_string(),
171 message: self.message.clone(),
172 metadata: HashMap::new(),
173 },
174 })
175 }
176}
177
178#[async_trait]
179impl Plugin for RefererRestrictionPlugin {
180 fn plugin_type(&self) -> &str {
181 "referer-restriction"
182 }
183
184 async fn execute(
185 &self,
186 ctx: Context,
187 _named_inputs: &HashMap<String, serde_json::Value>,
188 ) -> PluginResult {
189 let host = ctx
190 .request
191 .headers
192 .get("referer")
193 .and_then(|v| v.first())
194 .and_then(|r| referer_host(r));
195
196 let block = match host {
197 None => !self.bypass_missing,
199 Some(host) => {
200 if !self.whitelist.is_empty() {
201 !self.whitelist.matches(&host)
202 } else {
203 self.blacklist.matches(&host)
204 }
205 }
206 };
207
208 if block {
209 return self.reject(ctx);
210 }
211
212 Ok(PluginOutput {
213 context: ctx,
214 named_outputs: HashMap::new(),
215 })
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
223
224 fn test_context(referer: Option<&str>) -> Context {
225 let mut headers = HashMap::new();
226 if let Some(r) = referer {
227 headers.insert("referer".to_string(), vec![r.to_string()]);
228 }
229 Context {
230 request: GatewayRequest {
231 method: "GET".to_string(),
232 path: "/test".to_string(),
233 host: "localhost".to_string(),
234 scheme: "http".to_string(),
235 headers,
236 query_params: HashMap::new(),
237 body: Bytes::new(),
238 remote_addr: "127.0.0.1:12345".to_string(),
239 protocol: Protocol::Http1,
240 },
241 response: GatewayResponse {
242 status_code: 0,
243 headers: HashMap::new(),
244 body: Bytes::new(),
245 },
246 message: HashMap::new(),
247 errors: Vec::new(),
248 }
249 }
250
251 fn config(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
252 serde_json::from_value(json).unwrap()
253 }
254
255 #[test]
256 fn test_config_requires_exactly_one_list() {
257 assert!(RefererRestrictionPlugin::from_config(&config(serde_json::json!({}))).is_err());
258 assert!(
259 RefererRestrictionPlugin::from_config(&config(serde_json::json!({
260 "whitelist": ["a.com"], "blacklist": ["b.com"]
261 })))
262 .is_err()
263 );
264 assert!(
265 RefererRestrictionPlugin::from_config(&config(serde_json::json!({
266 "whitelist": ["a.com"]
267 })))
268 .is_ok()
269 );
270 assert!(
272 RefererRestrictionPlugin::from_config(&config(serde_json::json!({
273 "whitelist": "a.com"
274 })))
275 .is_err()
276 );
277 assert!(
278 RefererRestrictionPlugin::from_config(&config(serde_json::json!({
279 "whitelist": [""]
280 })))
281 .is_err()
282 );
283 }
284
285 #[test]
286 fn test_referer_host_parsing() {
287 assert_eq!(
288 referer_host("http://example.com/path"),
289 Some("example.com".to_string())
290 );
291 assert_eq!(
292 referer_host("https://Example.COM:8443?q=1"),
293 Some("example.com".to_string())
294 );
295 assert_eq!(
296 referer_host("https://example.com"),
297 Some("example.com".to_string())
298 );
299 assert_eq!(referer_host("example.com/path"), None);
301 assert_eq!(referer_host("ftp://example.com"), None);
302 assert_eq!(referer_host("http://"), None);
303 }
304
305 #[tokio::test]
306 async fn test_whitelist_exact_and_wildcard() {
307 let plugin = RefererRestrictionPlugin::from_config(&config(serde_json::json!({
308 "whitelist": ["example.com", "*.example.org"]
309 })))
310 .unwrap();
311
312 assert!(plugin
313 .execute(test_context(Some("http://example.com/x")), &HashMap::new())
314 .await
315 .is_ok());
316 assert!(plugin
317 .execute(
318 test_context(Some("https://api.example.org/x")),
319 &HashMap::new()
320 )
321 .await
322 .is_ok());
323 assert!(plugin
325 .execute(test_context(Some("https://example.org/")), &HashMap::new())
326 .await
327 .is_err());
328 let err = plugin
329 .execute(test_context(Some("https://evil.com/")), &HashMap::new())
330 .await
331 .unwrap_err();
332 assert_eq!(err.error.code, "REFERER_RESTRICTED");
333 assert_eq!(err.context.response.status_code, 403);
334 }
335
336 #[tokio::test]
337 async fn test_blacklist_blocks_matching_host() {
338 let plugin = RefererRestrictionPlugin::from_config(&config(serde_json::json!({
339 "blacklist": ["*.evil.com", "bad.org"]
340 })))
341 .unwrap();
342
343 assert!(plugin
344 .execute(test_context(Some("http://sub.evil.com/")), &HashMap::new())
345 .await
346 .is_err());
347 assert!(plugin
348 .execute(test_context(Some("http://bad.org/")), &HashMap::new())
349 .await
350 .is_err());
351 assert!(plugin
352 .execute(test_context(Some("http://good.org/")), &HashMap::new())
353 .await
354 .is_ok());
355 assert!(plugin
357 .execute(test_context(None), &HashMap::new())
358 .await
359 .is_err());
360 }
361
362 #[tokio::test]
363 async fn test_bypass_missing_and_malformed() {
364 let plugin = RefererRestrictionPlugin::from_config(&config(serde_json::json!({
365 "whitelist": ["example.com"], "bypass_missing": true
366 })))
367 .unwrap();
368
369 assert!(plugin
370 .execute(test_context(None), &HashMap::new())
371 .await
372 .is_ok());
373 assert!(plugin
375 .execute(test_context(Some("not a url")), &HashMap::new())
376 .await
377 .is_ok());
378
379 let strict = RefererRestrictionPlugin::from_config(&config(serde_json::json!({
380 "whitelist": ["example.com"]
381 })))
382 .unwrap();
383 assert!(strict
384 .execute(test_context(None), &HashMap::new())
385 .await
386 .is_err());
387 assert!(strict
388 .execute(test_context(Some("not a url")), &HashMap::new())
389 .await
390 .is_err());
391 }
392}