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"
);
}
}