use std::fmt;
use std::future::Future;
use std::net::IpAddr;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use ::tower::Layer;
use http::{Extensions, HeaderMap, Request, Response, StatusCode};
use crate::tower::{ExtractIp, IpGateDecision, StageResponse, default_extract_ip};
pub use guard_core_engine::cloud_provider::{
CloudConfigError, CloudIpTable, CloudRangeError, CloudSelectors, VALID_CLOUD_PROVIDERS,
parse_cloud_selectors,
};
pub const CLOUD_PROVIDER_BLOCKED_BODY: &str = "Cloud provider IP not allowed";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloudDecision {
pub answer: StageResponse,
pub details: Option<(String, String)>,
}
#[derive(Debug, Clone, Default)]
pub struct CloudProviderStageConfig {
pub block_cloud_providers: CloudSelectors,
pub table: CloudIpTable,
}
#[derive(Clone)]
pub struct CloudProviderStage {
config: CloudProviderStageConfig,
extract_ip: ExtractIp,
}
impl fmt::Debug for CloudProviderStage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CloudProviderStage")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl CloudProviderStage {
#[must_use]
pub fn new(config: CloudProviderStageConfig) -> Self {
Self {
config,
extract_ip: Arc::new(default_extract_ip),
}
}
pub const fn builder(config: CloudProviderStageConfig) -> CloudProviderStageBuilder {
CloudProviderStageBuilder {
config,
extract_ip: None,
}
}
#[must_use]
pub const fn config(&self) -> &CloudProviderStageConfig {
&self.config
}
#[must_use]
pub fn decide(
&self,
ip: Option<IpAddr>,
gate: Option<IpGateDecision>,
) -> Option<CloudDecision> {
if gate.is_some_and(|gate| gate.is_whitelisted || gate.is_exempt) {
return None;
}
let ip = ip?;
if self.config.block_cloud_providers.blocked.is_empty() {
return None;
}
if !self
.config
.table
.is_cloud_ip(ip, &self.config.block_cloud_providers)
{
return None;
}
let details = self
.config
.table
.provider_details(ip, &self.config.block_cloud_providers);
Some(CloudDecision {
answer: StageResponse {
status: StatusCode::FORBIDDEN,
body: CLOUD_PROVIDER_BLOCKED_BODY,
retry_after: None,
},
details,
})
}
}
#[must_use]
pub struct CloudProviderStageBuilder {
config: CloudProviderStageConfig,
extract_ip: Option<ExtractIp>,
}
impl CloudProviderStageBuilder {
pub fn ip_extractor<F>(mut self, extractor: F) -> Self
where
F: Fn(&HeaderMap, &Extensions) -> Option<IpAddr> + Send + Sync + 'static,
{
self.extract_ip = Some(Arc::new(extractor));
self
}
#[must_use]
pub fn build(self) -> CloudProviderStage {
let mut stage = CloudProviderStage::new(self.config);
if let Some(extract_ip) = self.extract_ip {
stage.extract_ip = extract_ip;
}
stage
}
}
#[derive(Clone)]
pub struct CloudProviderStageLayer {
stage: CloudProviderStage,
}
impl CloudProviderStageLayer {
#[must_use]
pub const fn new(stage: CloudProviderStage) -> Self {
Self { stage }
}
}
impl fmt::Debug for CloudProviderStageLayer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CloudProviderStageLayer")
.field("stage", &self.stage)
.finish()
}
}
impl<S> Layer<S> for CloudProviderStageLayer {
type Service = CloudProviderStageService<S>;
fn layer(&self, inner: S) -> Self::Service {
CloudProviderStageService {
inner,
stage: self.stage.clone(),
}
}
}
#[derive(Clone)]
pub struct CloudProviderStageService<S> {
inner: S,
stage: CloudProviderStage,
}
impl<S, B, ResBody> ::tower::Service<Request<B>> for CloudProviderStageService<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 ip = (self.stage.extract_ip)(request.headers(), request.extensions());
let gate = request.extensions().get::<IpGateDecision>().copied();
if let Some(decision) = self.stage.decide(ip, gate) {
let response = render(decision.answer);
return Box::pin(async move { Ok(response) });
}
let future = self.inner.call(request);
Box::pin(future)
}
}
fn render<ResBody: From<&'static str>>(answer: StageResponse) -> Response<ResBody> {
let mut response = Response::new(ResBody::from(answer.body));
*response.status_mut() = answer.status;
response
}
#[cfg(test)]
mod tests {
use super::*;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::str::FromStr;
use std::sync::atomic::{AtomicUsize, Ordering};
use ::tower::{Service, ServiceBuilder};
fn aws_stage() -> CloudProviderStage {
let table = CloudIpTable::default();
table
.set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
.expect("valid ranges");
CloudProviderStage::new(CloudProviderStageConfig {
block_cloud_providers: parse_cloud_selectors(["AWS"]).expect("valid selectors"),
table,
})
}
fn ip(text: &str) -> IpAddr {
IpAddr::from_str(text).expect("test address")
}
#[test]
fn default_stage_passes_everything_through() {
let stage = CloudProviderStage::new(CloudProviderStageConfig::default());
assert!(stage.config().block_cloud_providers.blocked.is_empty());
for address in ["203.0.113.9", "192.0.2.1", "2001:db8::1"] {
assert!(
stage.decide(Some(ip(address)), None).is_none(),
"an empty selector list must pass {address}"
);
}
}
#[test]
fn blocked_provider_answers_the_reference_403_shape_with_details() {
let stage = aws_stage();
let decision = stage
.decide(Some(ip("203.0.113.9")), None)
.expect("blocked");
assert_eq!(decision.answer.status, StatusCode::FORBIDDEN);
assert_eq!(decision.answer.body, CLOUD_PROVIDER_BLOCKED_BODY);
assert_eq!(decision.answer.retry_after, None);
assert_eq!(
decision.details,
Some(("AWS".to_owned(), "203.0.113.0/24".to_owned()))
);
assert!(stage.decide(Some(ip("203.0.114.9")), None).is_none());
}
#[test]
fn missing_client_ip_passes() {
let stage = aws_stage();
assert!(stage.decide(None, None).is_none());
}
#[test]
fn whitelisted_and_exempt_ips_skip_the_check() {
let stage = aws_stage();
for gate in [
IpGateDecision {
is_whitelisted: true,
is_exempt: false,
},
IpGateDecision {
is_whitelisted: false,
is_exempt: true,
},
] {
assert!(
stage.decide(Some(ip("203.0.113.9")), Some(gate)).is_none(),
"the skip state must pass a cloud-provider address"
);
}
}
#[test]
fn carve_outs_honor_the_region_data_through_the_stage() {
let table = CloudIpTable::default();
table
.set_provider_ranges(
"GCP",
vec![
("198.51.100.0/24".to_owned(), Some("us-central1".to_owned())),
(
"198.51.101.0/24".to_owned(),
Some("europe-west1".to_owned()),
),
],
)
.expect("valid ranges");
let stage = CloudProviderStage::new(CloudProviderStageConfig {
block_cloud_providers: parse_cloud_selectors(["GCP:!us-central1"])
.expect("valid selectors"),
table,
});
assert!(stage.decide(Some(ip("198.51.100.9")), None).is_none());
assert!(stage.decide(Some(ip("198.51.101.9")), None).is_some());
}
#[test]
fn builder_swaps_the_ip_extractor() {
let stage = CloudProviderStage::builder(CloudProviderStageConfig::default())
.ip_extractor(|_headers, _extensions| Some(ip("192.0.2.99")))
.build();
let request = Request::builder().body(()).expect("request");
assert_eq!(
(stage.extract_ip)(request.headers(), request.extensions()),
Some(ip("192.0.2.99"))
);
}
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<AtomicUsize>,
}
impl Inner {
fn new() -> Self {
Self {
calls: Arc::new(AtomicUsize::new(0)),
}
}
fn call_count(&self) -> usize {
self.calls.load(Ordering::Relaxed)
}
}
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, Ordering::Relaxed);
drop(request);
Box::pin(async move { Ok(Response::new("inner")) })
}
}
fn request_from_client(ip_text: &str) -> Request<&'static str> {
let socket = SocketAddr::from_str(&format!("{ip_text}:65535")).expect("socket");
Request::builder()
.extension(socket)
.body("body")
.expect("request")
}
#[test]
fn layer_passes_through_and_the_inner_service_answers() {
let layer = CloudProviderStageLayer::new(aws_stage());
let inner = Inner::new();
let mut service = ServiceBuilder::new().layer(layer).service(inner.clone());
let response = block_on(service.call(request_from_client("192.0.2.20"))).expect("ready");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.body(), &"inner");
assert_eq!(inner.call_count(), 1);
}
#[test]
fn layer_answers_the_blocked_shape_without_hitting_the_inner_service() {
let layer = CloudProviderStageLayer::new(aws_stage());
let inner = Inner::new();
let mut service = ServiceBuilder::new().layer(layer).service(inner.clone());
let response = block_on(service.call(request_from_client("203.0.113.9"))).expect("ready");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(response.body(), &CLOUD_PROVIDER_BLOCKED_BODY);
assert_eq!(inner.call_count(), 0, "the blocked request never hits it");
}
#[test]
fn layer_clone_shares_the_stage_table() {
let stage = aws_stage();
let mut first = CloudProviderStageService {
inner: Inner::new(),
stage: stage.clone(),
};
let mut second = CloudProviderStageService {
inner: Inner::new(),
stage,
};
let response = block_on(first.call(request_from_client("203.0.113.9"))).expect("ready");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let response = block_on(second.call(request_from_client("203.0.113.9"))).expect("ready");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
}