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 error port with error code `REFERER_RESTRICTED`.
7
8use 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
15/// Restricts access based on the host of the `Referer` request header.
16///
17/// Exactly one of `whitelist` / `blacklist` is configured (APISIX `oneOf`
18/// parity). A missing or malformed Referer is rejected unless
19/// `bypass_missing` is set. Rejections produce a 403 JSON response.
20pub struct RefererRestrictionPlugin {
21    /// Host patterns that may pass; non-empty means whitelist mode.
22    whitelist: HostMatcher,
23    /// Host patterns that are rejected; non-empty means blacklist mode.
24    blacklist: HostMatcher,
25    /// Pass requests whose Referer is missing or malformed (default `false`).
26    bypass_missing: bool,
27    /// Body message for rejections.
28    message: String,
29}
30
31/// Pre-split host patterns: exact hosts and `*`-prefix wildcard suffixes
32/// (mirrors APISIX's `create_host_matcher`).
33struct HostMatcher {
34    /// Lowercased exact host names.
35    exact: Vec<String>,
36    /// Suffixes from `*`-prefixed patterns: `*.example.com` stores
37    /// `.example.com` (so the bare apex `example.com` does NOT match).
38    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    /// True when `host` equals an exact entry or ends with a wildcard suffix.
47    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
53/// Parses a config key as an array of host patterns. Patterns starting with
54/// `*` become suffix matches on the remainder; others are exact (lowercased).
55fn 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
88/// Extracts the lowercased host from a Referer value. Only `http`/`https`
89/// URLs parse (APISIX `http.parse_uri` parity); anything else — including a
90/// bare host without a scheme — is treated as malformed and returns `None`.
91fn 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    /// Builds the plugin from node config.
107    ///
108    /// Accepted keys:
109    /// - `whitelist` (array of host patterns): only these Referer hosts pass.
110    /// - `blacklist` (array of host patterns): these Referer hosts are
111    ///   rejected. Exactly **one** of `whitelist` / `blacklist` must be
112    ///   non-empty (APISIX `oneOf` parity). Patterns are exact hosts
113    ///   (`example.com`) or leading-`*` wildcards (`*.example.com`, which
114    ///   matches any subdomain but not the bare apex).
115    /// - `bypass_missing` (bool, default `false`): pass requests whose
116    ///   Referer header is missing or not a parseable http(s) URL.
117    /// - `message` (string, default `"Your referer host is not allowed"`):
118    ///   rejection message, returned as `{"message": ...}`.
119    ///
120    /// ```yaml
121    /// type: referer-restriction
122    /// config:
123    ///   whitelist: ["example.com", "*.example.org"]
124    ///   bypass_missing: true
125    /// ```
126    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    /// Builds the 403 rejection routed through the error port with code
158    /// `REFERER_RESTRICTED`.
159    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            // Missing or malformed Referer.
198            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        // bad shapes
271        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        // malformed: no scheme, wrong scheme, empty host
300        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        // apex does not match "*.example.org"
324        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        // blacklist mode: missing referer is still blocked by default
356        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        // malformed referer counts as missing
374        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}