//! One-time(ish) access tokens for unattended answer files (v0.7.0). //! //! Why: answer files routinely embed credentials (local admin passwords, //! domain-join accounts, root hashes). Serving them to anyone who can //! GET `/unattended/` is exactly the exposure that got WDS //! hands-free deployment disabled upstream (CVE-2026-0386 hardening //! guidance). OpenPXE generates every answer-file URL it injects into a //! boot chain, so it can scope each URL to the boot that requested it: //! when a boot script is rendered, a short-lived token is minted and //! appended; the serving endpoint requires it (or a logged-in operator //! session, so browser testing keeps working). //! //! Deliberately multi-use within the TTL rather than strictly one-shot: //! real installers fetch the same file more than once (initramfs + //! installer stage, cloud-init retries), and the token's job is to stop //! *unrelated* hosts from harvesting credentials, not to count fetches. //! //! In-memory only. A server restart invalidates outstanding tokens — //! acceptable because a restart also interrupts the ISO streaming an //! in-flight install depends on, and the next boot mints fresh ones. use parking_lot::Mutex; use std::collections::HashMap; use std::time::{Duration, Instant}; use uuid::Uuid; /// Long enough to cover a slow OS install end-to-end (the answer file is /// fetched early, but cloud-init can re-read late), short enough that a /// leaked URL goes stale the same afternoon. const TOKEN_TTL: Duration = Duration::from_hours(4); /// Hard cap on outstanding tokens; past it the oldest is evicted. Tokens /// are minted once per boot-script render, so this only matters under /// abuse, and serving must never become a memory-growth vector. const MAX_TOKENS: usize = 4096; #[derive(Debug, Clone)] struct Grant { file_id: String, issued: Instant, } /// In-memory token table. Cheap to clone (`Arc`-shared). #[derive(Debug, Clone, Default)] pub struct BootTokens { inner: std::sync::Arc>>, } impl BootTokens { #[must_use] pub fn new() -> Self { Self::default() } /// Mint a token granting access to unattended file `file_id` for the /// next [`TOKEN_TTL`]. Returns the opaque token value to embed in the /// generated URL. #[must_use] pub fn mint(&self, file_id: &str) -> String { self.mint_at(file_id, Instant::now()) } /// Is `token` a live grant for `file_id`? #[must_use] pub fn check(&self, token: &str, file_id: &str) -> bool { self.check_at(token, file_id, Instant::now()) } fn mint_at(&self, file_id: &str, now: Instant) -> String { let token = Uuid::new_v4().simple().to_string(); let mut g = self.inner.lock(); g.retain(|_, gr| now.duration_since(gr.issued) < TOKEN_TTL); if g.len() >= MAX_TOKENS { if let Some(oldest) = g .iter() .min_by_key(|(_, gr)| gr.issued) .map(|(k, _)| k.clone()) { g.remove(&oldest); } } g.insert( token.clone(), Grant { file_id: file_id.to_string(), issued: now, }, ); token } fn check_at(&self, token: &str, file_id: &str, now: Instant) -> bool { let g = self.inner.lock(); g.get(token) .is_some_and(|gr| gr.file_id == file_id && now.duration_since(gr.issued) < TOKEN_TTL) } } #[cfg(test)] mod tests { use super::*; #[test] fn mint_then_check_round_trip() { let t = BootTokens::new(); let tok = t.mint("ks-1"); assert!(t.check(&tok, "ks-1")); // Multi-use within TTL: a second fetch still passes. assert!(t.check(&tok, "ks-1")); // Wrong file id never passes, even with a live token. assert!(!t.check(&tok, "ks-2")); // Unknown token never passes. assert!(!t.check("nope", "ks-1")); } #[test] fn token_expires_after_ttl() { let t = BootTokens::new(); let now = Instant::now(); let tok = t.mint_at("ks-1", now); let just_before = TOKEN_TTL.checked_sub(Duration::from_secs(1)).unwrap(); assert!(t.check_at(&tok, "ks-1", now + just_before)); assert!(!t.check_at(&tok, "ks-1", now + TOKEN_TTL + Duration::from_secs(1))); } #[test] fn table_is_capped() { let t = BootTokens::new(); let now = Instant::now(); let first = t.mint_at("f", now); for i in 0..MAX_TOKENS { let _ = t.mint_at(&format!("f{i}"), now + Duration::from_secs(1)); } // The oldest grant was evicted to stay within the cap. assert!(!t.check_at(&first, "f", now + Duration::from_secs(2))); assert!(t.inner.lock().len() <= MAX_TOKENS); } }