Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-server/src/security.rs

//! Response headers, the content-security policy, and the cross-site guard.
//!
//! Ported from garmr, which is where the interesting decisions were already
//! argued out. Two of them are worth restating because they look like mistakes:
//!
//! * **`style-src` keeps `'unsafe-inline'`, permanently.** Moving the console's
//!   CSS into its own file removes the inline `<style>` element but not the
//!   need: the directive also governs the `style=` attributes Leptos writes, and
//!   neither a nonce nor a hash can cover an attribute. A style injection cannot
//!   execute script, so this is the cheap half of the tradeoff. The expensive
//!   half, `script-src` with no `'unsafe-inline'`, is kept — see
//!   [`inline_script_hashes`].
//! * **There is no CORS anywhere.** The console is same-origin by construction:
//!   `connect-src 'self'` is the browser-side half, and the absence of a CORS
//!   layer is the server-side half. There is no other place the page may call:
//!   an encrypted file opens in the tab with the reader's security key
//!   (WebAuthn PRF, no network) since the local unlock helper was removed
//!   (CRYPT-YUBI, 2026-10-26).

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;

use axum::extract::Request;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode};

/// Decorate every response, including 401s and static files.
///
/// `hsts` is the one thing here that depends on the deployment; see
/// [`apply_hsts`] for why it is not simply always on.
pub async fn security_headers_layer(hsts: bool, request: Request, next: Next) -> Response {
    let path = request.uri().path().to_owned();
    let mut response = next.run(request).await;
    let headers = response.headers_mut();
    apply_security_headers(headers);
    apply_cache_policy(&path, headers);
    if hsts {
        apply_hsts(headers);
    }
    response
}

/// **Nothing that depends on who asked may be held by anything in between.**
///
/// Not one response carried a `Cache-Control` or a `Vary` before this. Every
/// answer under `/api/` is shaped by the session that asked — the repository
/// catalogue, a file's contents, a namespace's usage — and an intermediary that
/// held one had no way to know that, because nothing told it. `Vary: Cookie`
/// alone is not enough either: a browser's own back/forward cache and any proxy
/// that ignores `Vary` on a 200 would still re-serve it. So the pair, and the
/// strong half of the pair: `no-store` means "do not write this down", which is
/// the instruction that is actually wanted for somebody's private source code.
///
/// **Only `/api/`, `/auth/` and `/login`.** The console's own bundle is the same
/// bytes for everybody and is worth caching — it is 1.4 MB of wasm and the
/// argument for compressing it (`serve::console_files`) is the same argument
/// for letting a browser keep it.
pub fn apply_cache_policy(path: &str, headers: &mut HeaderMap) {
    let identity_bearing = path.starts_with("/api/")
        || path.starts_with("/auth/")
        || path == "/login"
        || path.starts_with("/login/")
        // **`/signup` and `/welcome`.** Neither carries a session — that is the
        // whole situation of the person reading them — but one is where a name
        // is typed and the other is where a payment's outcome is shown, and a
        // proxy or a back button re-serving a held copy would put one visitor's
        // signup in front of the next person at that machine.
        || path == "/signup"
        || path == "/welcome";
    if !identity_bearing {
        return;
    }
    headers.insert(
        http::header::CACHE_CONTROL,
        HeaderValue::from_static("no-store"),
    );
    headers.insert(http::header::VARY, HeaderValue::from_static("cookie"));
}

/// **`Strict-Transport-Security`, and only where it is true.**
///
/// A year, with subdomains, and no `preload`: preload is a submission to a list
/// browsers ship, it is slow to undo, and it is a decision about a domain rather
/// than about this process — so it is the operator's to make at the proxy, not
/// this console's to make on their behalf.
///
/// Sent only when the passkey origin is `https://`, which is the console's own
/// statement about how it is reached. On a `trunk serve` box, or an appliance
/// reached over plain http on a LAN, the header is at best ignored and at worst
/// locks a browser out of a console that has no TLS to offer it.
pub fn apply_hsts(headers: &mut HeaderMap) {
    headers.insert(
        http::header::STRICT_TRANSPORT_SECURITY,
        HeaderValue::from_static("max-age=31536000; includeSubDomains"),
    );
}

/// The headers that do not depend on the deployment.
pub fn apply_security_headers(headers: &mut HeaderMap) {
    const HEADERS: &[(&str, &str)] = &[
        ("x-content-type-options", "nosniff"),
        ("x-frame-options", "DENY"),
        ("referrer-policy", "no-referrer"),
        ("cross-origin-opener-policy", "same-origin"),
        ("cross-origin-resource-policy", "same-origin"),
        // Deliberately does NOT restrict publickey-credentials-get/create: that
        // would disable the passkey ceremony this console logs in with.
        (
            "permissions-policy",
            "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
        ),
    ];
    for (name, value) in HEADERS {
        headers.insert(
            HeaderName::from_static(name),
            HeaderValue::from_static(value),
        );
    }
}

/// Add the startup-built policy to every response that did not bring its own.
///
/// The console's policy is for the console's documents. A handler that sets
/// one is serving something else and knows what: `api::browse::raw` hands a
/// browser a repository's file, where the console's `object-src 'none'` could
/// stop a PDF viewer and a picture wants `sandbox` instead. Each of those is
/// chosen for the one kind of file it goes on, and `raw`'s tests pin which.
pub async fn csp_layer(csp: Arc<String>, request: Request, next: Next) -> Response {
    let mut response = next.run(request).await;
    let name = HeaderName::from_static("content-security-policy");
    if !response.headers().contains_key(&name) {
        if let Ok(value) = HeaderValue::from_str(&csp) {
            response.headers_mut().insert(name, value);
        }
    }
    response
}

/// Refuse cookie-authenticated cross-site state changes.
pub async fn csrf_layer(allowed: Arc<Option<String>>, request: Request, next: Next) -> Response {
    if csrf_blocked(
        request.method(),
        request.uri().path(),
        request.headers(),
        allowed.as_deref(),
    ) {
        tracing::warn!(
            method = %request.method(),
            path = %request.uri().path(),
            "blocked a cookie-authenticated request with a missing or foreign Origin"
        );
        return (StatusCode::FORBIDDEN, "cross-site request blocked").into_response();
    }
    next.run(request).await
}

/// Should this request be refused as a cross-site forgery?
///
/// A second line behind `SameSite=Strict` and the absence of CORS. It fires only
/// on an unsafe method that either carries the session cookie or targets the one
/// public POST, `/auth/logout` — so a machine client with a bearer token is
/// never affected. When it fires, `Origin` must be present and exact.
///
/// Safe methods never fire, which is what lets the SSE stream work: an
/// `EventSource` is a GET.
pub fn csrf_blocked(
    method: &Method,
    path: &str,
    headers: &HeaderMap,
    allowed: Option<&str>,
) -> bool {
    if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
        return false;
    }
    let has_session = crate::auth::session_cookie(headers).is_some();
    if !has_session && path != "/auth/logout" {
        return false;
    }
    let origin = headers
        .get(http::header::ORIGIN)
        .and_then(|v| v.to_str().ok());
    match (origin, allowed) {
        (Some(origin), Some(allowed)) => origin != allowed,
        // A cookie-authenticated state change with no Origin at all.
        (None, _) => true,
        // Passkey is off, so there is no real session cookie and nothing to
        // compare against.
        (Some(_), None) => false,
    }
}

// ── Who is asking, and how often ───────────────────────────────────────────

/// **The address a request came from, for the one route that is answered to
/// somebody with no session at all.**
///
/// Two sources, in this order, and the order is the whole of the argument:
///
/// * `x-forwarded-for`'s RIGHTMOST hop that no trusted proxy wrote — **and
///   only when the peer that sent it is a proxy this deployment trusts.** Each
///   proxy APPENDS the address it saw, so the left of the chain is whatever the
///   client typed and only the right is vouched for; walking from the right
///   past loopback and the trusted list stops at the first address a trusted
///   proxy saw connect. Entries that are not addresses are skipped. This
///   console's origin is `https://…` and
///   it terminates no TLS itself, so on a real deployment the peer address is a
///   reverse proxy on loopback and the header is the only client identity that
///   exists. Without it every visitor in the world shares one bucket.
/// * otherwise the peer address, from `ConnectInfo` — which is what `main`
///   installs and what a `trunk serve` box has.
///
/// # Who is a trusted proxy, and why the answer is not "whoever set the header"
///
/// The earlier version read the header from ANY peer, and it was the whole of
/// the rate limiter's key. A caller reaching this console directly could
/// therefore write a different `x-forwarded-for` on every request and be a new
/// address every time — which is not a limiter that can be worn down, it is a
/// limiter that is off. The header is a claim made by the previous hop, and it
/// is worth exactly what that hop is worth.
///
/// So: **loopback always**, because that is what a reverse proxy on the same
/// box is and it is the documented deployment, plus anything named in
/// `GUNNAR_UI_TRUSTED_PROXIES` — a comma-separated list of addresses, for the
/// deployment whose proxy is a second machine. From any other peer the header
/// is ignored entirely and the socket's own address is used, which is a fact
/// about the connection that no header can rewrite.
///
/// A request with no `ConnectInfo` at all gets `unknown` rather than its own
/// header: there is nothing to judge the claim by, and one shared bucket is the
/// honest name for that.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientIp(pub String);

impl<S: Send + Sync> axum::extract::FromRequestParts<S> for ClientIp {
    type Rejection = std::convert::Infallible;

    async fn from_request_parts(
        parts: &mut http::request::Parts,
        _state: &S,
    ) -> Result<ClientIp, std::convert::Infallible> {
        Ok(client_ip(&parts.headers, parts.extensions.get()))
    }
}

/// The addresses this deployment will believe an `x-forwarded-for` from.
///
/// Read once. An entry that is not an address is skipped with a warning rather
/// than failing startup: a typo in a proxy list should narrow what is trusted,
/// never widen it, and a console that refuses to boot over it is a console
/// somebody restarts with the variable removed.
pub(crate) fn trusted_proxies() -> &'static [std::net::IpAddr] {
    static TRUSTED: std::sync::OnceLock<Vec<std::net::IpAddr>> = std::sync::OnceLock::new();
    TRUSTED.get_or_init(|| {
        let Ok(configured) = std::env::var("GUNNAR_UI_TRUSTED_PROXIES") else {
            return Vec::new();
        };
        configured
            .split(',')
            .map(str::trim)
            .filter(|entry| !entry.is_empty())
            .filter_map(|entry| match entry.parse() {
                Ok(address) => Some(address),
                Err(_) => {
                    tracing::warn!(
                        entry,
                        "GUNNAR_UI_TRUSTED_PROXIES has an entry that is not an address; it is \
                         ignored, so that proxy's x-forwarded-for will not be believed"
                    );
                    None
                }
            })
            .collect()
    })
}

/// The decision above, with this process's trusted list.
pub fn client_ip(
    headers: &HeaderMap,
    peer: Option<&axum::extract::ConnectInfo<std::net::SocketAddr>>,
) -> ClientIp {
    client_ip_from(headers, peer, trusted_proxies())
}

/// The decision as a pure function, so it can be tested without a server and
/// without an environment variable.
pub fn client_ip_from(
    headers: &HeaderMap,
    peer: Option<&axum::extract::ConnectInfo<std::net::SocketAddr>>,
    trusted: &[std::net::IpAddr],
) -> ClientIp {
    let peer = peer.map(|peer| peer.0.ip());
    let believable = match peer {
        Some(address) => address.is_loopback() || trusted.contains(&address),
        None => false,
    };
    let forwarded = believable
        .then(|| {
            let hops: Vec<std::net::IpAddr> = headers
                .get_all("x-forwarded-for")
                .iter()
                .filter_map(|v| v.to_str().ok())
                .flat_map(|v| v.split(','))
                .filter_map(|hop| hop.trim().parse().ok())
                .collect();
            hops.into_iter().rev().find(|hop: &std::net::IpAddr| !hop.is_loopback() && !trusted.contains(hop))
        })
        .flatten();
    match (forwarded, peer) {
        (Some(client), _) => ClientIp(client.to_string()),
        (None, Some(peer)) => ClientIp(peer.to_string()),
        (None, None) => ClientIp("unknown".to_owned()),
    }
}

/// One key's tokens, and when they were last counted.
#[derive(Debug)]
struct Bucket {
    tokens: f64,
    seen: Instant,
}

/// **Token buckets by key, capped.** A bucket idle for a minute is full again
/// — the same as no bucket — so those go first; if the map is still crowded
/// the least-recently-seen go, an eighth of the cap at a time, so one O(n)
/// pass pays for `cap / 8` new keys instead of every new key paying a scan
/// under the lock.
///
/// One bucket type for the whole console: the per-route limits below take
/// `per_minute` as both the burst and the minute's refill, and the shared
/// throttle (`crate::throttle`) gives each tier its own burst and refill.
#[derive(Debug)]
pub(crate) struct Buckets {
    /// The most tokens a bucket holds (a new key starts full).
    burst: f64,
    /// Tokens added per second.
    per_sec: f64,
    cap: usize,
    held: HashMap<String, Bucket>,
    /// Entries looked at to make room, for the cost test.
    #[cfg(test)]
    scanned: usize,
}

impl Buckets {
    fn new(per_minute: u32, cap: usize) -> Buckets {
        Buckets::with(f64::from(per_minute), f64::from(per_minute) / 60.0, cap)
    }

    /// `burst` tokens at most, `per_sec` back each second, at most `cap` keys held.
    pub(crate) fn with(burst: f64, per_sec: f64, cap: usize) -> Buckets {
        Buckets {
            burst: burst.max(0.0),
            per_sec: per_sec.max(0.0),
            cap: cap.max(1),
            held: HashMap::new(),
            #[cfg(test)]
            scanned: 0,
        }
    }

    /// Spend one token from `key`'s bucket, refilling it for the time passed.
    fn take(&mut self, key: &str, now: Instant) -> bool {
        if self.check(key, now).is_err() {
            return false;
        }
        self.spend(key);
        true
    }

    /// Refill `key`'s bucket for the time passed and say whether it holds a
    /// token, or how many whole seconds (at least one) until it does. Takes
    /// nothing: a caller deciding over several buckets spends from all of them
    /// or none ([`Buckets::spend`]).
    pub(crate) fn check(&mut self, key: &str, now: Instant) -> Result<(), u64> {
        if self.held.len() >= self.cap && !self.held.contains_key(key) {
            self.make_room(now);
        }
        let (burst, per_sec) = (self.burst, self.per_sec);
        let bucket = self.held.entry(key.to_owned()).or_insert(Bucket { tokens: burst, seen: now });
        let elapsed = now.saturating_duration_since(bucket.seen).as_secs_f64();
        bucket.tokens = (bucket.tokens + elapsed * per_sec).min(burst);
        bucket.seen = now;
        if bucket.tokens >= 1.0 {
            return Ok(());
        }
        if per_sec <= 0.0 {
            return Err(3600);
        }
        Err((((1.0 - bucket.tokens) / per_sec).ceil() as u64).max(1))
    }

    /// Spend one token from `key`'s bucket (after [`Buckets::check`] said it has one).
    pub(crate) fn spend(&mut self, key: &str) {
        if let Some(bucket) = self.held.get_mut(key) {
            bucket.tokens = (bucket.tokens - 1.0).max(0.0);
        }
    }

    /// Keys held.
    #[cfg(test)]
    pub(crate) fn len(&self) -> usize {
        self.held.len()
    }

    fn make_room(&mut self, now: Instant) {
        #[cfg(test)]
        {
            self.scanned += self.held.len();
        }
        // A bucket idle this long is full again, the same as no bucket.
        let full_again = if self.per_sec > 0.0 { std::time::Duration::from_secs_f64((self.burst / self.per_sec).min(86_400.0)) } else { std::time::Duration::from_secs(86_400) };
        self.held.retain(|_, b| now.saturating_duration_since(b.seen) < full_again);
        let keep = self.cap - self.cap / 8;
        if self.held.len() > keep {
            let mut seen: Vec<Instant> = self.held.values().map(|b| b.seen).collect();
            let cut = seen.len() - keep;
            let (_, oldest_kept, _) = seen.select_nth_unstable(cut);
            let oldest_kept = *oldest_kept;
            self.held.retain(|_, b| b.seen >= oldest_kept);
            let mut ties = self.held.len().saturating_sub(keep);
            self.held.retain(|_, b| {
                let drop = ties > 0 && b.seen == oldest_kept;
                ties -= usize::from(drop);
                !drop
            });
        }
    }
}

/// **One token bucket per key** — a per-address limit on a route anybody may
/// call (single sign-on, SCIM), capped like [`RateLimit`].
#[derive(Debug)]
pub struct KeyLimit {
    held: Mutex<Buckets>,
}

impl KeyLimit {
    /// `per_minute` requests a minute for one key, at most `cap` keys held.
    pub fn new(per_minute: u32, cap: usize) -> KeyLimit {
        KeyLimit { held: Mutex::new(Buckets::new(per_minute, cap)) }
    }

    /// May `key` ask right now?
    pub fn allow(&self, key: &str) -> bool {
        self.held.lock().unwrap_or_else(|p| p.into_inner()).take(key, Instant::now())
    }

    /// Keys held, and the most it holds.
    #[cfg(all(test, feature = "sso"))]
    pub(crate) fn held(&self) -> (usize, usize) {
        let b = self.held.lock().expect("the rate-limit map is not poisoned");
        (b.held.len(), b.cap)
    }
}

/// **A two-tier token bucket for an unauthenticated polling route.**
///
/// There was no limiter in this console before this: every route either needs
/// a session or is answered by the grantless guest identity, and neither is
/// worth a bucket. The paid-signup status route is the first that is both
/// anonymous and interesting to guess at, so this is written for exactly its
/// shape and not as a general-purpose middleware.
///
/// # Why one bucket per IP would refuse the page it is protecting
///
/// The welcome page polls **every two seconds** — thirty requests a minute for
/// ONE honest visitor, and every one of them about the same session. A flat
/// "30 a minute per IP" is therefore not a limit on abuse, it is a coin flip on
/// whether a paying customer's page keeps working, and behind a proxy that
/// puts every visitor in the world in one bucket it is worse than that.
///
/// So there are two:
///
/// * **per `(ip, session)`**, [`Self::per_session`] a minute. This is what an
///   honest visitor spends, and it is generous because polling is the design.
///   It cannot be spent on somebody else's behalf: the key contains the
///   vendor's session id, which is opaque and high-entropy.
/// * **per `ip`, charged ONLY for a session that IP has not asked about
///   before**, [`Self::new_sessions`] a minute. This is the one that bites a
///   guesser, because every guess is by definition a new session id — and it
///   costs an honest visitor exactly one token, once.
///
/// The maps are capped ([`Buckets`]), so an attacker's own traffic cannot grow
/// them without bound, and both tiers are decided under one lock.
#[derive(Debug)]
pub struct RateLimit {
    held: Mutex<Tiers>,
    /// A test's stopped clock: the buckets refill by wall time, so a test that
    /// counts refusals would otherwise count how slow a loaded host is.
    #[cfg(test)]
    frozen: Mutex<Option<Instant>>,
}

#[derive(Debug)]
struct Tiers {
    pairs: Buckets,
    ips: Buckets,
}

impl RateLimit {
    /// `per_session` requests a minute for one (address, session), and
    /// `new_sessions` distinct sessions a minute from one address.
    pub fn new(per_session: u32, new_sessions: u32) -> RateLimit {
        RateLimit {
            held: Mutex::new(Tiers {
                pairs: Buckets::new(per_session, 4096),
                ips: Buckets::new(new_sessions, 4096),
            }),
            #[cfg(test)]
            frozen: Mutex::new(None),
        }
    }

    /// Stop this limiter's clock at now (tests only): no refill happens while
    /// the test counts.
    #[cfg(all(test, feature = "metered"))]
    pub(crate) fn freeze(&self) {
        *self.frozen.lock().unwrap_or_else(|p| p.into_inner()) = Some(Instant::now());
    }

    fn now(&self) -> Instant {
        #[cfg(test)]
        if let Some(at) = *self.frozen.lock().unwrap_or_else(|p| p.into_inner()) {
            return at;
        }
        Instant::now()
    }

    fn tiers(&self) -> std::sync::MutexGuard<'_, Tiers> {
        self.held.lock().unwrap_or_else(|p| p.into_inner())
    }

    /// Bytes of key the limiter holds, both maps.
    #[cfg(all(test, feature = "metered"))]
    pub(crate) fn key_bytes(&self) -> usize {
        let t = self.tiers();
        t.pairs.held.keys().chain(t.ips.held.keys()).map(String::len).sum()
    }

    /// May this address ask about this session right now? Both tiers are
    /// decided under one lock: two requests for the same new session cannot
    /// both be "new".
    pub fn allow(&self, ip: &str, session: &str) -> bool {
        // `\u{1f}` (unit separator) cannot appear in either half: an address is
        // printable and a session id is `[A-Za-z0-9_]` by the time this is
        // called, so no pair can be spelled two ways.
        let pair = format!("{ip}\u{1f}{session}");
        let now = self.now();
        let mut t = self.tiers();
        let fresh = !t.pairs.held.contains_key(&pair);
        if fresh && !t.ips.take(ip, now) {
            return false;
        }
        t.pairs.take(&pair, now)
    }
}

/// **An address from upstream that this console may send a browser to** —
/// an identity provider's, a checkout's: `https://` with a host, no
/// credentials in it, nothing that is not plain printable ASCII. Anything
/// else (`http://`, `javascript:`, `//host`, `https://user@host`) is `None`:
/// the caller refuses instead of redirecting off its own domain.
pub fn https_url(raw: &str) -> Option<&str> {
    if raw.is_empty() || raw.len() > 4096 || !raw.bytes().all(|b| b.is_ascii_graphic()) {
        return None;
    }
    let uri: http::Uri = raw.parse().ok()?;
    let authority = uri.authority()?;
    let host_ok = !authority.host().is_empty() && !authority.as_str().contains('@');
    (uri.scheme_str() == Some("https") && host_ok).then_some(raw)
}

/// **monetize's link to a vendor, as a browser may be given it** — the pay
/// button's href, `location.assign`'s argument: empty (no customer step yet)
/// or an [`https_url`]. `None` is a link the caller must refuse, never pass on.
#[cfg(feature = "metered")]
pub fn checked_pay_url(raw: String) -> Option<String> {
    (raw.is_empty() || https_url(&raw).is_some()).then_some(raw)
}

/// The `sha256-…` tokens for every inline `<script>` in `html`.
///
/// The browser hashes the exact bytes between the opening and closing tags, so
/// hashing trunk's generated bootstrap here is what lets `script-src` ship
/// without `'unsafe-inline'`. Positions are found on an ASCII-lowercased copy of
/// the same byte length; the original bytes are what get hashed.
pub fn inline_script_hashes(html: &str) -> Vec<String> {
    use base64::Engine as _;
    use sha2::{Digest, Sha256};

    let lower = html.to_ascii_lowercase();
    let mut hashes = Vec::new();
    let mut at = 0usize;
    while let Some(offset) = lower[at..].find("<script") {
        let tag_start = at + offset;
        let Some(gt) = lower[tag_start..].find('>') else {
            break;
        };
        let open_end = tag_start + gt + 1;
        let open_tag = &lower[tag_start..open_end];
        let Some(close_offset) = lower[open_end..].find("</script>") else {
            break;
        };
        let body_end = open_end + close_offset;
        let body = &html[open_end..body_end];
        if !open_tag.contains("src=") && !body.trim().is_empty() {
            hashes.push(format!(
                "sha256-{}",
                base64::engine::general_purpose::STANDARD.encode(Sha256::digest(body.as_bytes()))
            ));
        }
        at = body_end + "</script>".len();
    }
    hashes
}

/// Build the policy, given the inline scripts this deployment actually serves.
pub fn build_csp(script_hashes: &[String]) -> String {
    let mut script = String::from("'self' 'wasm-unsafe-eval'");
    for hash in script_hashes {
        script.push_str(" '");
        script.push_str(hash);
        script.push('\'');
    }
    format!(
        "default-src 'none'; base-uri 'none'; frame-src 'none'; frame-ancestors 'none'; \
         form-action 'self'; object-src 'none'; img-src 'self' data:; font-src 'self'; \
         style-src 'self' 'unsafe-inline'; connect-src 'self'; \
         script-src {script}"
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A request that carries a session cookie, spelled the way the console
    /// spells it.
    ///
    /// Built from [`crate::auth::SESSION_COOKIE`] rather than from a literal:
    /// the name gained its `__Host-` prefix in one commit and this fixture is
    /// where a hardcoded copy of it was found — with the wrong name here, every
    /// assertion about a COOKIE-authorised request is an assertion about a
    /// request that carries no cookie at all, and the CSRF layer passes it
    /// because there is nothing to forge.
    fn cookie_headers() -> HeaderMap {
        let mut headers = HeaderMap::new();
        headers.insert(
            http::header::COOKIE,
            format!("{}=abc.def", crate::auth::SESSION_COOKIE)
                .parse()
                .expect("a header"),
        );
        headers
    }

    #[test]
    fn a_safe_method_never_fires() {
        // The SSE stream is a GET carrying the session cookie; blocking it here
        // would kill the live feed.
        let mut headers = cookie_headers();
        headers.insert(
            http::header::ORIGIN,
            "https://evil.test".parse().expect("a header"),
        );

        assert!(!csrf_blocked(
            &Method::GET,
            "/events",
            &headers,
            Some("https://console.example")
        ));
    }

    #[test]
    fn a_foreign_origin_on_a_cookie_post_is_blocked() {
        let mut headers = cookie_headers();
        headers.insert(
            http::header::ORIGIN,
            "https://evil.test".parse().expect("a header"),
        );

        assert!(csrf_blocked(
            &Method::POST,
            "/api/repos",
            &headers,
            Some("https://console.example")
        ));
    }

    #[test]
    fn the_configured_origin_passes() {
        let mut headers = cookie_headers();
        headers.insert(
            http::header::ORIGIN,
            "https://console.example".parse().expect("a header"),
        );

        assert!(!csrf_blocked(
            &Method::POST,
            "/api/repos",
            &headers,
            Some("https://console.example")
        ));
    }

    #[test]
    fn a_missing_origin_on_a_cookie_post_is_a_forgery() {
        assert!(csrf_blocked(
            &Method::POST,
            "/api/repos",
            &cookie_headers(),
            Some("https://console.example")
        ));
    }

    #[test]
    fn a_client_without_the_cookie_is_untouched() {
        assert!(!csrf_blocked(
            &Method::POST,
            "/api/repos",
            &HeaderMap::new(),
            Some("https://console.example")
        ));
    }

    #[test]
    fn logout_is_guarded_even_without_a_cookie() {
        // The one public POST: otherwise a cross-site page can force a logout.
        assert!(csrf_blocked(
            &Method::POST,
            "/auth/logout",
            &HeaderMap::new(),
            Some("https://console.example")
        ));
    }

    /// ★ **An honest visitor polls for three minutes and is never refused.**
    ///
    /// The welcome page polls every two seconds and gives up after three
    /// minutes: ninety requests about ONE session from ONE address. A flat
    /// per-IP limiter at thirty a minute would refuse the fourth minute of a
    /// page it exists to protect, which is why the honest tier is keyed by the
    /// session as well.
    #[test]
    fn a_visitor_polling_one_session_is_not_refused() {
        let limit = RateLimit::new(120, 30);

        for poll in 0..90 {
            assert!(
                limit.allow("198.51.100.7", "cs_test_abcdefghij"),
                "the page's own polling was refused at request {poll}"
            );
        }
    }

    /// **And a guesser is refused after the budget, because every guess is a
    /// session that address has not asked about before.**
    #[test]
    fn an_address_guessing_session_ids_runs_out() {
        let limit = RateLimit::new(120, 30);

        for guess in 0..30 {
            assert!(
                limit.allow("203.0.113.9", &format!("cs_test_guess{guess:06}")),
                "the budget is 30 and guess {guess} was refused"
            );
        }

        assert!(
            !limit.allow("203.0.113.9", "cs_test_guess999999"),
            "an address guessed a 31st session id in one minute"
        );
    }

    /// The two tiers are independent: a guesser burning their own new-session
    /// budget does not stop a visitor who is already polling.
    #[test]
    fn a_burnt_new_session_budget_does_not_stop_a_poll_in_progress() {
        let limit = RateLimit::new(120, 5);
        // The visitor's first poll, which is what registers the pair.
        assert!(limit.allow("198.51.100.7", "cs_live_realsession"));
        // Somebody on the same address (a proxy, or a forged header) empties
        // the new-session tier.
        for guess in 0..10 {
            limit.allow("198.51.100.7", &format!("cs_test_guess{guess:06}"));
        }

        assert!(
            limit.allow("198.51.100.7", "cs_live_realsession"),
            "a visitor mid-signup was locked out by somebody else's guessing"
        );
    }

    #[test]
    fn the_remembered_keys_stay_bounded() {
        let limit = RateLimit::new(120, 100_000);
        for guess in 0..6_000 {
            limit.allow("203.0.113.9", &format!("cs_test_guess{guess:06}"));
        }

        assert!(
            {
                let t = limit.tiers();
                t.pairs.held.len() <= t.pairs.cap
            },
            "an attacker's own traffic grew the map without bound"
        );
    }

    /// ★ **A flood of new keys costs the limiter little per key** (stinky
    /// 2026-10-10 #14). Every new key past the cap scanned the whole map,
    /// under the lock, for the one to evict: a key flood was a CPU amplifier
    /// on the very route the limiter guards.
    #[test]
    fn a_flood_of_new_keys_costs_the_limiter_little_per_key() {
        let limit = RateLimit::new(120, 1_000_000);
        let cap = limit.tiers().pairs.cap;
        for n in 0..cap {
            limit.allow("203.0.113.9", &format!("cs_test_fill{n:06}"));
        }
        let before = limit.tiers().pairs.scanned;
        for n in 0..10_000 {
            limit.allow("203.0.113.9", &format!("cs_test_flood{n:06}"));
        }
        let per_key = (limit.tiers().pairs.scanned - before) / 10_000;
        assert!(per_key <= 64, "each new key scanned {per_key} entries under the lock");
    }

    /// A bench, not a check: new keys a second against a full limiter.
    /// `cargo test --release -p gunnar-ui-server --lib bench_ -- --ignored --nocapture`
    #[test]
    #[ignore = "a bench"]
    fn bench_a_key_flood_against_a_full_limiter() {
        let limit = RateLimit::new(120, 1_000_000);
        for n in 0..4096 {
            limit.allow("203.0.113.9", &format!("cs_test_fill{n:06}"));
        }
        let keys: Vec<String> = (0..200_000).map(|n| format!("cs_test_flood{n:06}")).collect();
        let started = Instant::now();
        for key in &keys {
            limit.allow("203.0.113.9", key);
        }
        let took = started.elapsed();
        println!(
            "BENCH ratelimit_key_flood keys={} total_ms={:.1} ns_per_key={:.0}",
            keys.len(),
            took.as_secs_f64() * 1e3,
            took.as_secs_f64() * 1e9 / keys.len() as f64
        );
    }

    #[test]
    fn the_forwarded_client_beats_the_peer_and_absence_is_named() {
        let mut headers = HeaderMap::new();
        let peer = axum::extract::ConnectInfo(std::net::SocketAddr::from(([127, 0, 0, 1], 40000)));
        let lb: std::net::IpAddr = "10.0.0.1".parse().expect("an address");

        assert_eq!(
            client_ip_from(&headers, Some(&peer), &[lb]),
            ClientIp("127.0.0.1".to_owned())
        );
        assert_eq!(client_ip_from(&headers, None, &[lb]), ClientIp("unknown".to_owned()));

        headers.insert(
            "x-forwarded-for",
            "198.51.100.7, 10.0.0.1".parse().expect("a header"),
        );
        assert_eq!(
            client_ip_from(&headers, Some(&peer), &[lb]),
            ClientIp("198.51.100.7".to_owned()),
            "behind a proxy the peer is the proxy, so every visitor would share one bucket"
        );
    }

    /// ★ **A caller who reaches this console directly cannot rename
    /// themselves.**
    ///
    /// **Seen red.** `client_ip` read `x-forwarded-for` from any peer at all,
    /// and that value IS the rate limiter's key — so a caller connecting
    /// straight to the console was a different address on every request, and
    /// the limiter on the unauthenticated signup route was not a limiter that
    /// could be worn down but one that was off. The header is a claim made by
    /// the previous hop; it is worth what that hop is worth.
    #[test]
    fn a_forwarded_header_from_an_untrusted_peer_is_ignored() {
        let mut headers = HeaderMap::new();
        headers.insert("x-forwarded-for", "203.0.113.99".parse().expect("a header"));
        let stranger =
            axum::extract::ConnectInfo(std::net::SocketAddr::from(([198, 51, 100, 4], 51000)));

        assert_eq!(
            client_ip_from(&headers, Some(&stranger), &[]),
            ClientIp("198.51.100.4".to_owned()),
            "a direct caller renamed themselves with a header they wrote"
        );

        // And a header from a peer this deployment DID name is believed, which
        // is what keeps the fix from turning every visitor behind a second-box
        // proxy into one bucket.
        let named: std::net::IpAddr = "198.51.100.4".parse().expect("an address");
        assert_eq!(
            client_ip_from(&headers, Some(&stranger), &[named]),
            ClientIp("203.0.113.99".to_owned())
        );
    }

    /// ★ **A proxy that appends cannot be talked past** (stinky 2026-10-10
    /// #4): the FIRST `x-forwarded-for` entry is whatever the client wrote
    /// before the proxy appended the address it saw. Walked from the right,
    /// the first hop that is not a trusted proxy is the client.
    #[test]
    fn the_client_is_the_rightmost_hop_no_trusted_proxy_wrote() {
        let loopback = axum::extract::ConnectInfo(std::net::SocketAddr::from(([127, 0, 0, 1], 40000)));
        let lb: std::net::IpAddr = "10.0.0.2".parse().expect("an address");
        for (header, trusted, client) in [
            ("1.2.3.4, 203.0.113.5", &[][..], "203.0.113.5"),
            ("1.2.3.4, 203.0.113.5, 10.0.0.2", &[lb][..], "203.0.113.5"),
            ("not-an-address, 203.0.113.5", &[][..], "203.0.113.5"),
            ("203.0.113.5, 127.0.0.1", &[][..], "203.0.113.5"),
        ] {
            let mut headers = HeaderMap::new();
            headers.insert("x-forwarded-for", header.parse().expect("a header"));
            assert_eq!(client_ip_from(&headers, Some(&loopback), trusted), ClientIp(client.to_owned()), "{header}");
        }
    }

    /// With no `ConnectInfo` there is nothing to judge the claim by, so the
    /// header is not believed either. One shared bucket, named as one.
    #[test]
    fn a_request_with_no_peer_is_not_taken_at_its_word() {
        let mut headers = HeaderMap::new();
        headers.insert("x-forwarded-for", "203.0.113.99".parse().expect("a header"));

        assert_eq!(
            client_ip_from(&headers, None, &[]),
            ClientIp("unknown".to_owned())
        );
    }

    #[test]
    fn an_inline_script_is_hashed_and_a_sourced_one_is_not() {
        let html = r#"<script src="/app.js"></script><script>console.log(1)</script>"#;
        let hashes = inline_script_hashes(html);

        assert_eq!(hashes.len(), 1);
        assert!(hashes[0].starts_with("sha256-"));
    }

    #[test]
    fn hashing_is_case_sensitive_about_the_body() {
        // The tag is matched case-insensitively; the body must not be, or the
        // hash will not match what the browser computes.
        let lower = inline_script_hashes("<script>let A = 1;</script>");
        let upper = inline_script_hashes("<SCRIPT>let a = 1;</SCRIPT>");

        assert_eq!(lower.len(), 1);
        assert_eq!(upper.len(), 1);
        assert_ne!(lower[0], upper[0]);
    }

    #[test]
    fn an_empty_inline_script_is_skipped() {
        assert!(inline_script_hashes("<script>  </script>").is_empty());
    }

    #[test]
    fn the_policy_never_allows_inline_script() {
        let csp = build_csp(&["sha256-abc".to_owned()]);

        assert!(csp.contains("script-src 'self' 'wasm-unsafe-eval' 'sha256-abc'"));
        assert!(
            !csp.contains("script-src")
                || !csp
                    .split("script-src")
                    .nth(1)
                    .unwrap()
                    .contains("'unsafe-inline'"),
            "an inline-script allowance would make the hashes pointless"
        );
        assert!(csp.contains("connect-src 'self';"), "{csp}");
        assert!(
            !csp.contains("127.0.0.1") && !csp.contains("localhost") && !csp.contains("http://*") && !csp.contains("https://*"),
            "the console calls its own origin and nothing else (the loopback helper is gone)"
        );
        assert!(csp.contains("frame-ancestors 'none'"));
    }

    #[test]
    fn the_policy_allows_inline_style_on_purpose() {
        // Leptos writes `style=` attributes, which no nonce or hash can cover.
        assert!(build_csp(&[]).contains("style-src 'self' 'unsafe-inline'"));
    }

    // ── What may be written down, and by whom (GUI-010, GUI-022) ────────────

    /// ★ **Nothing shaped by the session that asked may be held in between.**
    ///
    /// **Seen red.** The `HEADERS` list carried neither `Cache-Control` nor
    /// `Vary`, so not one response in this console said anything at all about
    /// caching. Every answer under `/api/` is shaped by who asked — the
    /// repository catalogue, a file's contents, a namespace's usage — and an
    /// intermediary holding one had no way to know that, because nothing told
    /// it.
    #[test]
    fn an_identity_bearing_answer_says_it_may_not_be_written_down() {
        for path in [
            "/api/repos",
            "/api/repos/ns/name/browse",
            "/api/namespaces/alpha/usage",
            "/auth/status",
            "/login",
            "/login/repos",
        ] {
            let mut headers = HeaderMap::new();
            apply_cache_policy(path, &mut headers);

            assert_eq!(
                headers
                    .get(http::header::CACHE_CONTROL)
                    .map(|v| v.as_bytes()),
                Some(&b"no-store"[..]),
                "{path} may be cached"
            );
            assert_eq!(
                headers.get(http::header::VARY).map(|v| v.as_bytes()),
                Some(&b"cookie"[..]),
                "{path} does not vary by who asked"
            );
        }
    }

    /// And the console's own bundle keeps none of it: it is the same bytes for
    /// everybody, it is 1.4 MB of wasm, and a `no-store` on it would undo the
    /// reason `serve::console_files` compresses it.
    #[test]
    fn the_static_console_is_still_cacheable() {
        for path in ["/", "/index.html", "/gunnar-ui-web_bg.wasm", "/app.css"] {
            let mut headers = HeaderMap::new();
            apply_cache_policy(path, &mut headers);

            assert!(headers.is_empty(), "{path} was made uncacheable");
        }
    }

    /// ★ **The layer really installs both, and HSTS only where there is TLS.**
    ///
    /// Through the middleware rather than by calling the helpers, because the
    /// finding is about what goes out on the wire and a helper nobody calls
    /// sends nothing.
    #[tokio::test]
    async fn the_layer_sends_the_policy_and_hsts_only_when_it_is_true() {
        use tower::ServiceExt as _;

        let build = |hsts: bool| {
            axum::Router::new()
                .route("/api/repos", axum::routing::get(|| async { "[]" }))
                .layer(axum::middleware::from_fn(move |request, next| {
                    security_headers_layer(hsts, request, next)
                }))
        };
        let ask = |app: axum::Router| async move {
            app.oneshot(
                http::Request::builder()
                    .uri("/api/repos")
                    .body(axum::body::Body::empty())
                    .expect("a request"),
            )
            .await
            .expect("a response")
        };

        let secure = ask(build(true)).await;
        assert_eq!(
            secure
                .headers()
                .get(http::header::STRICT_TRANSPORT_SECURITY)
                .and_then(|v| v.to_str().ok()),
            Some("max-age=31536000; includeSubDomains")
        );
        assert_eq!(
            secure
                .headers()
                .get(http::header::CACHE_CONTROL)
                .and_then(|v| v.to_str().ok()),
            Some("no-store"),
            "the layer decorates the headers but not the caching"
        );
        // The static set is still there: this is an addition, not a swap.
        assert!(secure.headers().contains_key("x-content-type-options"));

        let plain = ask(build(false)).await;
        assert!(
            !plain
                .headers()
                .contains_key(http::header::STRICT_TRANSPORT_SECURITY),
            "a console with no TLS told a browser to insist on TLS"
        );
    }

    #[test]
    fn the_passkey_ceremony_is_not_disabled_by_permissions_policy() {
        let mut headers = HeaderMap::new();
        apply_security_headers(&mut headers);
        let policy = headers
            .get("permissions-policy")
            .and_then(|v| v.to_str().ok())
            .expect("the header is set");

        assert!(
            !policy.contains("publickey-credentials"),
            "restricting these would break login"
        );
    }
}