Skip to main content

featherbit/plugins/native/
referer_restriction.rs

1//! Referer allow/deny list plugin (`referer-restriction`).
2//!
3//! Port of APISIX's `referer-restriction`: parses the host out of the
4//! `Referer` request header and matches it against a whitelist or blacklist
5//! of host patterns (exact hosts or leading-`*` wildcards). Rejections are
6//! routed through the node's `denied` port.
7
8use 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
16/// Restricts access based on the host of the `Referer` request header.
17///
18/// Exactly one of `whitelist` / `blacklist` is configured (APISIX `oneOf`
19/// parity). A missing or malformed Referer is rejected unless
20/// `bypass_missing` is set. Rejections produce a 403 JSON response.
21pub struct RefererRestrictionPlugin {
22    /// Host patterns that may pass; non-empty means whitelist mode.
23    whitelist: HostMatcher,
24    /// Host patterns that are rejected; non-empty means blacklist mode.
25    blacklist: HostMatcher,
26    /// Pass requests whose Referer is missing or malformed (default `false`).
27    bypass_missing: bool,
28    /// Body message for rejections. Supports `{{namespace.path}}` references
29    /// (no legacy `$var` interpolation — this field never supported it, so
30    /// this sweep must not start).
31    message: Template,
32}
33
34/// Pre-split host patterns: exact hosts and `*`-prefix wildcard suffixes
35/// (mirrors APISIX's `create_host_matcher`).
36struct HostMatcher {
37    /// Lowercased exact host names.
38    exact: Vec<String>,
39    /// Suffixes from `*`-prefixed patterns: `*.example.com` stores
40    /// `.example.com` (so the bare apex `example.com` does NOT match).
41    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    /// True when `host` equals an exact entry or ends with a wildcard suffix.
50    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
56/// Parses a config key as an array of host patterns. Patterns starting with
57/// `*` become suffix matches on the remainder; others are exact (lowercased).
58fn 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
91/// Extracts the lowercased host from a Referer value. Only `http`/`https`
92/// URLs parse (APISIX `http.parse_uri` parity); anything else — including a
93/// bare host without a scheme — is treated as malformed and returns `None`.
94fn 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    /// Builds the plugin from node config.
110    ///
111    /// Accepted keys:
112    /// - `whitelist` (array of host patterns): only these Referer hosts pass.
113    /// - `blacklist` (array of host patterns): these Referer hosts are
114    ///   rejected. Exactly **one** of `whitelist` / `blacklist` must be
115    ///   non-empty (APISIX `oneOf` parity). Patterns are exact hosts
116    ///   (`example.com`) or leading-`*` wildcards (`*.example.com`, which
117    ///   matches any subdomain but not the bare apex).
118    /// - `bypass_missing` (bool, default `false`): pass requests whose
119    ///   Referer header is missing or not a parseable http(s) URL.
120    /// - `message` (string, default `"Your referer host is not allowed"`):
121    ///   rejection message, returned as `{"message": ...}`. Supports
122    ///   `{{namespace.path}}` references.
123    ///
124    /// ```yaml
125    /// type: referer-restriction
126    /// config:
127    ///   whitelist: ["example.com", "*.example.org"]
128    ///   bypass_missing: true
129    /// ```
130    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                // Discard warnings here — the compile-time walk (a later
159                // task) reports well-formed-but-unknown references;
160                // execution must not.
161                Template::parse(message).0
162            },
163        })
164    }
165
166    /// Builds the 403 rejection routed through the `denied` port.
167    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            // Missing or malformed Referer.
195            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        // bad shapes
266        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        // malformed: no scheme, wrong scheme, empty host
295        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        // apex does not match "*.example.org"
320        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        // blacklist mode: missing referer is still blocked by default
366        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        // malformed referer counts as missing
401        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}