use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use ::tower::Layer;
use http::{Request, Response, StatusCode};
use crate::event_types::{EVENT_DECORATOR_VIOLATION, EVENT_HTTPS_ENFORCED};
use crate::events::{MIDDLEWARE_HANDLER_NAME, SecurityEvent, SecurityEventBus};
use crate::redact::SensitiveNames;
pub use guard_core_engine::https_enforcement::{
HTTPS_ENFORCEMENT_CHECK_NAME, HTTPS_REDIRECT_STATUS,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpsRedirectAnswer {
pub status: u16,
pub location: String,
}
pub type RouteHttpsResolver = Arc<dyn Fn(&str) -> Option<bool> + Send + Sync>;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct HttpsEnforcementStageConfig {
pub enforce_https: bool,
pub trust_x_forwarded_proto: bool,
pub passive_mode: bool,
}
#[derive(Clone)]
pub struct HttpsEnforcementStage {
config: HttpsEnforcementStageConfig,
trusted_proxies: Vec<String>,
route_resolver: Option<RouteHttpsResolver>,
events: Option<Arc<SecurityEventBus>>,
sensitive: Arc<SensitiveNames>,
}
impl fmt::Debug for HttpsEnforcementStage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HttpsEnforcementStage")
.field("config", &self.config)
.field("trusted_proxies", &self.trusted_proxies.len())
.finish_non_exhaustive()
}
}
#[derive(Default)]
pub struct HttpsEnforcementStageBuilder {
config: HttpsEnforcementStageConfig,
trusted_proxies: Vec<String>,
route_resolver: Option<RouteHttpsResolver>,
events: Option<Arc<SecurityEventBus>>,
sensitive: SensitiveNames,
}
impl HttpsEnforcementStage {
#[must_use]
pub fn builder(config: HttpsEnforcementStageConfig) -> HttpsEnforcementStageBuilder {
HttpsEnforcementStageBuilder {
config,
trusted_proxies: Vec::new(),
route_resolver: None,
events: None,
sensitive: SensitiveNames::default(),
}
}
pub fn decide(
&self,
path: &str,
url_scheme: &str,
client_host: Option<&str>,
x_forwarded_proto: Option<&str>,
https_url: &str,
) -> Option<HttpsRedirectAnswer> {
let engine_config = guard_core_engine::https_enforcement::HttpsEnforcementConfig::new(
self.config.enforce_https,
self.config.trust_x_forwarded_proto,
self.trusted_proxies.iter().map(String::as_str),
)
.expect("builder validated the proxy list");
let route_require_https = self
.route_resolver
.as_ref()
.and_then(|resolve| resolve(path));
let request = guard_core_engine::https_enforcement::HttpsRequest {
url_scheme,
client_host,
x_forwarded_proto,
route_require_https,
};
match guard_core_engine::https_enforcement::decide(&request, &engine_config) {
guard_core_engine::https_enforcement::HttpsVerdict::Allowed => None,
guard_core_engine::https_enforcement::HttpsVerdict::Redirect { route_scoped } => {
self.observe_https_violation(
route_scoped,
url_scheme,
client_host,
path,
https_url,
);
if self.config.passive_mode {
None
} else {
Some(HttpsRedirectAnswer {
status: HTTPS_REDIRECT_STATUS,
location: https_url.to_owned(),
})
}
}
}
}
fn observe_https_violation(
&self,
route_scoped: bool,
original_scheme: &str,
client_host: Option<&str>,
path: &str,
https_url: &str,
) {
let Some(bus) = &self.events else {
return;
};
let mut event = if route_scoped {
let mut event = SecurityEvent::new(
EVENT_DECORATOR_VIOLATION,
client_host.unwrap_or_default(),
"https_redirect",
"Route requires HTTPS but request was HTTP",
MIDDLEWARE_HANDLER_NAME,
);
event.decorator_type = Some(String::from("authentication"));
event.metadata.insert(
String::from("decorator_type"),
serde_json::json!("authentication"),
);
event.metadata.insert(
String::from("violation_type"),
serde_json::json!("require_https"),
);
event
} else {
SecurityEvent::new(
EVENT_HTTPS_ENFORCED,
client_host.unwrap_or_default(),
"https_redirect",
"HTTP request redirected to HTTPS for security",
MIDDLEWARE_HANDLER_NAME,
)
};
event.metadata.insert(
String::from("original_scheme"),
serde_json::json!(original_scheme),
);
event.metadata.insert(
String::from("redirect_url"),
serde_json::json!(crate::redact::redact_url_for_display(
https_url,
&self.sensitive
)),
);
event.endpoint = Some(crate::redact::redact_url_for_display(path, &self.sensitive));
bus.send_event(&event);
}
}
impl HttpsEnforcementStageBuilder {
#[must_use]
pub const fn enforce_https(mut self, enforce_https: bool) -> Self {
self.config.enforce_https = enforce_https;
self
}
#[must_use]
pub fn trusted_proxies<I>(mut self, entries: I) -> Self
where
I: IntoIterator,
I::Item: AsRef<str>,
{
self.trusted_proxies
.extend(entries.into_iter().map(|entry| entry.as_ref().to_owned()));
self
}
#[must_use]
pub fn route_resolver(mut self, resolver: RouteHttpsResolver) -> Self {
self.route_resolver = Some(resolver);
self
}
#[must_use]
pub fn events(mut self, bus: Arc<SecurityEventBus>) -> Self {
self.events = Some(bus);
self
}
#[must_use]
pub fn sensitive(mut self, sensitive: SensitiveNames) -> Self {
self.sensitive = sensitive;
self
}
pub fn build(self) -> Result<HttpsEnforcementStage, guard_core_engine::ip_gate::IpGateError> {
guard_core_engine::https_enforcement::HttpsEnforcementConfig::new(
self.config.enforce_https,
self.config.trust_x_forwarded_proto,
self.trusted_proxies.iter().map(String::as_str),
)?;
Ok(HttpsEnforcementStage {
config: self.config,
trusted_proxies: self.trusted_proxies,
route_resolver: self.route_resolver,
events: self.events,
sensitive: Arc::new(self.sensitive),
})
}
}
#[derive(Clone)]
pub struct HttpsEnforcementStageLayer {
stage: HttpsEnforcementStage,
}
impl HttpsEnforcementStageLayer {
#[must_use]
pub const fn new(stage: HttpsEnforcementStage) -> Self {
Self { stage }
}
}
impl fmt::Debug for HttpsEnforcementStageLayer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HttpsEnforcementStageLayer")
.field("stage", &self.stage)
.finish()
}
}
impl<S> Layer<S> for HttpsEnforcementStageLayer {
type Service = HttpsEnforcementStageService<S>;
fn layer(&self, inner: S) -> Self::Service {
HttpsEnforcementStageService {
inner,
stage: self.stage.clone(),
}
}
}
#[derive(Clone)]
pub struct HttpsEnforcementStageService<S> {
inner: S,
stage: HttpsEnforcementStage,
}
fn host_of_authority(value: &str) -> &str {
let host_port = value.rsplit('@').next().unwrap_or_default();
if let Some(rest) = host_port.strip_prefix('[') {
return rest.split(']').next().unwrap_or(rest);
}
match host_port.split_once(':') {
Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
_ => host_port,
}
}
impl<S, B, ResBody> ::tower::Service<Request<B>> for HttpsEnforcementStageService<S>
where
S: ::tower::Service<Request<B>, Response = Response<ResBody>>,
S::Future: Send + 'static,
ResBody: From<&'static str> + Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = Result<S::Response, S::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), S::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, request: Request<B>) -> Self::Future {
let scheme = request.uri().scheme_str().unwrap_or("http").to_owned();
let path = request.uri().path().to_owned();
let host_and_port = request
.uri()
.authority()
.map(|authority| authority.as_str().to_owned())
.or_else(|| {
request
.headers()
.get(http::header::HOST)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned)
});
let client_host = host_and_port
.as_deref()
.map(|value| host_of_authority(value).to_owned());
let forwarded = request
.headers()
.get("x-forwarded-proto")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let authority = host_and_port.unwrap_or_default();
let query = request
.uri()
.query()
.map(|q| format!("?{q}"))
.unwrap_or_default();
let https_url = format!("https://{authority}{path}{query}");
let answer = self.stage.decide(
&path,
&scheme,
client_host.as_deref(),
forwarded.as_deref(),
&https_url,
);
let Some(redirect) = answer else {
return Box::pin(self.inner.call(request));
};
drop(request);
let status = StatusCode::from_u16(redirect.status).expect("reference status");
let mut response = Response::new(ResBody::from(""));
*response.status_mut() = status;
#[cfg(not(coverage))] if let Ok(value) = http::HeaderValue::from_str(&redirect.location) {
response.headers_mut().insert(http::header::LOCATION, value);
}
#[cfg(coverage)]
response.headers_mut().insert(
http::header::LOCATION,
http::HeaderValue::from_str(&redirect.location)
.expect("the composed location is a valid header value"),
);
Box::pin(async move { Ok(response) })
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::convert::Infallible;
use ::tower::Service;
fn stage() -> HttpsEnforcementStage {
HttpsEnforcementStage::builder(HttpsEnforcementStageConfig::default())
.enforce_https(true)
.build()
.expect("valid")
}
fn route_stage() -> HttpsEnforcementStage {
HttpsEnforcementStage::builder(HttpsEnforcementStageConfig::default())
.enforce_https(false)
.route_resolver(Arc::new(|path: &str| match path {
"/secure" => Some(true),
"/open" => Some(false),
_ => None,
}))
.build()
.expect("valid")
}
#[test]
fn global_enforcement_redirects_plain_http_and_passes_https() {
let stage = stage();
let answer = stage
.decide("/x", "http", None, None, "https://host/x")
.expect("redirected");
assert_eq!(answer.status, 301);
assert_eq!(answer.location, "https://host/x");
assert!(stage.decide("/x", "https", None, None, "").is_none());
}
#[test]
fn the_route_verdict_wins_both_ways() {
let stage = route_stage();
assert!(
stage.decide("/secure", "http", None, None, "").is_some(),
"a route requiring https redirects even with the global arm off"
);
assert!(
stage.decide("/open", "http", None, None, "").is_none(),
"a route opting out passes even under a global arm"
);
assert!(
stage.decide("/other", "http", None, None, "").is_none(),
"no route entry and a globally-off engine passes"
);
}
#[test]
fn passive_mode_observes_without_redirecting() {
let stage = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig {
enforce_https: true,
passive_mode: true,
..HttpsEnforcementStageConfig::default()
})
.build()
.expect("valid");
assert!(
stage
.decide("/x", "http", None, None, "https://host/x")
.is_none(),
"passive mode never answers the redirect"
);
}
#[test]
fn a_forged_forwarded_proto_never_upgrades() {
let stage = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig {
enforce_https: true,
trust_x_forwarded_proto: true,
..HttpsEnforcementStageConfig::default()
})
.trusted_proxies(["10.0.0.0/8"])
.build()
.expect("valid");
assert!(
stage
.decide("/x", "http", Some("203.0.113.9"), Some("https"), "")
.is_some(),
"an untrusted client's header is ignored"
);
assert!(
stage
.decide("/x", "http", Some("10.1.2.3"), Some("https"), "")
.is_none(),
"a trusted proxy's header upgrades the request"
);
}
#[test]
fn the_builder_fails_closed_on_a_bad_proxy_entry() {
let error = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig::default())
.trusted_proxies(["not-an-ip"])
.build()
.unwrap_err();
assert_eq!(error.list, "trusted_proxies");
}
#[test]
fn the_connecting_host_strips_the_port_and_the_v6_brackets() {
assert_eq!(host_of_authority("10.1.2.3:8080"), "10.1.2.3");
assert_eq!(host_of_authority("host.example"), "host.example");
assert_eq!(host_of_authority("host.example:443"), "host.example");
assert_eq!(host_of_authority("[2001:db8::1]:8080"), "2001:db8::1");
assert_eq!(host_of_authority("[2001:db8::1]"), "2001:db8::1");
assert_eq!(host_of_authority("2001:db8::1"), "2001:db8::1");
}
fn recording_bus() -> (
Arc<std::sync::Mutex<Vec<SecurityEvent>>>,
Arc<SecurityEventBus>,
) {
let log = Arc::new(std::sync::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_violation_fires_the_route_and_global_event_split() {
let (log, bus) = recording_bus();
let stage = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig::default())
.enforce_https(true)
.events(Arc::clone(&bus))
.build()
.expect("valid");
stage
.decide("/x", "http", None, None, "https://host.example/x")
.expect("redirected");
let route_stage = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig::default())
.route_resolver(Arc::new(|_: &str| Some(true)))
.events(bus)
.build()
.expect("valid");
route_stage
.decide("/secure", "http", None, None, "https://host.example/secure")
.expect("redirected");
let events = log.lock().expect("sink").clone();
assert_eq!(events.len(), 2);
assert_eq!(events[0].event_type, EVENT_HTTPS_ENFORCED);
assert_eq!(events[0].action_taken, "https_redirect");
assert_eq!(
events[0].reason,
"HTTP request redirected to HTTPS for security"
);
assert_eq!(
events[0].metadata["original_scheme"], "http",
"the reference metadata rides along"
);
assert_eq!(
events[0].metadata["redirect_url"],
serde_json::json!("https://host.example/x")
);
assert_eq!(events[1].event_type, EVENT_DECORATOR_VIOLATION);
assert_eq!(events[1].decorator_type.as_deref(), Some("authentication"));
assert_eq!(events[1].metadata["violation_type"], "require_https");
assert_eq!(
events[1].reason,
"Route requires HTTPS but request was HTTP"
);
}
#[test]
fn passive_mode_still_observes_the_https_violation() {
let (log, bus) = recording_bus();
let stage = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig {
enforce_https: true,
passive_mode: true,
..HttpsEnforcementStageConfig::default()
})
.events(bus)
.build()
.expect("valid");
assert!(
stage
.decide("/x", "http", None, None, "https://host/x")
.is_none()
);
let events = log.lock().expect("sink").clone();
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, EVENT_HTTPS_ENFORCED);
}
#[test]
fn the_sensitive_sets_redact_the_emitted_redirect_target() {
let (log, bus) = recording_bus();
let stage = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig::default())
.enforce_https(true)
.sensitive(SensitiveNames::new(
None,
Some(&std::collections::HashSet::from([String::from(
"guard_test_param",
)])),
None,
))
.events(bus)
.build()
.expect("valid");
stage
.decide(
"/x?guard_test_param=leaked",
"http",
None,
None,
"https://host.example/x?guard_test_param=leaked",
)
.expect("redirected");
let events = log.lock().expect("sink").clone();
assert_eq!(events.len(), 1);
assert_eq!(
events[0].metadata["redirect_url"],
serde_json::json!("https://host.example/x?guard_test_param=[REDACTED]")
);
assert_eq!(
events[0].endpoint.as_deref(),
Some("/x?guard_test_param=[REDACTED]")
);
}
fn block_on<F: Future>(future: F) -> F::Output {
let mut future = std::pin::pin!(future);
let waker = std::task::Waker::noop();
let mut cx = Context::from_waker(waker);
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(output) => return output,
Poll::Pending => std::hint::spin_loop(),
}
}
}
#[derive(Clone)]
struct Inner {
calls: Arc<std::sync::atomic::AtomicUsize>,
}
impl Inner {
fn new() -> Self {
Self {
calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
}
impl ::tower::Service<Request<&'static str>> for Inner {
type Response = Response<&'static str>;
type Error = Infallible;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Infallible>> + Send>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _request: Request<&'static str>) -> Self::Future {
self.calls
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Box::pin(async move { Ok(Response::new("inner")) })
}
}
fn request_to(uri: &str) -> Request<&'static str> {
Request::builder().uri(uri).body("body").expect("request")
}
#[test]
fn the_layer_redirects_http_and_forwards_https() {
let layer = HttpsEnforcementStageLayer::new(stage());
let inner = Inner::new();
let mut service = ::tower::ServiceBuilder::new()
.layer(layer)
.service(inner.clone());
let response =
block_on(service.call(request_to("http://host/private?q=1"))).expect("ready");
assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
assert_eq!(
response
.headers()
.get(http::header::LOCATION)
.expect("location"),
"https://host/private?q=1"
);
assert_eq!(
inner.calls.load(std::sync::atomic::Ordering::Relaxed),
0,
"the redirected request never hits the inner service"
);
let response = block_on(service.call(request_to("https://host/private"))).expect("ready");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.body(), &"inner");
}
#[test]
fn the_stage_and_layer_render_debug_and_the_service_readies() {
let built = stage();
assert!(format!("{built:?}").starts_with("HttpsEnforcementStage"));
let layer = HttpsEnforcementStageLayer::new(stage());
assert!(format!("{layer:?}").starts_with("HttpsEnforcementStageLayer"));
let mut service = ::tower::ServiceBuilder::new()
.layer(layer)
.service(Inner::new());
let waker = std::task::Waker::noop();
let mut cx = Context::from_waker(waker);
assert_eq!(
::tower::Service::<Request<&'static str>>::poll_ready(&mut service, &mut cx),
Poll::Ready(Ok(()))
);
}
#[test]
fn a_path_only_request_takes_the_host_header() {
let layer = HttpsEnforcementStageLayer::new(stage());
let mut service = ::tower::ServiceBuilder::new()
.layer(layer)
.service(Inner::new());
let request = Request::builder()
.uri("/private")
.header(http::header::HOST, "host.example:8443")
.header("x-forwarded-proto", "https")
.body("body")
.expect("request");
let response = block_on(service.call(request)).expect("ready");
assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
assert_eq!(
response
.headers()
.get(http::header::LOCATION)
.expect("location"),
"https://host.example:8443/private"
);
}
#[derive(Clone)]
struct InnerYieldsOnce {
yielded: Arc<std::sync::atomic::AtomicBool>,
}
struct YieldOnce {
yielded: bool,
}
impl Future for YieldOnce {
type Output = ();
fn poll(mut self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
if self.yielded {
Poll::Ready(())
} else {
self.yielded = true;
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
impl ::tower::Service<Request<&'static str>> for InnerYieldsOnce {
type Response = Response<&'static str>;
type Error = Infallible;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Infallible>> + Send>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _request: Request<&'static str>) -> Self::Future {
let yielded = Arc::clone(&self.yielded);
Box::pin(async move {
YieldOnce {
yielded: yielded.load(std::sync::atomic::Ordering::Relaxed),
}
.await;
yielded.store(true, std::sync::atomic::Ordering::Relaxed);
Ok(Response::new("inner"))
})
}
}
#[test]
fn the_helper_block_on_spins_a_pending_future_once() {
struct PendingOnce {
polled: std::cell::Cell<bool>,
}
impl Future for PendingOnce {
type Output = u8;
fn poll(self: std::pin::Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<u8> {
if self.polled.get() {
Poll::Ready(7)
} else {
self.polled.set(true);
Poll::Pending
}
}
}
let future = PendingOnce {
polled: std::cell::Cell::new(false),
};
assert_eq!(block_on(future), 7, "the second poll answers");
let layer = HttpsEnforcementStageLayer::new(stage());
let mut service = ::tower::ServiceBuilder::new()
.layer(layer)
.service(InnerYieldsOnce {
yielded: Arc::new(std::sync::atomic::AtomicBool::new(false)),
});
let waker = std::task::Waker::noop();
let mut cx = Context::from_waker(waker);
assert_eq!(
::tower::Service::<Request<&'static str>>::poll_ready(&mut service, &mut cx),
Poll::Ready(Ok(()))
);
let response = block_on(service.call(request_to("https://host/private"))).expect("ready");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.body(), &"inner");
let response =
block_on(service.call(request_to("http://host/private?q=1"))).expect("ready");
assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
let request = Request::builder()
.uri("/private?q=1")
.header(http::header::HOST, "host.example:8443")
.header("x-forwarded-proto", "https")
.body("body")
.expect("request");
let response = block_on(service.call(request)).expect("ready");
assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
}
}