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