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}