TOCTOU and lifecycle races across runtime boundaries fixes

This commit is contained in:
Alexey
2026-09-19 22:37:22 +03:00
parent acad414cc7
commit 89dacbd17e
20 changed files with 194 additions and 24 deletions
+3 -5
View File
@@ -1,5 +1,6 @@
use std::collections::HashSet;
use std::ffi::OsString;
#[cfg(all(test, unix))]
use std::fs::OpenOptions;
use std::io::Write;
use std::net::SocketAddr;
@@ -33,7 +34,7 @@ use nix::fcntl::{Flock, FlockArg, OFlag, openat};
#[cfg(unix)]
use nix::sys::stat::Mode;
#[cfg(unix)]
#[cfg(all(test, unix))]
use std::os::unix::fs::OpenOptionsExt;
// Direct relay lifecycle and conntrack publication.
@@ -178,10 +179,7 @@ fn open_unknown_dc_log_append_anchored(
) -> std::io::Result<std::fs::File> {
#[cfg(unix)]
{
let parent = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(&path.allowed_parent)?;
let parent = crate::util::secure_fs::open_dir_nofollow(&path.allowed_parent)?;
let oflags = OFlag::O_CREAT
| OFlag::O_APPEND
+15
View File
@@ -1,5 +1,6 @@
use super::*;
/// Records one deterministic authentication failure against an isolated shared state.
pub(crate) fn auth_probe_record_failure_for_testing(
shared: &ProxySharedState,
peer_ip: IpAddr,
@@ -8,6 +9,7 @@ pub(crate) fn auth_probe_record_failure_for_testing(
auth_probe_record_failure_in(shared, peer_ip, now);
}
/// Returns the normalized peer failure streak from an isolated shared state.
pub(crate) fn auth_probe_fail_streak_for_testing_in_shared(
shared: &ProxySharedState,
peer_ip: IpAddr,
@@ -20,6 +22,7 @@ pub(crate) fn auth_probe_fail_streak_for_testing_in_shared(
.map(|entry| entry.fail_streak)
}
/// Clears probe entries, exact capacity accounting, and saturation state together.
pub(crate) fn clear_auth_probe_state_for_testing_in_shared(shared: &ProxySharedState) {
let removed = shared.handshake.auth_probe.len();
assert_eq!(shared.handshake.auth_probe_slots.used(), removed);
@@ -37,6 +40,7 @@ pub(crate) fn clear_auth_probe_state_for_testing_in_shared(shared: &ProxySharedS
}
}
/// Inserts one fixture entry while preserving exact registry capacity accounting.
pub(crate) fn insert_auth_probe_state_for_testing_in_shared(
shared: &ProxySharedState,
peer_ip: IpAddr,
@@ -59,22 +63,26 @@ pub(crate) fn insert_auth_probe_state_for_testing_in_shared(
}
}
/// Exposes the isolated probe registry to adversarial tests.
pub(crate) fn auth_probe_state_for_testing_in_shared(
shared: &ProxySharedState,
) -> &DashMap<IpAddr, AuthProbeState> {
&shared.handshake.auth_probe
}
/// Returns exact committed probe slots for capacity assertions.
pub(crate) fn auth_probe_slots_for_testing_in_shared(shared: &ProxySharedState) -> usize {
shared.handshake.auth_probe_slots.used()
}
/// Exposes the isolated saturation state mutex to tests.
pub(crate) fn auth_probe_saturation_state_for_testing_in_shared(
shared: &ProxySharedState,
) -> &Mutex<Option<AuthProbeSaturationState>> {
&shared.handshake.auth_probe_saturation
}
/// Locks isolated saturation state while recovering poisoned test fixtures.
pub(crate) fn auth_probe_saturation_state_lock_for_testing_in_shared(
shared: &ProxySharedState,
) -> std::sync::MutexGuard<'_, Option<AuthProbeSaturationState>> {
@@ -85,6 +93,7 @@ pub(crate) fn auth_probe_saturation_state_lock_for_testing_in_shared(
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Resets the isolated unknown-SNI warning rate limiter.
pub(crate) fn clear_unknown_sni_warn_state_for_testing_in_shared(shared: &ProxySharedState) {
let mut guard = shared
.handshake
@@ -94,6 +103,7 @@ pub(crate) fn clear_unknown_sni_warn_state_for_testing_in_shared(shared: &ProxyS
*guard = None;
}
/// Evaluates unknown-SNI warning admission at a deterministic instant.
pub(crate) fn should_emit_unknown_sni_warn_for_testing_in_shared(
shared: &ProxySharedState,
now: Instant,
@@ -101,18 +111,21 @@ pub(crate) fn should_emit_unknown_sni_warn_for_testing_in_shared(
should_emit_unknown_sni_warn_in(shared, now)
}
/// Clears the isolated invalid-secret warning deduplication set.
pub(crate) fn clear_warned_secrets_for_testing_in_shared(shared: &ProxySharedState) {
if let Ok(mut guard) = shared.handshake.invalid_secret_warned.lock() {
guard.clear();
}
}
/// Exposes the isolated invalid-secret warning set to tests.
pub(crate) fn warned_secrets_for_testing_in_shared(
shared: &ProxySharedState,
) -> &Mutex<HashSet<(String, String)>> {
&shared.handshake.invalid_secret_warned
}
/// Evaluates peer throttling against the current test clock.
pub(crate) fn auth_probe_is_throttled_for_testing_in_shared(
shared: &ProxySharedState,
peer_ip: IpAddr,
@@ -120,12 +133,14 @@ pub(crate) fn auth_probe_is_throttled_for_testing_in_shared(
auth_probe_is_throttled_in(shared, peer_ip, Instant::now())
}
/// Evaluates global saturation throttling against the current test clock.
pub(crate) fn auth_probe_saturation_is_throttled_for_testing_in_shared(
shared: &ProxySharedState,
) -> bool {
auth_probe_saturation_is_throttled_in(shared, Instant::now())
}
/// Evaluates global saturation throttling at a deterministic instant.
pub(crate) fn auth_probe_saturation_is_throttled_at_for_testing_in_shared(
shared: &ProxySharedState,
now: Instant,
+8
View File
@@ -56,17 +56,25 @@ pub(crate) enum ConntrackClosePolicy {
pub(crate) struct HandshakeSharedState {
pub(crate) auth_probe: DashMap<IpAddr, AuthProbeState>,
/// Exact capacity authority for the authentication probe registry.
pub(crate) auth_probe_slots: SlotBudget,
pub(crate) auth_probe_saturation: Mutex<Option<AuthProbeSaturationState>>,
pub(crate) auth_probe_eviction_hasher: RandomState,
pub(crate) invalid_secret_warned: Mutex<HashSet<(String, String)>>,
pub(crate) unknown_sni_warn_next_allowed: Mutex<Option<Instant>>,
/// Stable credential hints keyed by exact peer IP.
pub(crate) sticky_user_by_ip: DashMap<IpAddr, u64>,
/// Exact capacity authority for peer-IP credential hints.
pub(crate) sticky_user_by_ip_slots: SlotBudget,
/// Stable credential hints keyed by bounded peer network prefix.
pub(crate) sticky_user_by_ip_prefix: DashMap<u64, u64>,
/// Exact capacity authority for peer-prefix credential hints.
pub(crate) sticky_user_by_ip_prefix_slots: SlotBudget,
/// Stable credential hints keyed by normalized SNI hash.
pub(crate) sticky_user_by_sni_hash: DashMap<u64, u64>,
/// Exact capacity authority for SNI credential hints.
pub(crate) sticky_user_by_sni_hash_slots: SlotBudget,
/// Bounded recent credential-hint ring used as an authentication fallback.
pub(crate) recent_user_ring: Box<[AtomicU64]>,
pub(crate) recent_user_ring_seq: AtomicU64,
pub(crate) auth_expensive_checks_total: AtomicU64,
@@ -72,6 +72,51 @@ fn adversarial_parent_swap_after_check_is_blocked_by_anchored_open() {
);
}
#[cfg(unix)]
#[test]
fn adversarial_intermediate_parent_swap_is_blocked_by_component_walk() {
use std::os::unix::fs::symlink;
let directory = tempfile::tempdir().expect("temporary directory must be creatable");
let parent = directory.path().join("parent");
let moved = directory.path().join("moved");
let outside = directory.path().join("outside");
fs::create_dir_all(parent.join("nested"))
.expect("original nested directory must be creatable");
fs::create_dir_all(outside.join("nested"))
.expect("outside nested directory must be creatable");
let candidate = parent.join("nested/unknown-dc.log");
let sanitized = sanitize_unknown_dc_log_path(
candidate
.to_str()
.expect("temporary path must be valid UTF-8"),
)
.expect("candidate must sanitize before intermediate parent swap");
assert!(
unknown_dc_log_path_is_still_safe(&sanitized),
"precondition: target should initially pass revalidation"
);
fs::rename(&parent, &moved).expect("intermediate parent must be movable");
symlink(&outside, &parent).expect("intermediate parent symlink must be creatable");
let err = open_unknown_dc_log_append_anchored(&sanitized)
.expect_err("anchored open must reject a swapped intermediate component");
let raw = err.raw_os_error();
assert!(
matches!(
raw,
Some(libc::ELOOP) | Some(libc::ENOTDIR) | Some(libc::ENOENT)
),
"component walk must fail closed on intermediate swap, got raw_os_error={raw:?}"
);
assert!(
!outside.join("nested/unknown-dc.log").exists(),
"component walk must not create a log through a swapped intermediate directory"
);
}
#[cfg(unix)]
#[test]
fn anchored_open_nix_path_writes_expected_lines() {
+2 -1
View File
@@ -1115,7 +1115,8 @@ async fn tls_unknown_sni_reject_handshake_policy_emits_unrecognized_name_alert()
// Drain what the server wrote. We expect exactly one TLS alert record:
// 0x15 0x03 0x03 0x00 0x02 0x02 0x70
// (ContentType.alert, TLS 1.2, length=2, fatal, unrecognized_name)
drop(result); // drops the server-side writer so peer_side sees EOF
// Drop the server-side writer so `peer_side` observes EOF.
drop(result);
let mut buf = Vec::new();
peer_side.read_to_end(&mut buf).await.unwrap();
assert_eq!(