Skip to content
Draft
50 changes: 46 additions & 4 deletions crates/originweave-destination/src/redirect.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use std::collections::BTreeSet;
use std::fmt;
use std::time::Duration;

use originweave_core::Origin;

use crate::ResolutionSnapshot;
use crate::{DestinationError, FreshResolutionSnapshot};

/// The largest redirect chain accepted by the destination kernel.
pub const MAX_REDIRECT_HOPS: u8 = 20;
Expand Down Expand Up @@ -80,6 +81,11 @@ pub enum RedirectError {
/// The origin bound to the resolution snapshot.
resolution_origin: Origin,
},
/// The supplied resolution authority is not fresh at the requested use time.
ResolutionFreshnessDenied {
/// The destination-layer freshness failure.
error: DestinationError,
},
/// An HTTPS request attempted to redirect to HTTP.
InsecureSchemeDowngrade {
/// The secure source origin.
Expand Down Expand Up @@ -112,6 +118,9 @@ impl fmt::Display for RedirectError {
formatter,
"redirect resolution origin {resolution_origin} does not match target {target_origin}",
),
Self::ResolutionFreshnessDenied { error } => {
write!(formatter, "redirect resolution freshness denied: {error}")
}
Self::InsecureSchemeDowngrade {
source_origin,
target_origin,
Expand All @@ -128,7 +137,14 @@ impl fmt::Display for RedirectError {
}
}

impl std::error::Error for RedirectError {}
impl std::error::Error for RedirectError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::ResolutionFreshnessDenied { error } => Some(error),
_ => None,
}
}
}

/// Stateful redirect authorization for one bounded navigation chain.
#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -175,12 +191,17 @@ impl RedirectGuard {
self.maximum_hops
}

/// Authorize the next redirect after origin and DNS policy evaluation.
/// Authorize the next redirect after origin, DNS policy, and freshness evaluation.
///
/// `current_time` must come from the same trusted monotonic clock domain as
/// `target_resolution`. Freshness is checked before any redirect-chain state
/// is advanced.
pub fn authorize_redirect(
&mut self,
target_origin: Origin,
target_digest: RedirectTargetDigest,
target_resolution: &ResolutionSnapshot,
target_resolution: &FreshResolutionSnapshot,
current_time: Duration,
readable_origins: &BTreeSet<Origin>,
) -> Result<RedirectEvidence, RedirectError> {
if self.hop_count >= self.maximum_hops {
Expand All @@ -197,6 +218,8 @@ impl RedirectGuard {
resolution_origin: target_resolution.origin().clone(),
});
}
validate_resolution_freshness(target_resolution, current_time)
.map_err(|error| RedirectError::ResolutionFreshnessDenied { error })?;
if is_https(&self.current_origin) && !is_https(&target_origin) {
return Err(RedirectError::InsecureSchemeDowngrade {
source_origin: self.current_origin.clone(),
Expand All @@ -221,6 +244,25 @@ impl RedirectGuard {
}
}

fn validate_resolution_freshness(
target_resolution: &FreshResolutionSnapshot,
current_time: Duration,
) -> Result<(), DestinationError> {
if current_time < target_resolution.approved_at() {
return Err(DestinationError::ResolutionUseBeforeApproval {
approved_at: target_resolution.approved_at(),
current_time,
});
}
if current_time >= target_resolution.valid_until() {
return Err(DestinationError::ResolutionApprovalExpired {
valid_until: target_resolution.valid_until(),
current_time,
});
}
Ok(())
}

fn is_https(origin: &Origin) -> bool {
origin.as_str().starts_with("https://")
}
Expand Down
116 changes: 116 additions & 0 deletions crates/originweave-destination/tests/redirect_freshness.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#![allow(clippy::expect_used)]

use std::collections::BTreeSet;
use std::error::Error;
use std::net::{IpAddr, Ipv4Addr};
use std::time::Duration;

use originweave_core::Origin;
use originweave_destination::{
DestinationError, DestinationPolicy, FreshResolutionSnapshot, RedirectError, RedirectGuard,
RedirectTargetDigest,
};

const INITIAL_DIGEST: &str =
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const TARGET_DIGEST: &str =
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";

fn origin(value: &str) -> Origin {
Origin::parse(value).expect("test origin must parse")
}

fn digest(value: &str) -> RedirectTargetDigest {
RedirectTargetDigest::parse(value).expect("test digest must parse")
}

fn fresh_resolution(
target: &Origin,
approved_at: Duration,
validity: Duration,
) -> FreshResolutionSnapshot {
FreshResolutionSnapshot::approve(
target.clone(),
[IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))],
&DestinationPolicy::public_web(),
approved_at,
validity,
)
.expect("fresh public resolution must be approved")
}

#[test]
fn redirect_rejects_expired_resolution_authority_without_advancing_chain() {
let initial = origin("https://start.example");
let target = origin("https://target.example");
let approved_at = Duration::from_secs(10);
let validity = Duration::from_secs(2);
let current_time = approved_at + validity;
let resolution = fresh_resolution(&target, approved_at, validity);
let grants = BTreeSet::from([target.clone()]);
let mut guard = RedirectGuard::new(initial.clone(), digest(INITIAL_DIGEST), 2)
.expect("redirect guard must be valid");

let error = guard
.authorize_redirect(
target,
digest(TARGET_DIGEST),
&resolution,
current_time,
&grants,
)
.expect_err("exclusive freshness deadline must reject redirect");
assert_eq!(
error,
RedirectError::ResolutionFreshnessDenied {
error: DestinationError::ResolutionApprovalExpired {
valid_until: current_time,
current_time,
},
}
);
assert_eq!(
error.to_string(),
"redirect resolution freshness denied: resolution approval expired at 12s; current time is 12s"
);
let standard: &dyn Error = &error;
assert_eq!(
standard
.source()
.expect("freshness wrapper must preserve source")
.to_string(),
"resolution approval expired at 12s; current time is 12s"
);
assert_eq!(guard.current_origin(), &initial);
assert_eq!(guard.hop_count(), 0);
}

#[test]
fn redirect_rejects_resolution_use_before_approval_without_advancing_chain() {
let initial = origin("https://start.example");
let target = origin("https://target.example");
let approved_at = Duration::from_secs(10);
let current_time = Duration::from_secs(9);
let resolution = fresh_resolution(&target, approved_at, Duration::from_secs(2));
let grants = BTreeSet::from([target.clone()]);
let mut guard = RedirectGuard::new(initial.clone(), digest(INITIAL_DIGEST), 2)
.expect("redirect guard must be valid");

assert_eq!(
guard.authorize_redirect(
target,
digest(TARGET_DIGEST),
&resolution,
current_time,
&grants,
),
Err(RedirectError::ResolutionFreshnessDenied {
error: DestinationError::ResolutionUseBeforeApproval {
approved_at,
current_time,
},
})
);
assert_eq!(guard.current_origin(), &initial);
assert_eq!(guard.hop_count(), 0);
}
Loading
Loading