Skip to main content

guard_core_engine/
referrer.rs

1//! The route referrer gate: the reference pipeline's eighth check.
2//!
3//! This is the Rust family's port of
4//! `guard_core/core/checks/implementations/referrer.py` plus its matcher
5//! `guard_core/core/checks/helpers.py::is_referrer_domain_allowed`. A route
6//! with `require_referrer` (a list of allowed domains) admits requests
7//! whose `referer` header carries a host the list allows:
8//!
9//! ```text
10//! missing (empty) referer header:  Missing (the reference's
11//!                                  403 "Referrer required")
12//! host of the referer URL equals an allowed entry's host
13//!   (both lowercased) or is a subdomain of it:  Allowed
14//! anything else:                   Invalid (the reference's
15//!                                  403 "Invalid referrer")
16//! ```
17//!
18//! ## Details that mirror the reference exactly
19//!
20//! - The wire header is `referer` (the historic HTTP spelling; the check
21//!   is *named* referrer).
22//! - An allowed entry may be a bare domain (`example.com`), a
23//!   path-suffixed domain (`example.com/page`), or a full URL
24//!   (`https://example.com/page`) - the entry is normalized to its host:
25//!   a `scheme://` entry is parsed to its netloc, anything else is cut at
26//!   the first `/`, all lowercased (`_normalize_allowed_referrer_domain`).
27//! - The referer's host is its URL netloc, lowercased; a referer with no
28//!   scheme parses to an empty netloc (Python's `urlparse`), which never
29//!   matches a non-empty entry.
30//! - The subdomain arm is exact: `endswith("." + domain)`, so
31//!   `notexample.com` never matches `example.com` while
32//!   `api.example.com` does.
33//!
34//! # Example
35//!
36//! ```
37//! use guard_core_engine::referrer::{decide, ReferrerVerdict};
38//!
39//! let allowed = vec!["example.com".to_owned()];
40//! assert_eq!(
41//!     decide(Some("https://example.com/page"), &allowed),
42//!     ReferrerVerdict::Allowed
43//! );
44//! assert_eq!(
45//!     decide(Some("https://api.example.com/x"), &allowed),
46//!     ReferrerVerdict::Allowed
47//! );
48//! assert_eq!(
49//!     decide(Some("https://notexample.com/"), &allowed),
50//!     ReferrerVerdict::Invalid { referrer: "https://notexample.com/".to_owned() }
51//! );
52//! assert_eq!(decide(None, &allowed), ReferrerVerdict::Missing);
53//! ```
54
55/// The check's stable name (`check_name`).
56pub const REFERRER_CHECK_NAME: &str = "referrer";
57
58/// The missing-referrer block status (`403 "Referrer required"`).
59pub const REFERRER_MISSING_STATUS: u16 = 403;
60/// The missing-referrer block body.
61pub const REFERRER_MISSING_BODY: &str = "Referrer required";
62/// The invalid-referrer block status (`403 "Invalid referrer"`).
63pub const REFERRER_INVALID_STATUS: u16 = 403;
64/// The invalid-referrer block body.
65pub const REFERRER_INVALID_BODY: &str = "Invalid referrer";
66
67/// What [`decide`] decided.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum ReferrerVerdict {
70    /// The referer host is allowed (or the route required no referrer).
71    Allowed,
72    /// The `referer` header is absent or empty: the reference's
73    /// `"Referrer required"` shape.
74    Missing,
75    /// The referer is present but its host is not allowed: the reference's
76    /// `"Invalid referrer"` shape, carrying the raw value (the stage
77    /// redacts it for logs and events, exactly the reference).
78    Invalid {
79        /// The raw `referer` header value.
80        referrer: String,
81    },
82}
83
84/// The URL's netloc, the piece of `urlparse` the matcher reads.
85///
86/// The authority after `scheme://`, cut at the first `/`, `?`, or `#`. A
87/// URL without a scheme parses to an empty netloc, exactly like Python's
88/// `urlparse` (which then reads the whole text as the path).
89#[must_use]
90pub fn url_netloc(url: &str) -> &str {
91    let Some(rest) = url.split_once("://").map(|(_, after)| after) else {
92        return "";
93    };
94    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
95    &rest[..end]
96}
97
98/// An allowed entry normalized to its host (`_normalize_allowed_referrer_domain`):
99/// a `scheme://` entry parses to its netloc, anything else is lowercased
100/// and cut at the first `/`.
101#[must_use]
102pub fn normalize_allowed_domain(entry: &str) -> String {
103    if entry.contains("://") {
104        url_netloc(entry).to_lowercase()
105    } else {
106        let lowered = entry.to_lowercase();
107        match lowered.find('/') {
108            Some(slash) => lowered[..slash].to_owned(),
109            None => lowered,
110        }
111    }
112}
113
114/// The reference matcher: the referer's host equals an allowed entry's
115/// host (both lowercased) or is a subdomain of it.
116#[must_use]
117pub fn is_referrer_domain_allowed(referrer: &str, allowed_domains: &[String]) -> bool {
118    let referrer_domain = url_netloc(referrer).to_lowercase();
119    for allowed in allowed_domains {
120        let normalized = normalize_allowed_domain(allowed);
121        if referrer_domain == normalized || referrer_domain.ends_with(&format!(".{normalized}")) {
122            return true;
123        }
124    }
125    false
126}
127
128/// The reference check body: an absent or empty `referer` is missing, a
129/// host the list allows passes, everything else is invalid.
130///
131/// `referrer` is the raw header value; `None` when the header is absent.
132/// A route with no configured list never reaches this decision (the
133/// reference returns before reading the header), but the shape mirrors it
134/// anyway: an empty list allows nothing, so callers gate on the route
135/// first.
136#[must_use]
137pub fn decide(referrer: Option<&str>, allowed_domains: &[String]) -> ReferrerVerdict {
138    let Some(raw) = referrer else {
139        return ReferrerVerdict::Missing;
140    };
141    if raw.is_empty() {
142        return ReferrerVerdict::Missing;
143    }
144    if is_referrer_domain_allowed(raw, allowed_domains) {
145        ReferrerVerdict::Allowed
146    } else {
147        ReferrerVerdict::Invalid {
148            referrer: raw.to_owned(),
149        }
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn allowed(entries: &[&str]) -> Vec<String> {
158        entries.iter().map(|entry| (*entry).to_owned()).collect()
159    }
160
161    #[test]
162    fn netloc_extraction_matches_urlparse_shapes() {
163        assert_eq!(url_netloc("https://example.com/path"), "example.com");
164        assert_eq!(
165            url_netloc("http://example.com:8080/x?y=1#z"),
166            "example.com:8080"
167        );
168        assert_eq!(
169            url_netloc("https://user:pw@example.com/"),
170            "user:pw@example.com"
171        );
172        assert_eq!(url_netloc("https://example.com"), "example.com");
173        // No scheme: urlparse reads no netloc at all.
174        assert_eq!(url_netloc("example.com/path"), "");
175        assert_eq!(url_netloc(""), "");
176    }
177
178    #[test]
179    fn allowed_entry_normalization_covers_the_three_shapes() {
180        assert_eq!(normalize_allowed_domain("Example.COM"), "example.com");
181        assert_eq!(
182            normalize_allowed_domain("example.com/page"),
183            "example.com",
184            "a path-suffixed entry is cut at the first slash"
185        );
186        assert_eq!(
187            normalize_allowed_domain("https://Example.com/page"),
188            "example.com",
189            "a full-URL entry parses to its netloc"
190        );
191    }
192
193    #[test]
194    fn exact_and_subdomain_hosts_pass_others_fail() {
195        let domains = allowed(&["example.com"]);
196        assert!(is_referrer_domain_allowed(
197            "https://example.com/x",
198            &domains
199        ));
200        assert!(is_referrer_domain_allowed(
201            "https://EXAMPLE.com/x",
202            &domains
203        ));
204        assert!(is_referrer_domain_allowed(
205            "https://api.example.com/x",
206            &domains
207        ));
208        assert!(
209            !is_referrer_domain_allowed("https://notexample.com/x", &domains),
210            "the subdomain arm is endswith(.domain), never a substring test"
211        );
212        assert!(
213            !is_referrer_domain_allowed("https://example.com.evil.test/x", &domains),
214            "a suffixed host is not a subdomain"
215        );
216        assert!(!is_referrer_domain_allowed(
217            "https://other.test/x",
218            &domains
219        ));
220    }
221
222    #[test]
223    fn schemeless_referrers_have_no_host_and_fail() {
224        let domains = allowed(&["example.com"]);
225        assert!(!is_referrer_domain_allowed("example.com", &domains));
226    }
227
228    #[test]
229    fn decide_splits_missing_allowed_invalid() {
230        let domains = allowed(&["example.com"]);
231        assert_eq!(decide(None, &domains), ReferrerVerdict::Missing);
232        assert_eq!(decide(Some(""), &domains), ReferrerVerdict::Missing);
233        assert_eq!(
234            decide(Some("https://example.com/"), &domains),
235            ReferrerVerdict::Allowed
236        );
237        assert_eq!(
238            decide(Some("https://evil.test/"), &domains),
239            ReferrerVerdict::Invalid {
240                referrer: "https://evil.test/".to_owned()
241            }
242        );
243    }
244
245    #[test]
246    fn multiple_entries_allow_any_match() {
247        let domains = allowed(&["a.test", "https://b.test/page"]);
248        assert_eq!(
249            decide(Some("https://a.test/x"), &domains),
250            ReferrerVerdict::Allowed
251        );
252        assert_eq!(
253            decide(Some("https://b.test/y"), &domains),
254            ReferrerVerdict::Allowed
255        );
256        assert_eq!(
257            decide(Some("https://c.test/y"), &domains),
258            ReferrerVerdict::Invalid {
259                referrer: "https://c.test/y".to_owned()
260            }
261        );
262    }
263}