use std::fmt;
use std::sync::Arc;
use guard_core_engine::referrer::{self, REFERRER_CHECK_NAME, ReferrerVerdict};
use guard_core_engine::time_window::{
self, TIME_WINDOW_BLOCK_BODY, TIME_WINDOW_BLOCK_STATUS, TIME_WINDOW_CHECK_NAME, TimeWindow,
};
use crate::event_types::EVENT_DECORATOR_VIOLATION;
use crate::events::{MIDDLEWARE_HANDLER_NAME, SecurityEvent, SecurityEventBus};
use crate::redact::SensitiveNames;
use crate::responses::{
CustomErrorResponses, OnBlockHook, build_block_payload, fire_block_hook, resolve_error_body,
};
struct DecoratorViolation<'a> {
reason: &'a str,
client_ip: &'a str,
path: &'a str,
method: &'a str,
decorator_type: &'a str,
violation_type: &'a str,
extra: Vec<(&'static str, serde_json::Value)>,
}
fn send_decorator_violation(
events: Option<&Arc<SecurityEventBus>>,
passive: bool,
violation: &DecoratorViolation<'_>,
) {
let Some(bus) = events else {
return;
};
let mut event = SecurityEvent::new(
EVENT_DECORATOR_VIOLATION,
violation.client_ip,
if passive {
"logged_only"
} else {
"request_blocked"
},
violation.reason,
MIDDLEWARE_HANDLER_NAME,
);
event.decorator_type = Some(violation.decorator_type.to_owned());
event.metadata.insert(
String::from("decorator_type"),
serde_json::json!(violation.decorator_type),
);
event.metadata.insert(
String::from("violation_type"),
serde_json::json!(violation.violation_type),
);
for (key, value) in &violation.extra {
event.metadata.insert(String::from(*key), value.clone());
}
event.endpoint = Some(violation.path.to_owned());
event.method = Some(violation.method.to_owned());
bus.send_event(&event);
}
pub type RouteReferrerResolver = Arc<dyn Fn(&str) -> Option<Vec<String>> + Send + Sync>;
pub type RouteTimeWindowResolver = Arc<dyn Fn(&str) -> Option<TimeWindow> + Send + Sync>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GateAnswer {
pub status: u16,
pub body: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct GateConfig {
pub passive_mode: bool,
}
#[derive(Clone)]
pub struct ReferrerStage {
config: GateConfig,
resolver: Option<RouteReferrerResolver>,
on_block: Option<OnBlockHook>,
custom_error_responses: CustomErrorResponses,
sensitive: Arc<SensitiveNames>,
events: Option<Arc<SecurityEventBus>>,
}
impl fmt::Debug for ReferrerStage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReferrerStage").finish_non_exhaustive()
}
}
#[derive(Default)]
pub struct ReferrerStageBuilder {
config: GateConfig,
resolver: Option<RouteReferrerResolver>,
on_block: Option<OnBlockHook>,
custom_error_responses: CustomErrorResponses,
sensitive: SensitiveNames,
events: Option<Arc<SecurityEventBus>>,
}
impl ReferrerStage {
#[must_use]
pub fn builder(config: GateConfig) -> ReferrerStageBuilder {
ReferrerStageBuilder {
config,
resolver: None,
on_block: None,
custom_error_responses: CustomErrorResponses::new(),
sensitive: SensitiveNames::default(),
events: None,
}
}
#[must_use]
pub fn decide(
&self,
path: &str,
referer: Option<&str>,
client_ip: &str,
payload_path: &str,
method: &str,
) -> Option<GateAnswer> {
let allowed = self.resolver.as_ref().and_then(|resolve| resolve(path))?;
if allowed.is_empty() {
return None;
}
let (reason, status, body, extra) = match referrer::decide(referer, &allowed) {
ReferrerVerdict::Allowed => return None,
ReferrerVerdict::Missing => (
String::from("Missing referrer header"),
referrer::REFERRER_MISSING_STATUS,
referrer::REFERRER_MISSING_BODY,
Vec::new(),
),
ReferrerVerdict::Invalid { referrer } => {
let redacted = crate::redact::redact_url_for_display(&referrer, &self.sensitive);
(
format!("Referrer '{redacted}' not in allowed domains"),
referrer::REFERRER_INVALID_STATUS,
referrer::REFERRER_INVALID_BODY,
vec![("referrer", serde_json::json!(redacted))],
)
}
};
let mut metadata_extra = extra;
metadata_extra.push(("allowed_domains", serde_json::json!(allowed)));
send_decorator_violation(
self.events.as_ref(),
self.config.passive_mode,
&DecoratorViolation {
reason: &reason,
client_ip,
path: payload_path,
method,
decorator_type: "content_filtering",
violation_type: "require_referrer",
extra: metadata_extra,
},
);
self.answer(
REFERRER_CHECK_NAME,
&reason,
status,
body,
client_ip,
payload_path,
method,
)
}
#[allow(clippy::too_many_arguments)]
fn answer(
&self,
check_name: &str,
reason: &str,
status: u16,
default_body: &str,
client_ip: &str,
path: &str,
method: &str,
) -> Option<GateAnswer> {
if self.config.passive_mode {
fire_block_hook(
self.on_block.as_ref(),
&build_block_payload(
check_name,
reason,
"",
true,
client_ip,
path,
method,
None,
&self.sensitive,
),
);
return None;
}
fire_block_hook(
self.on_block.as_ref(),
&build_block_payload(
check_name,
reason,
"",
false,
client_ip,
path,
method,
Some(status),
&self.sensitive,
),
);
Some(GateAnswer {
status,
body: resolve_error_body(&self.custom_error_responses, status, default_body),
})
}
}
impl ReferrerStageBuilder {
#[must_use]
pub fn resolver(mut self, resolver: RouteReferrerResolver) -> Self {
self.resolver = Some(resolver);
self
}
#[must_use]
pub const fn passive_mode(mut self, passive_mode: bool) -> Self {
self.config.passive_mode = passive_mode;
self
}
#[must_use]
pub fn on_block(mut self, hook: OnBlockHook) -> Self {
self.on_block = Some(hook);
self
}
#[must_use]
pub fn custom_error_responses(mut self, custom: CustomErrorResponses) -> Self {
self.custom_error_responses.extend(custom);
self
}
#[must_use]
pub fn events(mut self, bus: Arc<SecurityEventBus>) -> Self {
self.events = Some(bus);
self
}
#[must_use]
pub fn build(self) -> ReferrerStage {
ReferrerStage {
config: self.config,
resolver: self.resolver,
on_block: self.on_block,
custom_error_responses: self.custom_error_responses,
sensitive: Arc::new(self.sensitive),
events: self.events,
}
}
}
#[derive(Clone)]
pub struct TimeWindowStage {
config: GateConfig,
resolver: Option<RouteTimeWindowResolver>,
on_block: Option<OnBlockHook>,
custom_error_responses: CustomErrorResponses,
sensitive: Arc<SensitiveNames>,
events: Option<Arc<SecurityEventBus>>,
}
impl fmt::Debug for TimeWindowStage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TimeWindowStage").finish_non_exhaustive()
}
}
#[derive(Default)]
pub struct TimeWindowStageBuilder {
config: GateConfig,
resolver: Option<RouteTimeWindowResolver>,
on_block: Option<OnBlockHook>,
custom_error_responses: CustomErrorResponses,
sensitive: SensitiveNames,
events: Option<Arc<SecurityEventBus>>,
}
impl TimeWindowStage {
#[must_use]
pub fn builder(config: GateConfig) -> TimeWindowStageBuilder {
TimeWindowStageBuilder {
config,
resolver: None,
on_block: None,
custom_error_responses: CustomErrorResponses::new(),
sensitive: SensitiveNames::default(),
events: None,
}
}
#[must_use]
pub fn decide(
&self,
path: &str,
client_ip: &str,
payload_path: &str,
method: &str,
) -> Option<GateAnswer> {
let now = chrono::Utc::now();
self.decide_at(path, now, client_ip, payload_path, method)
}
#[must_use]
pub fn decide_at(
&self,
path: &str,
now_utc: chrono::DateTime<chrono::Utc>,
client_ip: &str,
payload_path: &str,
method: &str,
) -> Option<GateAnswer> {
let window = self.resolver.as_ref().and_then(|resolve| resolve(path))?;
if window.timezone.is_none() && window.start.is_none() && window.end.is_none() {
return None;
}
let current = time_window::hhmm_in_zone(now_utc, window.timezone.as_deref());
if time_window::is_within(&window, ¤t) {
return None;
}
send_decorator_violation(
self.events.as_ref(),
self.config.passive_mode,
&DecoratorViolation {
reason: "Access outside allowed time window",
client_ip,
path: payload_path,
method,
decorator_type: "advanced",
violation_type: "time_restriction",
extra: Vec::new(),
},
);
self.answer(
TIME_WINDOW_CHECK_NAME,
"Access outside allowed time window",
TIME_WINDOW_BLOCK_STATUS,
TIME_WINDOW_BLOCK_BODY,
client_ip,
payload_path,
method,
)
}
#[allow(clippy::too_many_arguments)]
fn answer(
&self,
check_name: &str,
reason: &str,
status: u16,
default_body: &str,
client_ip: &str,
path: &str,
method: &str,
) -> Option<GateAnswer> {
if self.config.passive_mode {
fire_block_hook(
self.on_block.as_ref(),
&build_block_payload(
check_name,
reason,
"",
true,
client_ip,
path,
method,
None,
&self.sensitive,
),
);
return None;
}
fire_block_hook(
self.on_block.as_ref(),
&build_block_payload(
check_name,
reason,
"",
false,
client_ip,
path,
method,
Some(status),
&self.sensitive,
),
);
Some(GateAnswer {
status,
body: resolve_error_body(&self.custom_error_responses, status, default_body),
})
}
}
impl TimeWindowStageBuilder {
#[must_use]
pub fn resolver(mut self, resolver: RouteTimeWindowResolver) -> Self {
self.resolver = Some(resolver);
self
}
#[must_use]
pub const fn passive_mode(mut self, passive_mode: bool) -> Self {
self.config.passive_mode = passive_mode;
self
}
#[must_use]
pub fn on_block(mut self, hook: OnBlockHook) -> Self {
self.on_block = Some(hook);
self
}
#[must_use]
pub fn custom_error_responses(mut self, custom: CustomErrorResponses) -> Self {
self.custom_error_responses.extend(custom);
self
}
#[must_use]
pub fn events(mut self, bus: Arc<SecurityEventBus>) -> Self {
self.events = Some(bus);
self
}
#[must_use]
pub fn build(self) -> TimeWindowStage {
TimeWindowStage {
config: self.config,
resolver: self.resolver,
on_block: self.on_block,
custom_error_responses: self.custom_error_responses,
sensitive: Arc::new(self.sensitive),
events: self.events,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use std::sync::Mutex;
fn at_utc(hour: u32, minute: u32) -> chrono::DateTime<chrono::Utc> {
chrono::Utc
.with_ymd_and_hms(2026, 1, 15, hour, minute, 0)
.unwrap()
}
fn recorder() -> (
Arc<Mutex<Option<crate::responses::BlockPayload>>>,
OnBlockHook,
) {
let cell: Arc<Mutex<Option<crate::responses::BlockPayload>>> = Arc::new(Mutex::new(None));
let hook = Arc::clone(&cell);
(
cell,
Arc::new(move |fired| {
*hook.lock().expect("recorder") = Some(fired.clone());
}),
)
}
fn referrer_stage() -> ReferrerStage {
ReferrerStage::builder(GateConfig::default())
.resolver(Arc::new(|path| {
(path == "/embed").then(|| vec!["partner.example.com".to_owned()])
}))
.build()
}
fn recording_bus() -> (Arc<Mutex<Vec<SecurityEvent>>>, Arc<SecurityEventBus>) {
let log = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&log);
let bus = Arc::new(SecurityEventBus::new(true).on_event(Arc::new(
move |event: &SecurityEvent| {
sink.lock().expect("sink").push(event.clone());
},
)));
(log, bus)
}
#[test]
fn the_referrer_deny_fires_the_decorator_violation_with_its_metadata() {
let (log, bus) = recording_bus();
let stage = ReferrerStage::builder(GateConfig::default())
.resolver(Arc::new(|path| {
(path == "/embed").then(|| vec!["partner.example.com".to_owned()])
}))
.events(bus)
.build();
stage
.decide("/embed", None, "1.2.3.4", "/embed", "GET")
.expect("missing");
stage
.decide(
"/embed",
Some("https://evil.test/x?token=secret"),
"1.2.3.4",
"/embed",
"GET",
)
.expect("invalid");
let events = log.lock().expect("sink").clone();
assert_eq!(events.len(), 2);
assert_eq!(events[0].event_type, "decorator_violation");
assert_eq!(events[0].action_taken, "request_blocked");
assert_eq!(events[0].reason, "Missing referrer header");
assert_eq!(
events[0].decorator_type.as_deref(),
Some("content_filtering")
);
assert_eq!(events[0].metadata["violation_type"], "require_referrer");
assert_eq!(
events[0].metadata["allowed_domains"],
serde_json::json!(["partner.example.com"])
);
assert_eq!(
events[1].reason,
"Referrer 'https://evil.test/x?token=[REDACTED]' not in allowed domains"
);
assert_eq!(
events[1].metadata["referrer"], "https://evil.test/x?token=[REDACTED]",
"the redacted referrer rides the metadata"
);
}
#[test]
fn passive_mode_flips_the_referrer_event_action() {
let (log, bus) = recording_bus();
let stage = ReferrerStage::builder(GateConfig { passive_mode: true })
.resolver(Arc::new(|path| {
(path == "/embed").then(|| vec!["partner.example.com".to_owned()])
}))
.events(bus)
.build();
assert!(
stage
.decide("/embed", None, "1.2.3.4", "/embed", "GET")
.is_none()
);
let events = log.lock().expect("sink").clone();
assert_eq!(events.len(), 1);
assert_eq!(events[0].action_taken, "logged_only");
}
#[test]
fn the_time_window_deny_fires_the_decorator_violation() {
let (log, bus) = recording_bus();
let stage = TimeWindowStage::builder(GateConfig::default())
.resolver(Arc::new(|path| {
(path == "/nightly").then(|| TimeWindow {
start: Some(String::from("09:00")),
end: Some(String::from("17:00")),
timezone: None,
})
}))
.events(bus)
.build();
assert!(
stage
.decide_at("/nightly", at_utc(3, 0), "1.2.3.4", "/nightly", "GET")
.is_some()
);
let events = log.lock().expect("sink").clone();
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, "decorator_violation");
assert_eq!(events[0].decorator_type.as_deref(), Some("advanced"));
assert_eq!(events[0].metadata["violation_type"], "time_restriction");
assert_eq!(events[0].reason, "Access outside allowed time window");
assert_eq!(events[0].action_taken, "request_blocked");
}
#[test]
fn referrer_allows_the_configured_domain_and_its_subdomains() {
let stage = referrer_stage();
assert!(
stage
.decide(
"/embed",
Some("https://partner.example.com/x"),
"1.2.3.4",
"/embed",
"GET"
)
.is_none()
);
assert!(
stage
.decide(
"/embed",
Some("https://api.partner.example.com/"),
"1.2.3.4",
"/embed",
"GET"
)
.is_none()
);
assert!(
stage
.decide("/open", None, "1.2.3.4", "/open", "GET")
.is_none(),
"no route entry: the check never runs"
);
}
#[test]
fn referrer_blocks_missing_and_invalid_with_the_reference_shapes() {
let stage = referrer_stage();
let answer = stage
.decide("/embed", None, "1.2.3.4", "/embed", "GET")
.expect("missing");
assert_eq!(answer.status, 403);
assert_eq!(answer.body, "Referrer required");
let answer = stage
.decide(
"/embed",
Some("https://evil.test/x"),
"1.2.3.4",
"/embed",
"GET",
)
.expect("invalid");
assert_eq!(answer.status, 403);
assert_eq!(answer.body, "Invalid referrer");
}
#[test]
fn referrer_passive_mode_observes_only() {
let (cell, hook) = recorder();
let stage = ReferrerStage::builder(GateConfig { passive_mode: true })
.resolver(Arc::new(|path| {
(path == "/embed").then(|| vec!["partner.example.com".to_owned()])
}))
.on_block(hook)
.build();
assert!(
stage
.decide("/embed", None, "1.2.3.4", "/embed", "GET")
.is_none()
);
let fired = cell.lock().expect("cell").clone().expect("fired");
assert_eq!(fired.check_name, REFERRER_CHECK_NAME);
assert!(fired.passive_mode);
assert_eq!(fired.status_code, None);
assert_eq!(fired.reason, "Missing referrer header");
}
#[test]
fn referrer_active_mode_fires_the_block_payload() {
let (cell, hook) = recorder();
let stage = ReferrerStage::builder(GateConfig::default())
.resolver(Arc::new(|path| {
(path == "/embed").then(|| vec!["partner.example.com".to_owned()])
}))
.on_block(hook)
.build();
assert!(
stage
.decide("/embed", None, "192.0.2.9", "/embed", "POST")
.is_some()
);
let fired = cell.lock().expect("cell").clone().expect("fired");
assert_eq!(fired.status_code, Some(403));
assert_eq!(fired.client_ip, "192.0.2.9");
assert_eq!(fired.path, "/embed");
assert_eq!(fired.method, "POST");
}
fn window_stage() -> TimeWindowStage {
TimeWindowStage::builder(GateConfig::default())
.resolver(Arc::new(|path| {
(path == "/nightly").then(|| TimeWindow {
start: Some("09:00".into()),
end: Some("17:00".into()),
timezone: Some("UTC".into()),
})
}))
.build()
}
#[test]
fn time_window_allows_inside_and_blocks_outside() {
let stage = window_stage();
assert!(
stage
.decide_at("/nightly", at_utc(12, 0), "1.2.3.4", "/nightly", "GET")
.is_none()
);
assert!(
stage
.decide_at("/nightly", at_utc(9, 0), "1.2.3.4", "/nightly", "GET")
.is_none()
);
let answer = stage
.decide_at("/nightly", at_utc(18, 30), "1.2.3.4", "/nightly", "GET")
.expect("blocked");
assert_eq!(answer.status, 403);
assert_eq!(answer.body, "Access not allowed at this time");
assert!(
stage
.decide_at("/open", at_utc(23, 0), "1.2.3.4", "/open", "GET")
.is_none()
);
}
#[test]
fn time_window_uses_the_zone_clock() {
let stage = TimeWindowStage::builder(GateConfig::default())
.resolver(Arc::new(|path| {
(path == "/jp").then(|| TimeWindow {
start: Some("09:00".into()),
end: Some("17:00".into()),
timezone: Some("Asia/Tokyo".into()),
})
}))
.build();
assert!(
stage
.decide_at("/jp", at_utc(12, 0), "1.2.3.4", "/jp", "GET")
.is_some()
);
assert!(
stage
.decide_at("/jp", at_utc(0, 0), "1.2.3.4", "/jp", "GET")
.is_none()
);
}
#[test]
fn time_window_missing_bounds_fail_open() {
let stage = TimeWindowStage::builder(GateConfig::default())
.resolver(Arc::new(|path| {
(path == "/broken").then(|| TimeWindow {
start: None,
end: Some("06:00".into()),
timezone: None,
})
}))
.build();
assert!(
stage
.decide_at("/broken", at_utc(12, 0), "1.2.3.4", "/broken", "GET")
.is_none(),
"the reference's fail-open except arm allows the request"
);
}
#[test]
fn both_gates_honor_custom_error_bodies() {
let mut custom = CustomErrorResponses::new();
custom.insert(403, "Not now".to_owned());
let referrer = ReferrerStage::builder(GateConfig::default())
.resolver(Arc::new(|path| {
(path == "/embed").then(|| vec!["partner.example.com".to_owned()])
}))
.custom_error_responses(custom.clone())
.build();
assert_eq!(
referrer
.decide("/embed", None, "1.2.3.4", "/embed", "GET")
.expect("blocked")
.body,
"Not now"
);
let window = TimeWindowStage::builder(GateConfig::default())
.resolver(Arc::new(|path| {
(path == "/nightly").then(|| TimeWindow {
start: Some("09:00".into()),
end: Some("17:00".into()),
timezone: None,
})
}))
.custom_error_responses(custom)
.build();
assert_eq!(
window
.decide_at("/nightly", at_utc(20, 0), "1.2.3.4", "/nightly", "GET")
.expect("blocked")
.body,
"Not now"
);
}
#[test]
fn both_gates_render_their_debug_shapes() {
let referrer = referrer_stage();
let rendered = format!("{referrer:?}");
assert!(rendered.starts_with("ReferrerStage"));
let window = window_stage();
let rendered = format!("{window:?}");
assert!(rendered.starts_with("TimeWindowStage"));
}
#[test]
fn an_empty_referrer_requirement_never_runs_the_check() {
let stage = ReferrerStage::builder(GateConfig::default())
.resolver(Arc::new(|path| (path == "/open").then_some(Vec::new())))
.build();
assert!(
stage
.decide("/open", None, "1.2.3.4", "/open", "GET")
.is_none(),
"an empty allowed list means the route requires nothing"
);
}
#[test]
fn the_referrer_builder_carries_passive_mode() {
let (cell, hook) = recorder();
let stage = ReferrerStage::builder(GateConfig::default())
.resolver(Arc::new(|path| {
(path == "/embed").then(|| vec!["partner.example.com".to_owned()])
}))
.passive_mode(true)
.on_block(hook)
.build();
assert!(
stage
.decide("/embed", None, "1.2.3.4", "/embed", "GET")
.is_none()
);
let fired = cell.lock().expect("cell").clone().expect("fired");
assert!(fired.passive_mode);
assert_eq!(fired.status_code, None);
}
#[test]
fn the_wall_clock_decide_passes_an_unconfigured_path() {
let stage = window_stage();
assert!(stage.decide("/open", "1.2.3.4", "/open", "GET").is_none());
}
#[test]
fn an_all_none_window_fails_open() {
let stage = TimeWindowStage::builder(GateConfig::default())
.resolver(Arc::new(|path| {
(path == "/blank").then_some(TimeWindow {
start: None,
end: None,
timezone: None,
})
}))
.build();
assert!(
stage
.decide_at("/blank", at_utc(12, 0), "1.2.3.4", "/blank", "GET")
.is_none(),
"a window with no bounds restricts nothing"
);
}
#[test]
fn the_time_window_builder_carries_passive_mode_and_the_hook() {
let (cell, hook) = recorder();
let stage = TimeWindowStage::builder(GateConfig::default())
.resolver(Arc::new(|path| {
(path == "/nightly").then(|| TimeWindow {
start: Some("09:00".into()),
end: Some("17:00".into()),
timezone: Some("UTC".into()),
})
}))
.passive_mode(true)
.on_block(hook)
.build();
assert!(
stage
.decide_at("/nightly", at_utc(18, 30), "1.2.3.4", "/nightly", "GET")
.is_none(),
"passive mode observes the outside-the-window match only"
);
let fired = cell.lock().expect("cell").clone().expect("fired");
assert_eq!(fired.check_name, TIME_WINDOW_CHECK_NAME);
assert!(fired.passive_mode);
assert_eq!(fired.status_code, None);
assert_eq!(fired.reason, "Access outside allowed time window");
}
}