Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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