From 98bb8ee99ec685f19091d6b6094ac7861872292b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 03:39:56 +0900 Subject: [PATCH 1/3] test(destination): require fresh redirect resolution authority --- .../tests/redirect_freshness.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 crates/originweave-destination/tests/redirect_freshness.rs diff --git a/crates/originweave-destination/tests/redirect_freshness.rs b/crates/originweave-destination/tests/redirect_freshness.rs new file mode 100644 index 00000000..63ace796 --- /dev/null +++ b/crates/originweave-destination/tests/redirect_freshness.rs @@ -0,0 +1,61 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; +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") +} + +#[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 resolution = 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"); + 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, + approved_at + validity, + &grants, + ), + Err(RedirectError::ResolutionFreshnessDenied { + error: DestinationError::ResolutionApprovalExpired { + valid_until: approved_at + validity, + current_time: approved_at + validity, + }, + }) + ); + assert_eq!(guard.current_origin(), &initial); + assert_eq!(guard.hop_count(), 0); +} From 5516055bd70e687d6d76dc8660e90f003d3c0432 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 03:45:49 +0900 Subject: [PATCH 2/3] fix(destination): require fresh resolution on redirects --- .../originweave-destination/src/redirect.rs | 50 ++++++++++- .../tests/redirect_freshness.rs | 79 ++++++++++++++--- .../tests/redirect_policy.rs | 84 +++++++++++++++---- 3 files changed, 181 insertions(+), 32 deletions(-) diff --git a/crates/originweave-destination/src/redirect.rs b/crates/originweave-destination/src/redirect.rs index 0a9a503a..a20b878b 100644 --- a/crates/originweave-destination/src/redirect.rs +++ b/crates/originweave-destination/src/redirect.rs @@ -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; @@ -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. @@ -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, @@ -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)] @@ -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, ) -> Result { if self.hop_count >= self.maximum_hops { @@ -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(), @@ -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://") } diff --git a/crates/originweave-destination/tests/redirect_freshness.rs b/crates/originweave-destination/tests/redirect_freshness.rs index 63ace796..dead27ab 100644 --- a/crates/originweave-destination/tests/redirect_freshness.rs +++ b/crates/originweave-destination/tests/redirect_freshness.rs @@ -1,6 +1,7 @@ #![allow(clippy::expect_used)] use std::collections::BTreeSet; +use std::error::Error; use std::net::{IpAddr, Ipv4Addr}; use std::time::Duration; @@ -23,20 +24,74 @@ fn digest(value: &str) -> RedirectTargetDigest { RedirectTargetDigest::parse(value).expect("test digest must parse") } -#[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 resolution = FreshResolutionSnapshot::approve( +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"); + .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"); @@ -46,13 +101,13 @@ fn redirect_rejects_expired_resolution_authority_without_advancing_chain() { target, digest(TARGET_DIGEST), &resolution, - approved_at + validity, + current_time, &grants, ), Err(RedirectError::ResolutionFreshnessDenied { - error: DestinationError::ResolutionApprovalExpired { - valid_until: approved_at + validity, - current_time: approved_at + validity, + error: DestinationError::ResolutionUseBeforeApproval { + approved_at, + current_time, }, }) ); diff --git a/crates/originweave-destination/tests/redirect_policy.rs b/crates/originweave-destination/tests/redirect_policy.rs index 91d4f934..28c7842c 100644 --- a/crates/originweave-destination/tests/redirect_policy.rs +++ b/crates/originweave-destination/tests/redirect_policy.rs @@ -2,11 +2,12 @@ use std::collections::BTreeSet; use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; use originweave_core::Origin; use originweave_destination::{ - AddressClass, DestinationPolicy, MAX_REDIRECT_HOPS, RedirectError, RedirectGuard, - RedirectTargetDigest, RedirectTargetDigestError, ResolutionSnapshot, + AddressClass, DestinationPolicy, FreshResolutionSnapshot, MAX_REDIRECT_HOPS, RedirectError, + RedirectGuard, RedirectTargetDigest, RedirectTargetDigestError, }; const DIGEST_A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -14,6 +15,9 @@ const DIGEST_B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb const DIGEST_C: &str = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; const DIGEST_NUMERIC: &str = "sha256:0123456789012345678901234567890123456789012345678901234567890123"; +const APPROVED_AT: Duration = Duration::from_secs(10); +const CURRENT_TIME: Duration = Duration::from_secs(11); +const VALIDITY: Duration = Duration::from_secs(2); fn origin(value: &str) -> Origin { Origin::parse(value).expect("test origin must parse") @@ -23,13 +27,15 @@ fn digest(value: &str) -> RedirectTargetDigest { RedirectTargetDigest::parse(value).expect("test digest must parse") } -fn public_resolution(target: &Origin, address: [u8; 4]) -> ResolutionSnapshot { - ResolutionSnapshot::approve( +fn public_resolution(target: &Origin, address: [u8; 4]) -> FreshResolutionSnapshot { + FreshResolutionSnapshot::approve( target.clone(), [IpAddr::V4(Ipv4Addr::from(address))], &DestinationPolicy::public_web(), + APPROVED_AT, + VALIDITY, ) - .expect("public resolution") + .expect("fresh public resolution") } #[test] @@ -85,7 +91,13 @@ fn every_redirect_hop_reauthorizes_origin_resolution_and_evidence() { RedirectGuard::new(initial.clone(), digest(DIGEST_A), 3).expect("redirect guard"); let first = guard - .authorize_redirect(target.clone(), digest(DIGEST_B), &resolution, &grants) + .authorize_redirect( + target.clone(), + digest(DIGEST_B), + &resolution, + CURRENT_TIME, + &grants, + ) .expect("authorized cross-origin redirect"); assert_eq!(first.hop_number(), 1); assert_eq!(first.source_origin(), &initial); @@ -96,7 +108,13 @@ fn every_redirect_hop_reauthorizes_origin_resolution_and_evidence() { assert_eq!(guard.hop_count(), 1); let second = guard - .authorize_redirect(target.clone(), digest(DIGEST_C), &resolution, &grants) + .authorize_redirect( + target.clone(), + digest(DIGEST_C), + &resolution, + CURRENT_TIME, + &grants, + ) .expect("same-origin redirect still has a distinct target digest"); assert_eq!(second.hop_number(), 2); assert_eq!(second.source_origin(), &target); @@ -119,6 +137,7 @@ fn redirect_guard_fails_closed_for_missing_authority_and_mismatched_resolution() target.clone(), digest(DIGEST_B), &target_resolution, + CURRENT_TIME, &BTreeSet::new(), ), Err(RedirectError::OriginNotGranted { @@ -132,6 +151,7 @@ fn redirect_guard_fails_closed_for_missing_authority_and_mismatched_resolution() target.clone(), digest(DIGEST_B), &other_resolution, + CURRENT_TIME, &BTreeSet::from([target.clone()]), ), Err(RedirectError::ResolutionOriginMismatch { @@ -146,12 +166,14 @@ fn redirect_guard_rejects_https_downgrade_cycles_and_excess_hops() { let secure = origin("https://secure.example"); let loopback = origin("http://localhost"); let loopback_policy = DestinationPolicy::from_allowed_classes([AddressClass::Loopback]); - let loopback_resolution = ResolutionSnapshot::approve( + let loopback_resolution = FreshResolutionSnapshot::approve( loopback.clone(), [IpAddr::V4(Ipv4Addr::LOCALHOST)], &loopback_policy, + APPROVED_AT, + VALIDITY, ) - .expect("managed loopback resolution"); + .expect("managed fresh loopback resolution"); let mut downgrade = RedirectGuard::new(secure.clone(), digest(DIGEST_A), 2).expect("redirect guard"); assert_eq!( @@ -159,6 +181,7 @@ fn redirect_guard_rejects_https_downgrade_cycles_and_excess_hops() { loopback.clone(), digest(DIGEST_B), &loopback_resolution, + CURRENT_TIME, &BTreeSet::from([loopback.clone()]), ), Err(RedirectError::InsecureSchemeDowngrade { @@ -173,7 +196,13 @@ fn redirect_guard_rejects_https_downgrade_cycles_and_excess_hops() { let mut cycle = RedirectGuard::new(origin("https://start.example"), digest(DIGEST_A), 2) .expect("redirect guard"); assert_eq!( - cycle.authorize_redirect(target.clone(), digest(DIGEST_A), &resolution, &grants), + cycle.authorize_redirect( + target.clone(), + digest(DIGEST_A), + &resolution, + CURRENT_TIME, + &grants, + ), Err(RedirectError::RedirectCycle { target_digest: digest(DIGEST_A), }) @@ -182,10 +211,22 @@ fn redirect_guard_rejects_https_downgrade_cycles_and_excess_hops() { let mut limited = RedirectGuard::new(origin("https://start.example"), digest(DIGEST_A), 1) .expect("redirect guard"); limited - .authorize_redirect(target.clone(), digest(DIGEST_B), &resolution, &grants) + .authorize_redirect( + target.clone(), + digest(DIGEST_B), + &resolution, + CURRENT_TIME, + &grants, + ) .expect("first and only redirect"); assert_eq!( - limited.authorize_redirect(target, digest(DIGEST_C), &resolution, &grants), + limited.authorize_redirect( + target, + digest(DIGEST_C), + &resolution, + CURRENT_TIME, + &grants, + ), Err(RedirectError::RedirectLimitExceeded) ); } @@ -195,14 +236,25 @@ fn explicitly_managed_http_loopback_redirects_do_not_trigger_downgrade_logic() { let initial = origin("http://localhost"); let target = origin("http://localhost:8080"); let policy = DestinationPolicy::from_allowed_classes([AddressClass::Loopback]); - let resolution = - ResolutionSnapshot::approve(target.clone(), [IpAddr::V4(Ipv4Addr::LOCALHOST)], &policy) - .expect("loopback resolution"); + let resolution = FreshResolutionSnapshot::approve( + target.clone(), + [IpAddr::V4(Ipv4Addr::LOCALHOST)], + &policy, + APPROVED_AT, + VALIDITY, + ) + .expect("fresh loopback resolution"); let grants = BTreeSet::from([target.clone()]); let mut guard = RedirectGuard::new(initial, digest(DIGEST_A), 1).expect("redirect guard"); let evidence = guard - .authorize_redirect(target.clone(), digest(DIGEST_B), &resolution, &grants) + .authorize_redirect( + target.clone(), + digest(DIGEST_B), + &resolution, + CURRENT_TIME, + &grants, + ) .expect("explicit loopback redirect"); assert_eq!(evidence.target_origin(), &target); } From b796564d059f7bcbd8177617b6fd46c6edc7dda1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 03:47:38 +0900 Subject: [PATCH 3/3] style(destination): apply canonical redirect formatting --- crates/originweave-destination/tests/redirect_policy.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/crates/originweave-destination/tests/redirect_policy.rs b/crates/originweave-destination/tests/redirect_policy.rs index 28c7842c..0a69cd6e 100644 --- a/crates/originweave-destination/tests/redirect_policy.rs +++ b/crates/originweave-destination/tests/redirect_policy.rs @@ -220,13 +220,7 @@ fn redirect_guard_rejects_https_downgrade_cycles_and_excess_hops() { ) .expect("first and only redirect"); assert_eq!( - limited.authorize_redirect( - target, - digest(DIGEST_C), - &resolution, - CURRENT_TIME, - &grants, - ), + limited.authorize_redirect(target, digest(DIGEST_C), &resolution, CURRENT_TIME, &grants,), Err(RedirectError::RedirectLimitExceeded) ); }