1use std::{net::IpAddr, time::Duration};
4
5use chrono::TimeDelta;
6use ipnet::IpNet;
7
8use crate::{
9 checks::{Checks, Thresholds},
10 cookie::CookieConfig,
11 error::ConfigError,
12};
13
14const MAX_DURATION: Duration = Duration::from_hours(100 * 365 * 24);
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
19pub enum Mode {
20 #[default]
22 Enforce,
23 Shadow,
26}
27
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
30#[non_exhaustive]
31pub enum StepUpPolicy {
32 #[default]
35 Require,
36 Revoke,
39 Allow,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct RotationConfig {
47 pub interval: Duration,
49 pub grace: Duration,
52 pub reuse_detection: Duration,
55}
56
57impl Default for RotationConfig {
58 fn default() -> Self {
59 Self {
60 interval: Duration::from_mins(10),
61 grace: Duration::from_secs(30),
62 reuse_detection: Duration::from_hours(7 * 24),
63 }
64 }
65}
66
67#[derive(Clone, Debug)]
70pub struct GuardConfig {
71 pub(crate) cookie: CookieConfig,
72 pub(crate) session_lifetime: TimeDelta,
73 pub(crate) idle_timeout: TimeDelta,
74 pub(crate) trusted_proxies: Vec<IpNet>,
75 pub(crate) tls_fingerprint_header: Option<String>,
76 pub(crate) rotation_interval: TimeDelta,
77 pub(crate) rotation_grace: TimeDelta,
78 pub(crate) reuse_detection: TimeDelta,
79 pub(crate) checks: Checks,
80 pub(crate) thresholds: Thresholds,
81 pub(crate) mode: Mode,
82 pub(crate) step_up_policy: StepUpPolicy,
83 pub(crate) ping_pong_window: TimeDelta,
84 pub(crate) last_seen_write_interval: TimeDelta,
85 pub(crate) event_retention: TimeDelta,
86 pub(crate) minimize_stored_events: bool,
87 pub(crate) keep_origin_client: bool,
88 pub(crate) shadow_dedup_window: TimeDelta,
89 pub(crate) alert_dedup_window: TimeDelta,
90 pub(crate) max_header_len: usize,
91 pub(crate) token_prefix: Option<String>,
92 pub(crate) orphan_reissue_window: Option<TimeDelta>,
93}
94
95impl Default for GuardConfig {
96 fn default() -> Self {
97 GuardConfigBuilder::default()
98 .build()
99 .unwrap_or_else(|_| unreachable!("default configuration is valid"))
100 }
101}
102
103impl GuardConfig {
104 pub fn builder() -> GuardConfigBuilder {
106 GuardConfigBuilder::default()
107 }
108
109 pub fn cookie(&self) -> &CookieConfig {
111 &self.cookie
112 }
113
114 pub fn session_lifetime(&self) -> Duration {
116 to_std(self.session_lifetime)
117 }
118
119 pub fn idle_timeout(&self) -> Duration {
121 to_std(self.idle_timeout)
122 }
123
124 pub fn trusted_proxies(&self) -> &[IpNet] {
126 &self.trusted_proxies
127 }
128
129 pub fn tls_fingerprint_header(&self) -> Option<&str> {
131 self.tls_fingerprint_header.as_deref()
132 }
133
134 pub fn rotation(&self) -> RotationConfig {
136 RotationConfig {
137 interval: to_std(self.rotation_interval),
138 grace: to_std(self.rotation_grace),
139 reuse_detection: to_std(self.reuse_detection),
140 }
141 }
142
143 pub fn checks(&self) -> &Checks {
145 &self.checks
146 }
147
148 pub fn thresholds(&self) -> Thresholds {
150 self.thresholds
151 }
152
153 pub fn mode(&self) -> Mode {
155 self.mode
156 }
157
158 pub fn step_up_policy(&self) -> StepUpPolicy {
160 self.step_up_policy
161 }
162
163 pub fn ping_pong_window(&self) -> Duration {
165 to_std(self.ping_pong_window)
166 }
167
168 pub fn last_seen_write_interval(&self) -> Duration {
170 to_std(self.last_seen_write_interval)
171 }
172
173 pub fn event_retention(&self) -> Duration {
176 to_std(self.event_retention)
177 }
178
179 pub fn minimize_stored_events(&self) -> bool {
182 self.minimize_stored_events
183 }
184
185 pub fn keep_origin_client(&self) -> bool {
188 self.keep_origin_client
189 }
190
191 pub fn shadow_dedup_window(&self) -> Duration {
193 to_std(self.shadow_dedup_window)
194 }
195
196 pub fn alert_dedup_window(&self) -> Duration {
199 to_std(self.alert_dedup_window)
200 }
201
202 pub fn max_header_len(&self) -> usize {
204 self.max_header_len
205 }
206
207 pub fn token_prefix(&self) -> Option<&str> {
209 self.token_prefix.as_deref()
210 }
211
212 pub fn orphan_reissue_window(&self) -> Option<Duration> {
214 self.orphan_reissue_window.map(to_std)
215 }
216}
217
218fn to_std(delta: TimeDelta) -> Duration {
219 delta.to_std().unwrap_or_default()
220}
221
222#[derive(Clone, Debug)]
224#[must_use]
225pub struct GuardConfigBuilder {
226 cookie: CookieConfig,
227 session_lifetime: Duration,
228 idle_timeout: Duration,
229 trusted_proxies: Vec<IpNet>,
230 tls_fingerprint_header: Option<String>,
231 rotation: RotationConfig,
232 checks: Checks,
233 thresholds: Thresholds,
234 mode: Mode,
235 step_up_policy: StepUpPolicy,
236 ping_pong_window: Duration,
237 last_seen_write_interval: Duration,
238 event_retention: Duration,
239 minimize_stored_events: bool,
240 keep_origin_client: bool,
241 shadow_dedup_window: Duration,
242 alert_dedup_window: Duration,
243 max_header_len: usize,
244 token_prefix: Option<String>,
245 orphan_reissue_window: Option<Duration>,
246}
247
248impl Default for GuardConfigBuilder {
249 fn default() -> Self {
250 const MINUTE: u64 = 60;
251 const DAY: u64 = 24 * 60 * MINUTE;
252 Self {
253 cookie: CookieConfig::default(),
254 session_lifetime: Duration::from_secs(30 * DAY),
255 idle_timeout: Duration::from_secs(7 * DAY),
256 trusted_proxies: Vec::new(),
257 tls_fingerprint_header: None,
258 rotation: RotationConfig::default(),
259 checks: Checks::default(),
260 thresholds: Thresholds::default(),
261 mode: Mode::Enforce,
262 step_up_policy: StepUpPolicy::Require,
263 ping_pong_window: Duration::from_secs(5 * MINUTE),
264 last_seen_write_interval: Duration::from_secs(MINUTE),
265 event_retention: Duration::from_secs(90 * DAY),
266 minimize_stored_events: true,
267 keep_origin_client: false,
268 shadow_dedup_window: Duration::from_secs(10 * MINUTE),
269 alert_dedup_window: Duration::ZERO,
270 max_header_len: 512,
271 token_prefix: None,
272 orphan_reissue_window: None,
273 }
274 }
275}
276
277impl GuardConfigBuilder {
278 pub fn cookie(mut self, cookie: CookieConfig) -> Self {
280 self.cookie = cookie;
281 self
282 }
283
284 pub fn cookie_name(mut self, name: impl Into<String>) -> Self {
286 self.cookie = self.cookie.name(name);
287 self
288 }
289
290 pub fn session_lifetime(mut self, lifetime: Duration) -> Self {
293 self.session_lifetime = lifetime;
294 self
295 }
296
297 pub fn idle_timeout(mut self, timeout: Duration) -> Self {
299 self.idle_timeout = timeout;
300 self
301 }
302
303 pub fn trusted_proxies<I, T>(mut self, proxies: I) -> Result<Self, ConfigError>
310 where
311 I: IntoIterator<Item = T>,
312 T: AsRef<str>,
313 {
314 self.trusted_proxies = proxies
315 .into_iter()
316 .map(|p| {
317 let p = p.as_ref().trim();
318 p.parse::<IpNet>()
319 .or_else(|_| p.parse::<IpAddr>().map(IpNet::from))
320 .map(|net| net.trunc())
321 .map_err(|_| ConfigError::InvalidProxy(p.to_owned()))
322 })
323 .collect::<Result<_, _>>()?;
324 Ok(self)
325 }
326
327 pub fn tls_fingerprint_header(mut self, header: impl Into<String>) -> Self {
331 self.tls_fingerprint_header = Some(header.into().to_ascii_lowercase());
332 self
333 }
334
335 pub fn rotation(mut self, rotation: RotationConfig) -> Self {
337 self.rotation = rotation;
338 self
339 }
340
341 pub fn checks(mut self, checks: Checks) -> Self {
343 self.checks = checks;
344 self
345 }
346
347 pub fn thresholds(mut self, thresholds: Thresholds) -> Self {
349 self.thresholds = thresholds;
350 self
351 }
352
353 pub fn mode(mut self, mode: Mode) -> Self {
355 self.mode = mode;
356 self
357 }
358
359 pub fn step_up_policy(mut self, policy: StepUpPolicy) -> Self {
361 self.step_up_policy = policy;
362 self
363 }
364
365 pub fn orphan_reissue_window(mut self, window: Duration) -> Self {
372 self.orphan_reissue_window = Some(window);
373 self
374 }
375
376 pub fn ping_pong_window(mut self, window: Duration) -> Self {
378 self.ping_pong_window = window;
379 self
380 }
381
382 pub fn last_seen_write_interval(mut self, interval: Duration) -> Self {
385 self.last_seen_write_interval = interval;
386 self
387 }
388
389 pub fn event_retention(mut self, retention: Duration) -> Self {
391 self.event_retention = retention;
392 self
393 }
394
395 pub fn minimize_stored_events(mut self, minimize: bool) -> Self {
400 self.minimize_stored_events = minimize;
401 self
402 }
403
404 pub fn keep_origin_client(mut self, keep: bool) -> Self {
409 self.keep_origin_client = keep;
410 self
411 }
412
413 pub fn shadow_dedup_window(mut self, window: Duration) -> Self {
415 self.shadow_dedup_window = window;
416 self
417 }
418
419 pub fn alert_dedup_window(mut self, window: Duration) -> Self {
430 self.alert_dedup_window = window;
431 self
432 }
433
434 pub fn max_header_len(mut self, len: usize) -> Self {
436 self.max_header_len = len;
437 self
438 }
439
440 pub fn token_prefix(mut self, prefix: impl Into<String>) -> Self {
448 self.token_prefix = Some(prefix.into());
449 self
450 }
451
452 pub fn build(self) -> Result<GuardConfig, ConfigError> {
459 self.cookie.validate()?;
460 if !self.thresholds.is_valid() {
461 return Err(ConfigError::InvalidThresholds);
462 }
463 if self.rotation.grace >= self.rotation.interval {
464 return Err(ConfigError::GraceTooLong);
465 }
466 if self.max_header_len == 0 {
467 return Err(ConfigError::InvalidDuration("max_header_len"));
468 }
469 if let Some(h) = &self.tls_fingerprint_header
470 && (h.is_empty() || !h.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
471 {
472 return Err(ConfigError::InvalidHeaderName(h.clone()));
473 }
474 if let Some(p) = &self.token_prefix
475 && (p.is_empty() || p.len() > 32 || !p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
476 {
477 return Err(ConfigError::InvalidTokenPrefix(p.clone()));
478 }
479 Ok(GuardConfig {
480 cookie: self.cookie,
481 session_lifetime: positive(self.session_lifetime, "session_lifetime")?,
482 idle_timeout: positive(self.idle_timeout, "idle_timeout")?,
483 trusted_proxies: self.trusted_proxies,
484 tls_fingerprint_header: self.tls_fingerprint_header,
485 rotation_interval: positive(self.rotation.interval, "rotation.interval")?,
486 rotation_grace: bounded(self.rotation.grace, "rotation.grace")?,
487 reuse_detection: positive(self.rotation.reuse_detection, "rotation.reuse_detection")?,
488 checks: self.checks,
489 thresholds: self.thresholds,
490 mode: self.mode,
491 step_up_policy: self.step_up_policy,
492 ping_pong_window: bounded(self.ping_pong_window, "ping_pong_window")?,
493 last_seen_write_interval: bounded(self.last_seen_write_interval, "last_seen_write_interval")?,
494 event_retention: positive(self.event_retention, "event_retention")?,
495 minimize_stored_events: self.minimize_stored_events,
496 keep_origin_client: self.keep_origin_client,
497 shadow_dedup_window: bounded(self.shadow_dedup_window, "shadow_dedup_window")?,
498 alert_dedup_window: bounded(self.alert_dedup_window, "alert_dedup_window")?,
499 max_header_len: self.max_header_len,
500 token_prefix: self.token_prefix,
501 orphan_reissue_window: self
502 .orphan_reissue_window
503 .map(|w| bounded(w, "orphan_reissue_window"))
504 .transpose()?,
505 })
506 }
507}
508
509fn positive(d: Duration, name: &'static str) -> Result<TimeDelta, ConfigError> {
510 if d.is_zero() {
511 return Err(ConfigError::InvalidDuration(name));
512 }
513 bounded(d, name)
514}
515
516fn bounded(d: Duration, name: &'static str) -> Result<TimeDelta, ConfigError> {
517 if d > MAX_DURATION {
518 return Err(ConfigError::InvalidDuration(name));
519 }
520 TimeDelta::from_std(d).map_err(|_| ConfigError::InvalidDuration(name))
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use crate::checks::Check;
527
528 #[test]
529 fn defaults_match_the_spec() {
530 let c = GuardConfig::default();
531 assert_eq!(c.cookie().cookie_name(), "__Host-sid");
532 assert_eq!(c.session_lifetime(), Duration::from_hours(30 * 24));
533 assert_eq!(c.idle_timeout(), Duration::from_hours(7 * 24));
534 assert_eq!(c.rotation(), RotationConfig::default());
535 assert_eq!(c.rotation().interval, Duration::from_secs(600));
536 assert_eq!(c.rotation().grace, Duration::from_secs(30));
537 assert_eq!(c.mode(), Mode::Enforce);
538 assert_eq!(c.step_up_policy(), StepUpPolicy::Require);
539 assert_eq!(c.checks(), &Checks::default());
540 assert_eq!(c.thresholds(), Thresholds::default());
541 assert_eq!(c.event_retention(), Duration::from_hours(90 * 24));
542 assert!(c.minimize_stored_events());
543 assert!(!c.keep_origin_client());
544 assert_eq!(c.trusted_proxies(), &[]);
545 assert_eq!(c.orphan_reissue_window(), None);
546 assert_eq!(c.alert_dedup_window(), Duration::ZERO);
547 }
548
549 #[test]
550 fn builder_sets_everything() {
551 let c = GuardConfig::builder()
552 .cookie_name("__Host-app")
553 .session_lifetime(Duration::from_secs(3600))
554 .idle_timeout(Duration::from_secs(600))
555 .trusted_proxies(["10.0.0.0/8", "::1", " 192.168.1.7 "])
556 .unwrap()
557 .tls_fingerprint_header("X-JA4")
558 .checks(Checks::default().disable(Check::AsnChanged))
559 .thresholds(Thresholds {
560 step_up: 30,
561 revoke: 90,
562 })
563 .mode(Mode::Shadow)
564 .step_up_policy(StepUpPolicy::Allow)
565 .minimize_stored_events(false)
566 .keep_origin_client(true)
567 .orphan_reissue_window(Duration::from_secs(3600))
568 .alert_dedup_window(Duration::from_mins(15))
569 .build()
570 .unwrap();
571 assert_eq!(c.cookie().cookie_name(), "__Host-app");
572 assert_eq!(c.alert_dedup_window(), Duration::from_mins(15));
573 assert_eq!(c.orphan_reissue_window(), Some(Duration::from_secs(3600)));
574 assert_eq!(c.trusted_proxies().len(), 3);
575 assert!(
576 c.trusted_proxies()
577 .iter()
578 .any(|n| n.contains(&"10.1.2.3".parse::<IpAddr>().unwrap()))
579 );
580 assert!(
581 c.trusted_proxies()
582 .iter()
583 .any(|n| n.contains(&"192.168.1.7".parse::<IpAddr>().unwrap()))
584 );
585 assert!(
586 !c.trusted_proxies()
587 .iter()
588 .any(|n| n.contains(&"192.168.1.8".parse::<IpAddr>().unwrap()))
589 );
590 assert_eq!(c.tls_fingerprint_header(), Some("x-ja4"));
591 assert_eq!(c.mode(), Mode::Shadow);
592 assert_eq!(c.step_up_policy(), StepUpPolicy::Allow);
593 assert!(!c.minimize_stored_events());
594 assert!(c.keep_origin_client());
595 }
596
597 #[test]
598 fn invalid_configs_are_rejected() {
599 let b = GuardConfig::builder;
600 assert_eq!(
601 b().trusted_proxies(["nope"]).unwrap_err(),
602 ConfigError::InvalidProxy("nope".into())
603 );
604 assert!(matches!(
605 b().cookie(CookieConfig::default().secure(false)).build(),
606 Err(ConfigError::InvalidCookie(_))
607 ));
608 assert_eq!(
609 b().thresholds(Thresholds {
610 step_up: 90,
611 revoke: 80
612 })
613 .build()
614 .unwrap_err(),
615 ConfigError::InvalidThresholds
616 );
617 assert_eq!(
618 b().rotation(RotationConfig {
619 grace: Duration::from_secs(600),
620 ..RotationConfig::default()
621 })
622 .build()
623 .unwrap_err(),
624 ConfigError::GraceTooLong
625 );
626 assert_eq!(
627 b().session_lifetime(Duration::ZERO).build().unwrap_err(),
628 ConfigError::InvalidDuration("session_lifetime")
629 );
630 assert_eq!(
631 b().idle_timeout(Duration::MAX).build().unwrap_err(),
632 ConfigError::InvalidDuration("idle_timeout")
633 );
634 assert_eq!(
635 b().orphan_reissue_window(Duration::MAX).build().unwrap_err(),
636 ConfigError::InvalidDuration("orphan_reissue_window")
637 );
638 assert_eq!(
639 b().alert_dedup_window(Duration::MAX).build().unwrap_err(),
640 ConfigError::InvalidDuration("alert_dedup_window")
641 );
642 assert!(matches!(
643 b().tls_fingerprint_header("x ja4").build(),
644 Err(ConfigError::InvalidHeaderName(_))
645 ));
646 }
647
648 #[test]
649 fn token_prefix_is_optional_and_validated() {
650 assert_eq!(GuardConfig::default().token_prefix(), None);
651 let c = GuardConfig::builder().token_prefix("sess_").build().unwrap();
652 assert_eq!(c.token_prefix(), Some("sess_"));
653 for bad in ["", "se ss_", "sess;", "sess=", "é_", &"a".repeat(33)] {
654 assert_eq!(
655 GuardConfig::builder().token_prefix(bad).build().unwrap_err(),
656 ConfigError::InvalidTokenPrefix(bad.to_owned()),
657 "{bad:?}"
658 );
659 }
660 }
661
662 #[test]
663 fn zero_is_allowed_where_it_means_off() {
664 let c = GuardConfig::builder()
665 .ping_pong_window(Duration::ZERO)
666 .last_seen_write_interval(Duration::ZERO)
667 .shadow_dedup_window(Duration::ZERO)
668 .alert_dedup_window(Duration::ZERO)
669 .orphan_reissue_window(Duration::ZERO)
670 .rotation(RotationConfig {
671 grace: Duration::ZERO,
672 ..RotationConfig::default()
673 })
674 .build()
675 .unwrap();
676 assert_eq!(c.rotation().grace, Duration::ZERO);
677 assert_eq!(c.orphan_reissue_window(), Some(Duration::ZERO));
678 }
679}